Compare commits

...

10 Commits

Author SHA1 Message Date
86d426f92c 0.0.54 2026-04-13 08:20:48 -07:00
049fc4b00b Add installPersistentTooling call to cached install 2026-04-13 08:20:19 -07:00
50517227db 0.0.53 2026-04-12 20:07:35 -07:00
9a334992ad Replace Claude with Pi as the AI agent 2026-04-12 20:07:23 -07:00
e64564a045 Replace Claude with Pi throughout codebase 2026-04-11 23:56:48 -07:00
9eded80a0e Replace Claude Code with Pi in VM tooling layer
The inner coding agent has been rebranded. Update binary
paths, config directories, install scripts, exported
functions, and environment variables accordingly.
2026-04-11 23:38:46 -07:00
846f2cd021 0.0.52 2026-04-11 15:12:36 -07:00
894a0455a7 Add hosts config for local network resolution 2026-04-11 15:12:33 -07:00
a7e3a6333b 0.0.51 2026-04-10 23:31:46 -07:00
d30a5b94ab Add avahi/mDNS packages to container base install 2026-04-10 23:31:34 -07:00
15 changed files with 185 additions and 114 deletions

View File

@ -69,7 +69,7 @@ Each module has a single responsibility. No classes — only exported functions
2. Creates symlink `<repo-root>/.sandlot/<branch>` → worktree path 2. Creates symlink `<repo-root>/.sandlot/<branch>` → worktree path
3. `vm.ensure()` → start/create/provision the container 3. `vm.ensure()` → start/create/provision the container
4. `state.setSession()` → write to `.sandlot/state.json` 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` 6. `saveChanges()` → auto-save on exit (stage all, AI-generated commit message) unless `--no-save`
**Worktree location**: `~/.sandlot/<repo-name>/<branch>/` (outside the repo) **Worktree location**: `~/.sandlot/<repo-name>/<branch>/` (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` - 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 - 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/…`) - `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 - Provisioned once on first use: apt installs `curl git fish unzip`, then installs Bun, 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 - Binary caching: after first install, bun/neofetch/nvim are cached in `~/.sandlot/.cache/` — 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) - Persistent tooling: Pi, Rust, Go, and sccache are installed to `/sandlot/` paths that survive container recreates
- Claude settings: `skipDangerousModePermissionPrompt: true`, activity tracking hooks (`UserPromptSubmit` / `Stop`) in container - Pi is installed as a standalone binary from GitHub releases (`pi-linux-arm64.tar.gz`) to `/sandlot/.pi-bin/`
- Also writes `~/.claude.json` with `hasCompletedOnboarding: true` and `effortCalloutDismissed: true` - 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 ## Shell Command Pattern
@ -138,23 +140,21 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
## Key Implementation Notes ## Key Implementation Notes
- `vm.exec()` prepends `export PATH=$HOME/.local/bin:$PATH` so `claude` binary is found - `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.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.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.claude()` runs Claude with `--dangerously-skip-permissions`, `--model claude-opus-4-6`, and `--append-system-prompt` (system prompt describes the container environment) - `vm.pi()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue`
- `vm.claude()` 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.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.isPiActive()` reads activity marker files written by the in-container `sandlot-activity.ts` Pi extension
- `vm.isClaudeActive()` reads activity marker files written by the in-container `sandlot-activity` hook script
- Branch creation in `createWorktree()` handles three cases: local branch, remote branch (tracks origin), new branch from HEAD - 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 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 open` always passes `continue: true` to `vm.pi()` to resume the previous conversation
- `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff - `sandlot save` uses `vm.piPipe()` to generate commit messages from the staged diff
- `sandlot merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically - `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 `claudePipe()`; falls back to `"squash <branch>"`. Rolls back to the original HEAD on failure. - `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 <branch>"`. 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 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 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 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 Claude exits (disable with `--no-save`) - `sandlot new` and `sandlot open` auto-save changes when Pi exits (disable with `--no-save`)
- `sandlot close` has a hidden `rm` alias - `sandlot close` has a hidden `rm` alias
- Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty) - Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty)
- `.sandlot/` should be in the repo's `.gitignore` - `.sandlot/` should be in the repo's `.gitignore`

