Compare commits
10 Commits
rust-rewri
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 86d426f92c | |||
| 049fc4b00b | |||
| 50517227db | |||
| 9a334992ad | |||
| e64564a045 | |||
| 9eded80a0e | |||
| 846f2cd021 | |||
| 894a0455a7 | |||
| a7e3a6333b | |||
| d30a5b94ab |
38
CLAUDE.md
38
CLAUDE.md
|
|
@ -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`
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
18
src/cli.ts
18
src/cli.ts
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)`)
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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
130
src/vm.ts
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user