From 9a334992ad47961c9529daf0b996b1bd764ad8b1 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 12 Apr 2026 20:07:23 -0700 Subject: [PATCH] Replace Claude with Pi as the AI agent --- CLAUDE.md | 38 ++++++++++---------- src/commands/list.ts | 24 ++----------- src/vm.ts | 84 +++++++++++++++++++++++++++----------------- 3 files changed, 74 insertions(+), 72 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c86126a..66fda05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ Each module has a single responsibility. No classes — only exported functions 2. Creates symlink `/.sandlot/` → worktree path 3. `vm.ensure()` → start/create/provision the container 4. `state.setSession()` → write to `.sandlot/state.json` -5. `vm.claude()` → launch Claude Code in container at worktree path +5. `vm.pi()` → launch Pi in container at worktree path 6. `saveChanges()` → auto-save on exit (stage all, AI-generated commit message) unless `--no-save` **Worktree location**: `~/.sandlot///` (outside the repo) @@ -85,11 +85,13 @@ Each module has a single responsibility. No classes — only exported functions - Mounts: `~/dev` **read-only** at `/host`, `~/.sandlot` read-write at `/sandlot` - Host symlinks: creates `~/dev` → `/host` and `~/.sandlot` → `/sandlot` inside the container so host-absolute worktree paths resolve correctly - `containerPath()` in `vm.ts` translates host paths to container paths (`~/.sandlot/…` → `/sandlot/…`, `~/dev/…` → `/host/…`) -- Provisioned once on first use: apt installs `curl git fish unzip`, then installs Bun, Claude Code, neofetch, and Neovim -- Binary caching: after first install, binaries are cached in `~/.sandlot/.cache/` (bun, claude, neofetch, nvim.tar.gz) — subsequent `vm create` uses cached copies, skipping downloads -- API key: read from `~/.env` on host (`ANTHROPIC_API_KEY=...`), written to a temp file and copied as `~/.claude/api-key-helper.sh` in the container (never passed as a process argument) -- Claude settings: `skipDangerousModePermissionPrompt: true`, activity tracking hooks (`UserPromptSubmit` / `Stop`) in container -- Also writes `~/.claude.json` with `hasCompletedOnboarding: true` and `effortCalloutDismissed: true` +- Provisioned once on first use: apt installs `curl git fish unzip`, then installs Bun, neofetch, and Neovim +- Binary caching: after first install, bun/neofetch/nvim are cached in `~/.sandlot/.cache/` — subsequent `vm create` uses cached copies, skipping downloads +- Persistent tooling: Pi, Rust, Go, and sccache are installed to `/sandlot/` paths that survive container recreates +- Pi is installed as a standalone binary from GitHub releases (`pi-linux-arm64.tar.gz`) to `/sandlot/.pi-bin/` +- API key: read from `~/.env` on host (`ANTHROPIC_API_KEY=...`), written to `~/.pi/agent/auth.json` in the container via temp file (never passed as a process argument) +- Pi settings: `~/.pi/agent/settings.json` with `defaultProvider: anthropic`, `defaultModel: claude-opus-4-6`, `defaultThinkingLevel: high`, `quietStartup: true` +- Activity tracking: a Pi extension at `~/.pi/agent/extensions/sandlot-activity.ts` writes activity markers on `before_agent_start` and `tool_call` events ## Shell Command Pattern @@ -138,23 +140,21 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t ## Key Implementation Notes -- `vm.exec()` prepends `export PATH=$HOME/.local/bin:$PATH` so `claude` binary is found -- `vm.claude()` uses `Bun.spawn` with `stdin/stdout/stderr: "inherit"` for interactive TTY; in print mode (`-p`), captures stdout via pipe and returns the output -- `vm.claude()` runs Claude with `--dangerously-skip-permissions`, `--model claude-opus-4-6`, and `--append-system-prompt` (system prompt describes the container environment) -- `vm.claude()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue` -- `vm.claudePipe()` writes input to a temp file in `~/.sandlot/`, pipes it to `claude -p` inside the container, and returns the result — used for commit message generation and conflict resolution -- `vm.isClaudeActive()` reads activity marker files written by the in-container `sandlot-activity` hook script +- `vm.pi()` uses `Bun.spawn` with `stdin/stdout/stderr: "inherit"` for interactive TTY; in print mode (`-p`), captures stdout via pipe and returns the output +- `vm.pi()` runs Pi with `--append-system-prompt` (system prompt describes the container environment); model and thinking level are configured via `~/.pi/agent/settings.json` +- `vm.pi()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue` +- `vm.piPipe()` writes input to a temp file in `~/.sandlot/`, pipes it to `pi -p` inside the container, and returns the result — used for commit message generation and conflict resolution +- `vm.isPiActive()` reads activity marker files written by the in-container `sandlot-activity.ts` Pi extension - Branch creation in `createWorktree()` handles three cases: local branch, remote branch (tracks origin), new branch from HEAD - `sandlot new` accepts a prompt instead of a branch name — derives a 2-word branch name via the Anthropic API (Haiku), falling back to simple text munging -- `sandlot open` always passes `continue: true` to `vm.claude()` to resume the previous conversation -- `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff -- `sandlot merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically -- `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `claudePipe()`; falls back to `"squash "`. Rolls back to the original HEAD on failure. +- `sandlot open` always passes `continue: true` to `vm.pi()` to resume the previous conversation +- `sandlot save` uses `vm.piPipe()` to generate commit messages from the staged diff +- `sandlot merge` and `sandlot rebase` use `vm.piPipe()` to resolve merge conflicts automatically +- `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `piPipe()`; falls back to `"squash "`. Rolls back to the original HEAD on failure. - `sandlot merge` delegates to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up -- `sandlot list` discovers missing session prompts by parsing Claude's `history.jsonl` from inside the container - `sandlot list` shows five status icons: idle (dim `◯`), active (cyan `◎`), dirty/unsaved (yellow `◐`), saved (green `●`), review (magenta `⦿`) -- `sandlot review` sets `in_review` on the session during the review and clears it in a `finally` block on exit; `list` detects stale `in_review` flags (Claude not active) and clears them from state -- `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`) +- `sandlot review` sets `in_review` on the session during the review and clears it in a `finally` block on exit; `list` detects stale `in_review` flags (Pi not active) and clears them from state +- `sandlot new` and `sandlot open` auto-save changes when Pi exits (disable with `--no-save`) - `sandlot close` has a hidden `rm` alias - Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty) - `.sandlot/` should be in the repo's `.gitignore` diff --git a/src/commands/list.ts b/src/commands/list.ts index 1659edd..5999754 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,5 +1,4 @@ import { basename } from "path" -import { homedir } from "os" import { stat } from "fs/promises" import * as git from "../git.ts" import * as vm from "../vm.ts" @@ -75,26 +74,9 @@ async function clearStaleReviews( } } -async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) { - if (!vmRunning) return - const needsPrompt = sessions.filter(s => !s.prompt) - if (needsPrompt.length === 0) return - const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.pi/history.jsonl 2>/dev/null").catch(() => null) - if (!result || result.exitCode !== 0 || !result.stdout) return - - const byProject = new Map() - for (const line of result.stdout.split("\n")) { - if (!line) continue - try { - const e = JSON.parse(line) - if (e.project && e.display) byProject.set(e.project, e.display) - } catch {} - } - - for (const s of needsPrompt) { - const display = byProject.get(vm.containerPath(s.worktree)) - if (display) s.prompt = display - } +async function backfillPrompts(_sessions: { worktree: string; prompt?: string }[], _vmRunning: boolean) { + // Pi doesn't maintain a history.jsonl like Claude did. + // Prompts are populated from state.json at session creation time. } // ── Command ────────────────────────────────────────────────────────── diff --git a/src/vm.ts b/src/vm.ts index c831ed4..0617e3a 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -9,7 +9,7 @@ import { get as getConfig, DEFAULTS, validateMemory } from "./config.ts" const DEBUG = !!process.env.DEBUG const CONTAINER_NAME = "sandlot" const USER = "ubuntu" -const PI_BIN = `/home/${USER}/.local/bin/pi` +const PI_BIN = `/sandlot/.pi-bin/pi/pi` const CONTAINER_PATH = `/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` const CONTAINER_ENV = { RUSTUP_HOME: "/sandlot/.rustup", @@ -107,12 +107,12 @@ const CACHE_DIR = join(homedir(), '.sandlot', '.cache') /** Check whether the package cache is populated. */ async function hasCachedTooling(): Promise { - const files = ['bun', 'pi', 'neofetch', 'nvim.tar.gz'] + const files = ['bun', 'neofetch', 'nvim.tar.gz'] const checks = await Promise.all(files.map(f => Bun.file(join(CACHE_DIR, f)).exists())) return checks.every(Boolean) } -/** Install Bun, Pi, neofetch, and Neovim using cached binaries when available. */ +/** Install Bun, neofetch, and Neovim using cached binaries when available. Pi is installed persistently. */ async function installTooling(cached: boolean, log?: (msg: string) => void): Promise { // Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container) await $`mkdir -p ${CACHE_DIR}`.quiet() @@ -123,7 +123,7 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`, "Create bin directory") await run( - $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/pi /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/pi ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`, + $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`, "Install cached binaries") await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, @@ -136,11 +136,6 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro $`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`, "Bun installation") - log?.("Installing Pi") - await run( - $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://pi.ai/install.sh | bash"}`, - "Pi installation") - log?.("Installing neofetch") await run( $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch"}`, @@ -152,13 +147,22 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro "Neovim installation") // Cache binaries for next time - await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/pi ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet() + await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet() await installPersistentTooling(log) } -/** Install Rust and Go to /sandlot/ so they persist across container recreates. */ +/** Install Pi, Rust, and Go to /sandlot/ so they persist across container recreates. */ async function installPersistentTooling(log?: (msg: string) => void): Promise { + // Pi — skip if already installed on the persistent mount + const hasPi = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.pi-bin/pi/pi`.nothrow().quiet() + if (hasPi.exitCode !== 0) { + log?.("Installing Pi") + await run( + $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p /sandlot/.pi-bin && curl -fsSL https://github.com/badlogic/pi-mono/releases/latest/download/pi-linux-arm64.tar.gz | tar xz -C /sandlot/.pi-bin"}`, + "Pi installation") + } + // Rust — skip if already installed on the persistent mount const hasRust = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.cargo/bin/rustc`.nothrow().quiet() if (hasRust.exitCode !== 0) { @@ -212,37 +216,53 @@ async function installScript(home: string, name: string, content: string): Promi await Bun.file(tmp).unlink() } -/** Configure git identity, API key helper, activity hook, and Pi settings. */ +/** Configure git identity, API key (via auth.json), activity extension, and Pi settings. */ async function configureEnvironment(home: string, apiKey: string): Promise { const gitName = (await $`git config user.name`.quiet().text()).trim() const gitEmail = (await $`git config user.email`.quiet().text()).trim() if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet() if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet() - const activityBin = `/home/${USER}/.local/bin/sandlot-activity` - const hooks = { - UserPromptSubmit: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }], - PreToolUse: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }], - } - const statusLine = { type: "command", command: `/home/${USER}/.local/bin/sandlot-statusline` } - const settingsJson = JSON.stringify({ apiKeyHelper: "~/.pi/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks, statusLine }) - const piJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true, projects: { "/": { hasTrustDialogAccepted: true } } }) + const settingsJson = JSON.stringify({ + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + defaultThinkingLevel: "high", + quietStartup: true, + }) + const authJson = JSON.stringify({ + anthropic: { type: "api_key", key: apiKey }, + }) - // Write the helper script to a temp file and copy it in so the key + // Write auth.json to a temp file and copy it in so the key // never appears in a process argument visible in `ps`. - const tmp = `${home}/.sandlot/.api-key-helper.tmp` - await Bun.write(tmp, `#!/bin/sh\necho '${apiKey.replace(/'/g, "'\\''")}'\n`) - await $`chmod +x ${tmp}`.quiet() - await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.pi && cp /sandlot/.api-key-helper.tmp ~/.pi/api-key-helper.sh"}`.quiet() + const tmp = `${home}/.sandlot/.auth-json.tmp` + await Bun.write(tmp, authJson) + await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.pi/agent && cp /sandlot/.auth-json.tmp ~/.pi/agent/auth.json && chmod 600 ~/.pi/agent/auth.json"}`.quiet() await Bun.file(tmp).unlink() - await installScript(home, "sandlot-activity", `#!/bin/bash\nP="\${PI_PROJECT_DIR%/}"\necho "$1" > "$(dirname "$P")/.activity-$(basename "$P")"\n`) - await installScript(home, "sandlot-statusline", `#!/bin/bash\ninput=$(cat)\ncwd=$(echo "$input" | grep -oP '"cwd"\\s*:\\s*"\\K[^"]+' | head -1)\n[ -n "$cwd" ] && printf '\\033[36m\u2387 %s\\033[0m\\n' "$(basename "$cwd")"\n`) + // Write the activity-tracking extension + const extensionContent = `import { writeFileSync } from "fs"; +import { dirname, basename, join } from "path"; +export default function (pi) { + function markActive(ctx) { + try { + const cwd = ctx.cwd; + const file = join(dirname(cwd), ".activity-" + basename(cwd)); + writeFileSync(file, "active\\n"); + } catch {} + } + pi.on("before_agent_start", async (_event, ctx) => { markActive(ctx); }); + pi.on("tool_call", async (_event, ctx) => { markActive(ctx); }); +} +` + const extTmp = `${home}/.sandlot/.sandlot-activity-ext.tmp` + await Bun.write(extTmp, extensionContent) + await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.pi/agent/extensions && cp /sandlot/.sandlot-activity-ext.tmp ~/.pi/agent/extensions/sandlot-activity.ts"}`.quiet() + await Bun.file(extTmp).unlink() await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${` -mkdir -p ~/.pi -echo '${settingsJson}' > ~/.pi/settings.json -echo '${piJson}' > ~/.pi.json +mkdir -p ~/.pi/agent +echo '${settingsJson}' > ~/.pi/agent/settings.json `}`.quiet() } @@ -361,7 +381,7 @@ export async function pi(workdir: string, opts?: { prompt?: string; print?: stri const term = process.env.TERM || "xterm-256color" const envArgs = [`TERM=${term}`, `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`)] - const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", ...envArgs, PI_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--effort", "max", "--append-system-prompt", systemPrompt] + const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", ...envArgs, PI_BIN, "--append-system-prompt", systemPrompt] if (opts?.continue) args.push("--continue") if (opts?.print) args.push("-p", opts.print) else if (opts?.prompt) args.push(opts.prompt) @@ -424,7 +444,7 @@ export async function piPipe(input: string, prompt: string): Promise<{ exitCode: await Bun.write(tmpPath, input) return await exec( join(homedir(), '.sandlot'), - `cat /sandlot/${tmpName} | pi --model claude-opus-4-6 --effort max -p "${prompt.replace(/"/g, '\\"')}"`, + `cat /sandlot/${tmpName} | /sandlot/.pi-bin/pi/pi -p "${prompt.replace(/"/g, '\\"')}"`, ) } finally { await Bun.file(tmpPath).unlink().catch(() => {})