View File

@ -59,3 +59,17 @@ Config is stored in `~/.config/sandlot/config.json`.
| Key | Default | Description | | Key | Default | Description |
|-----|---------|-------------| |-----|---------|-------------|
| `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) | | `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) |
| `hosts` | `[]` | Hostnames to resolve on the host and inject into the container |
#### Local network hosts
The container can't resolve `.local` (mDNS) hostnames natively. To make local network hosts reachable from inside the container, add them to the `hosts` config:
```bash
sandlot config hosts add claude.toes.local
sandlot config hosts add myserver.local
sandlot config hosts rm myserver.local
sandlot config hosts # list configured hosts
```
Hostnames are resolved on the Mac via mDNS and written to the container's `/etc/hosts` every time the VM starts.

View File

@ -1,6 +1,6 @@
{ {
"name": "@because/sandlot", "name": "@because/sandlot",
"version": "0.0.50", "version": "0.0.54",
"description": "Sandboxed, branch-based development with Claude", "description": "Sandboxed, branch-based development with Claude",
"type": "module", "type": "module",
"bin": { "bin": {

View File

@ -35,7 +35,7 @@ const program = new Command()
program program
.name("sandlot") .name("sandlot")
.description("Sandboxed development with Claude.") .description("Sandboxed development with Pi.")
.configureHelp({ styleTitle: (str) => `${yellow}${str}${reset}` }) .configureHelp({ styleTitle: (str) => `${yellow}${str}${reset}` })
.helpOption(false) .helpOption(false)
.addOption(new Option("-h, --help").hideHelp()) .addOption(new Option("-h, --help").hideHelp())
@ -59,19 +59,19 @@ program
program program
.command("new") .command("new")
.argument("[branch]", "branch name or prompt (if it contains spaces)") .argument("[branch]", "branch name or prompt (if it contains spaces)")
.argument("[prompt]", "initial prompt for Claude") .argument("[prompt]", "initial prompt for Pi")
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p") .option("-p, --print <prompt>", "run Pi in non-interactive mode with -p")
.option("-n, --no-save", "skip auto-save after Claude exits") .option("-n, --no-save", "skip auto-save after Pi exits")
.description("Create a new session and launch Claude") .description("Create a new session and launch Pi")
.action(newAction) .action(newAction)
program program
.command("open") .command("open")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.argument("[prompt]", "initial prompt for Claude") .argument("[prompt]", "initial prompt for Pi")
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p") .option("-p, --print <prompt>", "run Pi in non-interactive mode with -p")
.option("-n, --no-save", "skip auto-save after Claude exits") .option("-n, --no-save", "skip auto-save after Pi exits")
.description("Open an existing Claude session") .description("Open an existing Pi session")
.action(openAction) .action(openAction)
program program

View File

@ -2,14 +2,19 @@ import { die } from "../fmt.ts"
import * as config from "../config.ts" import * as config from "../config.ts"
const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[] const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[]
const ARRAY_KEYS = Object.entries(config.DEFAULTS).filter(([, v]) => Array.isArray(v)).map(([k]) => k)
export async function action(args: string[]) { export async function action(args: string[]) {
if (args.length === 0) { if (args.length === 0) {
const cfg = await config.load() const cfg = await config.load()
for (const key of VALID_KEYS) { for (const key of VALID_KEYS) {
const val = cfg[key] const val = cfg[key]
const display = val ?? `${config.DEFAULTS[key]} (default)` if (Array.isArray(config.DEFAULTS[key])) {
console.log(`${key} = ${display}`) const arr = (val as string[] | undefined) ?? []
console.log(`${key} = ${arr.length ? arr.join(", ") : "(empty)"}`)
} else {
console.log(`${key} = ${val ?? `${config.DEFAULTS[key]} (default)`}`)
}
} }
return return
} }
@ -17,6 +22,32 @@ export async function action(args: string[]) {
const [key, ...rest] = args const [key, ...rest] = args
if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`) if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`)
// Array keys: `config hosts add foo.local`, `config hosts rm foo.local`
if (ARRAY_KEYS.includes(key)) {
const current = ((await config.get(key as config.Key)) ?? []) as string[]
if (rest.length === 0) {
if (current.length === 0) console.log("(empty)")
else current.forEach(v => console.log(v))
return
}
const [op, ...values] = rest
if (op === "add") {
if (values.length === 0) die("Usage: sandlot config hosts add <hostname>")
const updated = [...new Set([...current, ...values])]
await config.set(key as config.Key, updated as any)
updated.forEach(v => console.log(v))
} else if (op === "rm" || op === "remove") {
if (values.length === 0) die("Usage: sandlot config hosts rm <hostname>")
const updated = current.filter(v => !values.includes(v))
await config.set(key as config.Key, updated as any)
if (updated.length === 0) console.log("(empty)")
else updated.forEach(v => console.log(v))
} else {
die(`Unknown operation: ${op}\nUsage: sandlot config ${key} add|rm <value>`)
}
return
}
if (rest.length === 0) { if (rest.length === 0) {
const val = await config.get(key as config.Key) const val = await config.get(key as config.Key)
console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`) console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`)

View File

@ -104,7 +104,7 @@ const SKIP_RESOLVE = new Set([
"yarn.lock", "yarn.lock",
]) ])
/** Resolve conflict markers in files using Claude, then stage them. */ /** Resolve conflict markers in files using Pi, then stage them. */
export async function resolveConflicts( export async function resolveConflicts(
files: string[], files: string[],
cwd: string, cwd: string,
@ -124,13 +124,13 @@ export async function resolveConflicts(
throw new Error(`Failed to read conflicted file: ${file}`) throw new Error(`Failed to read conflicted file: ${file}`)
}) })
const resolved = await vm.claudePipe( const resolved = await vm.piPipe(
content, content,
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
) )
if (resolved.exitCode !== 0 || !resolved.stdout.trim()) { if (resolved.exitCode !== 0 || !resolved.stdout.trim()) {
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`) throw new Error(`Pi failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
} }
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n") await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
@ -162,7 +162,7 @@ export async function mergeAndClose(branch: string, opts?: { force?: boolean }):
return return
} }
// Resolve conflicts with Claude // Resolve conflicts with Pi
spin.text = `Resolving ${conflicts.length} conflict(s)` spin.text = `Resolving ${conflicts.length} conflict(s)`
try { try {
@ -210,7 +210,7 @@ export async function saveChanges(worktree: string, branch: string, message?: st
spin.text = "Generating commit message" spin.text = "Generating commit message"
const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text() const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text()
const gen = await vm.claudePipe( const gen = await vm.piPipe(
diff, diff,
"Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.", "Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.",
) )

View File

@ -1,5 +1,4 @@
import { basename } from "path" import { basename } from "path"
import { homedir } from "os"
import { stat } from "fs/promises" import { stat } from "fs/promises"
import * as git from "../git.ts" import * as git from "../git.ts"
import * as vm from "../vm.ts" import * as vm from "../vm.ts"
@ -44,7 +43,7 @@ async function resolveStatus(
): Promise<string> { ): Promise<string> {
try { await stat(s.worktree) } catch { return "idle" } try { await stat(s.worktree) } catch { return "idle" }
if (vmRunning) { if (vmRunning) {
const active = await vm.isClaudeActive(s.worktree, s.branch).catch(() => false) const active = await vm.isPiActive(s.worktree, s.branch).catch(() => false)
if (active && s.in_review) return "review" if (active && s.in_review) return "review"
if (active) return "active" if (active) return "active"
} }
@ -58,7 +57,7 @@ async function resolveStatus(
} }
} }
/** Clear in_review flags for sessions where Claude is no longer active. */ /** Clear in_review flags for sessions where Pi is no longer active. */
async function clearStaleReviews( async function clearStaleReviews(
sessions: state.GlobalSession[], sessions: state.GlobalSession[],
statusMap: Map<state.GlobalSession, string>, statusMap: Map<state.GlobalSession, string>,
@ -75,26 +74,9 @@ async function clearStaleReviews(
} }
} }
async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) { async function backfillPrompts(_sessions: { worktree: string; prompt?: string }[], _vmRunning: boolean) {
if (!vmRunning) return // Pi doesn't maintain a history.jsonl like Claude did.
const needsPrompt = sessions.filter(s => !s.prompt) // Prompts are populated from state.json at session creation time.
if (needsPrompt.length === 0) return
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null").catch(() => null)
if (!result || result.exitCode !== 0 || !result.stdout) return
const byProject = new Map<string, string>()
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
}
} }
// ── Command ────────────────────────────────────────────────────────── // ── Command ──────────────────────────────────────────────────────────

View File

@ -126,7 +126,7 @@ export async function action(
if (opts.print) { if (opts.print) {
spin.text = "Running prompt…" spin.text = "Running prompt…"
const result = await vm.claude(worktreeAbs, { prompt, print: opts.print }) const result = await vm.pi(worktreeAbs, { prompt, print: opts.print })
if (result.output) { if (result.output) {
spin.stop() spin.stop()
process.stdout.write(renderMarkdown(result.output) + "\n") process.stdout.write(renderMarkdown(result.output) + "\n")
@ -134,7 +134,7 @@ export async function action(
spin.succeed("Done") spin.succeed("Done")
} }
} else { } else {
await vm.claude(worktreeAbs, { prompt, print: opts.print }) await vm.pi(worktreeAbs, { prompt, print: opts.print })
} }
await vm.clearActivity(worktreeAbs, branch) await vm.clearActivity(worktreeAbs, branch)

View File

@ -21,7 +21,7 @@ export async function action(
if (opts.print) { if (opts.print) {
spin.text = "Running prompt…" spin.text = "Running prompt…"
const result = await vm.claude(session.worktree, { prompt, print: opts.print, continue: true }) const result = await vm.pi(session.worktree, { prompt, print: opts.print, continue: true })
if (result.output) { if (result.output) {
spin.stop() spin.stop()
process.stdout.write(renderMarkdown(result.output) + "\n") process.stdout.write(renderMarkdown(result.output) + "\n")
@ -30,7 +30,7 @@ export async function action(
} }
} else { } else {
spin.succeed("Session ready") spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt, print: opts.print, continue: true }) await vm.pi(session.worktree, { prompt, print: opts.print, continue: true })
} }
await vm.clearActivity(session.worktree, branch) await vm.clearActivity(session.worktree, branch)

View File

@ -34,7 +34,7 @@ export async function action(branch: string) {
} }
fetchSpin.stop() fetchSpin.stop()
console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`) console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Pi...`)
const resolveSpin = spinner("Starting container", branch) const resolveSpin = spinner("Starting container", branch)
try { try {

View File

@ -74,11 +74,11 @@ Your thoughts, in brief.
try { try {
if (opts.print) { if (opts.print) {
spin.text = "Running review…" spin.text = "Running review…"
const result = await vm.claude(session.worktree, { print: prompt }) const result = await vm.pi(session.worktree, { print: prompt })
if (result.output) process.stdout.write(result.output + "\n") if (result.output) process.stdout.write(result.output + "\n")
} else { } else {
spin.succeed("Session ready") spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt }) await vm.pi(session.worktree, { prompt })
} }
} finally { } finally {
spin.stop() spin.stop()

View File

@ -40,7 +40,7 @@ export async function action(branch: string) {
process.exit(1) process.exit(1)
} }
const gen = await vm.claudePipe( const gen = await vm.piPipe(
diff, diff,
"Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.", "Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.",
) )

View File

@ -70,7 +70,7 @@ export function register(program: Command) {
sessions.map(async (sess): Promise<[string, string]> => { sessions.map(async (sess): Promise<[string, string]> => {
const key = `${basename(sess.repoRoot)}/${sess.branch}` const key = `${basename(sess.repoRoot)}/${sess.branch}`
try { try {
if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"] if (await vm.isPiActive(sess.worktree, sess.branch)) return [key, "active"]
if (await git.isDirty(sess.worktree)) return [key, "dirty"] if (await git.isDirty(sess.worktree)) return [key, "dirty"]
if (await git.hasNewCommits(sess.worktree)) return [key, "saved"] if (await git.hasNewCommits(sess.worktree)) return [key, "saved"]
} catch {} } catch {}

View File

@ -5,14 +5,16 @@ import { join } from "path"
const CONFIG_DIR = join(homedir(), ".config", "sandlot") const CONFIG_DIR = join(homedir(), ".config", "sandlot")
const CONFIG_PATH = join(CONFIG_DIR, "config.json") const CONFIG_PATH = join(CONFIG_DIR, "config.json")
export const DEFAULTS = { export const DEFAULTS: Record<string, string | string[]> = {
memory: "16G", memory: "16G",
} as const hosts: [],
}
export type Key = keyof typeof DEFAULTS export type Key = keyof typeof DEFAULTS
export interface Config { export interface Config {
memory?: string memory?: string
hosts?: string[]
} }
const MIN_MEMORY_MB = 512 const MIN_MEMORY_MB = 512

130
src/vm.ts
View File

@ -9,7 +9,7 @@ import { get as getConfig, DEFAULTS, validateMemory } from "./config.ts"
const DEBUG = !!process.env.DEBUG const DEBUG = !!process.env.DEBUG
const CONTAINER_NAME = "sandlot" const CONTAINER_NAME = "sandlot"
const USER = "ubuntu" const USER = "ubuntu"
const CLAUDE_BIN = `/home/${USER}/.local/bin/claude` 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_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 = { const CONTAINER_ENV = {
RUSTUP_HOME: "/sandlot/.rustup", RUSTUP_HOME: "/sandlot/.rustup",
@ -85,8 +85,8 @@ async function createContainer(home: string): Promise<void> {
/** Install base system packages (as root). */ /** Install base system packages (as root). */
async function installPackages(cached: boolean): Promise<void> { async function installPackages(cached: boolean): Promise<void> {
const packages = cached const packages = cached
? "curl git fish build-essential" ? "curl git fish build-essential avahi-daemon libnss-mdns"
: "curl git fish unzip build-essential" : "curl git fish unzip build-essential avahi-daemon libnss-mdns"
await run( await run(
$`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`, $`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`,
"Package installation") "Package installation")
@ -107,12 +107,12 @@ const CACHE_DIR = join(homedir(), '.sandlot', '.cache')
/** Check whether the package cache is populated. */ /** Check whether the package cache is populated. */
async function hasCachedTooling(): Promise<boolean> { async function hasCachedTooling(): Promise<boolean> {
const files = ['bun', 'claude', '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())) const checks = await Promise.all(files.map(f => Bun.file(join(CACHE_DIR, f)).exists()))
return checks.every(Boolean) return checks.every(Boolean)
} }
/** Install Bun, Claude Code, 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<void> { async function installTooling(cached: boolean, log?: (msg: string) => void): Promise<void> {
// Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container) // Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container)
await $`mkdir -p ${CACHE_DIR}`.quiet() await $`mkdir -p ${CACHE_DIR}`.quiet()
@ -123,11 +123,12 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`, $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`,
"Create bin directory") "Create bin directory")
await run( await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.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") "Install cached binaries")
await run( await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`,
"Install cached Neovim") "Install cached Neovim")
await installPersistentTooling(log)
return return
} }
@ -136,11 +137,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"}`, $`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`,
"Bun installation") "Bun installation")
log?.("Installing Claude Code")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`,
"Claude Code installation")
log?.("Installing neofetch") log?.("Installing neofetch")
await run( 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"}`, $`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 +148,22 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
"Neovim installation") "Neovim installation")
// Cache binaries for next time // Cache binaries for next time
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/claude ~/.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) 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<void> { async function installPersistentTooling(log?: (msg: string) => void): Promise<void> {
// 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 // 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() const hasRust = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.cargo/bin/rustc`.nothrow().quiet()
if (hasRust.exitCode !== 0) { if (hasRust.exitCode !== 0) {
@ -212,40 +217,72 @@ async function installScript(home: string, name: string, content: string): Promi
await Bun.file(tmp).unlink() await Bun.file(tmp).unlink()
} }
/** Configure git identity, API key helper, activity hook, and Claude settings. */ /** Configure git identity, API key (via auth.json), activity extension, and Pi settings. */
async function configureEnvironment(home: string, apiKey: string): Promise<void> { async function configureEnvironment(home: string, apiKey: string): Promise<void> {
const gitName = (await $`git config user.name`.quiet().text()).trim() const gitName = (await $`git config user.name`.quiet().text()).trim()
const gitEmail = (await $`git config user.email`.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 (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() 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 settingsJson = JSON.stringify({
const hooks = { defaultProvider: "anthropic",
UserPromptSubmit: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }], defaultModel: "claude-opus-4-6",
PreToolUse: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }], defaultThinkingLevel: "high",
} quietStartup: true,
const statusLine = { type: "command", command: `/home/${USER}/.local/bin/sandlot-statusline` } })
const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks, statusLine }) const authJson = JSON.stringify({
const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true, projects: { "/": { hasTrustDialogAccepted: true } } }) 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`. // never appears in a process argument visible in `ps`.
const tmp = `${home}/.sandlot/.api-key-helper.tmp` const tmp = `${home}/.sandlot/.auth-json.tmp`
await Bun.write(tmp, `#!/bin/sh\necho '${apiKey.replace(/'/g, "'\\''")}'\n`) await Bun.write(tmp, authJson)
await $`chmod +x ${tmp}`.quiet() 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 $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh"}`.quiet()
await Bun.file(tmp).unlink() await Bun.file(tmp).unlink()
await installScript(home, "sandlot-activity", `#!/bin/bash\nP="\${CLAUDE_PROJECT_DIR%/}"\necho "$1" > "$(dirname "$P")/.activity-$(basename "$P")"\n`) // Write the activity-tracking extension
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`) 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 ${` await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`
mkdir -p ~/.claude mkdir -p ~/.pi/agent
echo '${settingsJson}' > ~/.claude/settings.json echo '${settingsJson}' > ~/.pi/agent/settings.json
echo '${claudeJson}' > ~/.claude.json
`}`.quiet() `}`.quiet()
} }
/** Resolve hostnames on the host (via mDNS) and add them to the container's /etc/hosts.
* Reads the "hosts" array from config (e.g. ["claude.toes.local"]). */
async function syncLocalHosts(): Promise<void> {
const hostnames = (await getConfig("hosts")) as string[] | undefined
if (!hostnames?.length) return
const entries: string[] = []
for (const name of hostnames) {
const out = (await $`dscacheutil -q host -a name ${name}`.nothrow().quiet().text()).trim()
const match = out.match(/ip_address:\s+(\S+)/)
if (match) entries.push(`${match[1]} ${name}`)
}
if (!entries.length) return
const block = entries.join("\\n")
await $`container exec ${CONTAINER_NAME} bash -c ${`grep -v '# sandlot-hosts' /etc/hosts > /tmp/hosts.clean; echo -e '${block}' | sed 's/$/ # sandlot-hosts/' >> /tmp/hosts.clean; cp /tmp/hosts.clean /etc/hosts`}`.nothrow().quiet()
}
// ── create() ──────────────────────────────────────────────────────── // ── create() ────────────────────────────────────────────────────────
/** Create and provision the container from scratch. Fails if it already exists. */ /** Create and provision the container from scratch. Fails if it already exists. */
@ -273,6 +310,7 @@ export async function create(log?: (msg: string) => void): Promise<void> {
log?.("Configuring environment") log?.("Configuring environment")
await configureEnvironment(home, apiKey) await configureEnvironment(home, apiKey)
await syncLocalHosts()
} }
/** Start a stopped container. */ /** Start a stopped container. */
@ -282,6 +320,7 @@ export async function start(): Promise<void> {
if (s === "running") return if (s === "running") return
if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.") if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.")
await run($`container start ${CONTAINER_NAME}`, "Container start") await run($`container start ${CONTAINER_NAME}`, "Container start")
await syncLocalHosts()
} }
/** Ensure the sandlot container exists and is running. Creates and provisions on first use. */ /** Ensure the sandlot container exists and is running. Creates and provisions on first use. */
@ -294,7 +333,10 @@ export async function ensure(log?: (msg: string) => void): Promise<void> {
else await $`container system start --enable-kernel-install`.nothrow().quiet() else await $`container system start --enable-kernel-install`.nothrow().quiet()
const s = await status() const s = await status()
if (s === "running") return if (s === "running") {
await syncLocalHosts()
return
}
if (s === "stopped") { if (s === "stopped") {
await start() await start()
@ -318,8 +360,8 @@ export async function status(): Promise<"running" | "stopped" | "missing"> {
} }
} }
/** Launch claude in the container at the given workdir. */ /** Launch pi in the container at the given workdir. */
export async function claude(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> { export async function pi(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> {
const cwd = containerPath(workdir) const cwd = containerPath(workdir)
const mounts = hostMounts(homedir()) const mounts = hostMounts(homedir())
const systemPromptLines = [ const systemPromptLines = [
@ -340,7 +382,7 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?:
const term = process.env.TERM || "xterm-256color" const term = process.env.TERM || "xterm-256color"
const envArgs = [`TERM=${term}`, `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`)] 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, CLAUDE_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?.continue) args.push("--continue")
if (opts?.print) args.push("-p", opts.print) if (opts?.print) args.push("-p", opts.print)
else if (opts?.prompt) args.push(opts.prompt) else if (opts?.prompt) args.push(opts.prompt)
@ -351,7 +393,7 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?:
const exitCode = await proc.exited const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) { if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue") info("Retrying without --continue")
return claude(workdir, { ...opts, continue: false }) return pi(workdir, { ...opts, continue: false })
} }
return { exitCode, output } return { exitCode, output }
} }
@ -360,7 +402,7 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?:
const exitCode = await proc.exited const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) { if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue") info("Retrying without --continue")
return claude(workdir, { ...opts, continue: false }) return pi(workdir, { ...opts, continue: false })
} }
return { exitCode } return { exitCode }
} }
@ -395,23 +437,23 @@ export async function exec(workdir: string, command: string): Promise<{ exitCode
} }
} }
/** Pipe input text to Claude in the container with a prompt, returning the output. */ /** Pipe input text to Pi in the container with a prompt, returning the output. */
export async function claudePipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { export async function piPipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const tmpName = `.claude-pipe-${crypto.randomUUID()}` const tmpName = `.pi-pipe-${crypto.randomUUID()}`
const tmpPath = join(homedir(), '.sandlot', tmpName) const tmpPath = join(homedir(), '.sandlot', tmpName)
try { try {
await Bun.write(tmpPath, input) await Bun.write(tmpPath, input)
return await exec( return await exec(
join(homedir(), '.sandlot'), join(homedir(), '.sandlot'),
`cat /sandlot/${tmpName} | claude --model claude-opus-4-6 --effort max -p "${prompt.replace(/"/g, '\\"')}"`, `cat /sandlot/${tmpName} | /sandlot/.pi-bin/pi/pi -p "${prompt.replace(/"/g, '\\"')}"`,
) )
} finally { } finally {
await Bun.file(tmpPath).unlink().catch(() => {}) await Bun.file(tmpPath).unlink().catch(() => {})
} }
} }
/** Check if Claude is actively working in the given worktree (based on activity hook). */ /** Check if Pi is actively working in the given worktree (based on activity hook). */
export async function isClaudeActive(worktree: string, branch: string): Promise<boolean> { export async function isPiActive(worktree: string, branch: string): Promise<boolean> {
const file = `${dirname(worktree)}/.activity-${branch}` const file = `${dirname(worktree)}/.activity-${branch}`
try { try {
const content = await Bun.file(file).text() const content = await Bun.file(file).text()