[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-74803":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":10,"language":11,"languages":10,"totalLinesOfCode":10,"stars":12,"forks":13,"watchers":14,"openIssues":15,"contributorsCount":16,"subscribersCount":16,"size":16,"stars1d":17,"stars7d":18,"stars30d":19,"stars90d":16,"forks30d":16,"starsTrendScore":20,"compositeScore":21,"rankGlobal":10,"rankLanguage":10,"license":22,"archived":23,"fork":23,"defaultBranch":24,"hasWiki":23,"hasPages":23,"topics":25,"createdAt":10,"pushedAt":10,"updatedAt":26,"readmeContent":27,"aiSummary":28,"trendingCount":16,"starSnapshotCount":16,"syncStatus":29,"lastSyncTime":30,"discoverSource":31},74803,"sandcastle","mattpocock\u002Fsandcastle","mattpocock","Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()","",null,"TypeScript",5877,586,16,24,0,97,334,1038,291,114.31,"MIT License",false,"main",[],"2026-06-12 04:01:15","\u003Cdiv align=\"center\">\n  \u003Cpicture>\n    \u003Csource media=\"(prefers-color-scheme: dark)\" srcset=\"https:\u002F\u002Fres.cloudinary.com\u002Ftotal-typescript\u002Fimage\u002Fupload\u002Fv1775033787\u002Freadme-sandcastle-ondark_2x.png\">\n    \u003Csource media=\"(prefers-color-scheme: light)\" srcset=\"https:\u002F\u002Fres.cloudinary.com\u002Ftotal-typescript\u002Fimage\u002Fupload\u002Fv1775033787\u002Freadme-sandcastle-onlight_2x.png\">\n    \u003Cimg alt=\"Sandcastle\" src=\"https:\u002F\u002Fres.cloudinary.com\u002Ftotal-typescript\u002Fimage\u002Fupload\u002Fv1775033787\u002Freadme-sandcastle-onlight_2x.png\" height=\"200\" style=\"margin-bottom: 20px;\">\n  \u003C\u002Fpicture>\n\u003C\u002Fdiv>\n\n## What Is Sandcastle?\n\nA TypeScript library for orchestrating AI coding agents in isolated sandboxes:\n\n1. You invoke agents with a single `sandcastle.run()`.\n2. Sandcastle handles sandboxing the agent with a configurable branch strategy.\n3. The commits made on the branches get merged back.\n\nSandcastle is provider-agnostic — it ships with built-in providers for Docker, Podman, and Vercel, and you can create your own. Great for parallelizing multiple AFK agents, creating review pipelines, or even just orchestrating your own agents.\n\n## Prerequisites\n\n- [Git](https:\u002F\u002Fgit-scm.com\u002F)\n- A sandbox provider — Sandcastle needs an isolated environment to run agents in. Built-in options:\n  - [Docker Desktop](https:\u002F\u002Fwww.docker.com\u002F) — most common for local development\n  - [Podman](https:\u002F\u002Fpodman.io\u002F) — rootless alternative to Docker\n  - [Vercel](https:\u002F\u002Fvercel.com\u002F) — cloud-based Firecracker microVMs via `@vercel\u002Fsandbox`\n  - Or [create your own](#custom-sandbox-providers) using `createBindMountSandboxProvider` or `createIsolatedSandboxProvider`\n\n## Quick start\n\n1. Install the package:\n\n```bash\nnpm install --save-dev @ai-hero\u002Fsandcastle\n```\n\n2. Run `sandcastle init`. This scaffolds a `.sandcastle` directory with all the files needed.\n\n```bash\nnpx sandcastle init\n```\n\n3. Edit `.sandcastle\u002F.env` and fill in your default values for `ANTHROPIC_API_KEY`. If you want to use your Claude subscription instead of an API key, see [#191](https:\u002F\u002Fgithub.com\u002Fmattpocock\u002Fsandcastle\u002Fissues\u002F191).\n\n```bash\ncp .sandcastle\u002F.env.example .sandcastle\u002F.env\n```\n\n4. Run the `.sandcastle\u002Fmain.ts` (or `main.mts`) file with `npx tsx`\n\n```bash\nnpx tsx .sandcastle\u002Fmain.ts\n```\n\n```typescript\n\u002F\u002F 3. Run the agent via the JS API\nimport { run, claudeCode } from \"@ai-hero\u002Fsandcastle\";\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\n\nawait run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: docker(), \u002F\u002F or podman(), vercel(), or your own provider\n  promptFile: \".sandcastle\u002Fprompt.md\",\n});\n```\n\n## Sandbox Providers\n\nSandcastle uses a `SandboxProvider` to create isolated environments. The `sandbox` option on `run()` and `createSandbox()` accepts any provider. A no-sandbox option is also available for `interactive()` and `wt.interactive()`. Built-in providers:\n\n| Provider   | Import path                                | Type       | Accepted by                                   |\n| ---------- | ------------------------------------------ | ---------- | --------------------------------------------- |\n| Docker     | `@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker`     | Bind-mount | `run()`, `createSandbox()`, `interactive()`   |\n| Podman     | `@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fpodman`     | Bind-mount | `run()`, `createSandbox()`, `interactive()`   |\n| Vercel     | `@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fvercel`     | Isolated   | `run()`, `createSandbox()`, `interactive()`   |\n| No-sandbox | `@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fno-sandbox` | None       | `interactive()`, `wt.interactive()` (default) |\n\nWorktree methods (`wt.run()`, `wt.interactive()`, `wt.createSandbox()`) accept the same providers as their top-level counterparts. `wt.interactive()` defaults to `noSandbox()` when no sandbox is specified.\n\n```typescript\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\nimport { podman } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fpodman\";\nimport { vercel } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fvercel\";\nimport { noSandbox } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fno-sandbox\";\n\n\u002F\u002F Docker, Podman, and Vercel are interchangeable in run() and createSandbox():\nawait run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: docker(),\n  prompt: \"...\",\n});\n\n\u002F\u002F No-sandbox runs the agent directly on the host — interactive() only:\nawait interactive({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: noSandbox(),\n  prompt: \"...\", \u002F\u002F optional — omit to launch the TUI with no initial prompt\n  cwd: \"\u002Fpath\u002Fto\u002Fother-repo\", \u002F\u002F optional — defaults to process.cwd()\n});\n```\n\nYou can also [create your own provider](#custom-sandbox-providers) using `createBindMountSandboxProvider` or `createIsolatedSandboxProvider`.\n\n## API\n\nSandcastle exports a programmatic `run()` function for use in scripts, CI pipelines, or custom tooling. The examples below use `docker()`, but any `SandboxProvider` works in its place.\n\n```typescript\nimport { run, claudeCode } from \"@ai-hero\u002Fsandcastle\";\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\n\nconst result = await run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: docker(),\n  promptFile: \".sandcastle\u002Fprompt.md\",\n});\n\nconsole.log(result.iterations.length); \u002F\u002F number of iterations executed\nconsole.log(result.iterations); \u002F\u002F per-iteration results with optional sessionId\nconsole.log(result.commits); \u002F\u002F array of { sha } for commits created\nconsole.log(result.branch); \u002F\u002F target branch name\n```\n\n### All options\n\n```typescript\nimport { run, claudeCode } from \"@ai-hero\u002Fsandcastle\";\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\n\nconst result = await run({\n  \u002F\u002F Agent provider — required. Pass a model string to claudeCode().\n  \u002F\u002F Optional second arg for provider-specific options like effort level.\n  agent: claudeCode(\"claude-opus-4-7\", { effort: \"high\" }),\n\n  \u002F\u002F Sandbox provider — required. Any SandboxProvider works (docker, podman, vercel, or custom).\n  \u002F\u002F Provider-specific config (like imageName, mounts) lives inside the provider factory call.\n  sandbox: docker({\n    imageName: \"sandcastle:local\",\n    \u002F\u002F Optional: override the UID\u002FGID used for --user flag (defaults to host UID\u002FGID).\n    \u002F\u002F Must match the UID baked into the image. Pre-flight check catches mismatches.\n    \u002F\u002F containerUid: 1000,\n    \u002F\u002F containerGid: 1000,\n    \u002F\u002F Optional: mount host directories into the sandbox (e.g. package manager caches)\n    \u002F\u002F hostPath supports absolute, tilde-expanded (~), and relative paths (resolved from cwd).\n    \u002F\u002F sandboxPath supports absolute and relative paths (resolved from the sandbox repo directory).\n    mounts: [\n      { hostPath: \"~\u002F.npm\", sandboxPath: \"\u002Fhome\u002Fagent\u002F.npm\", readonly: true },\n      { hostPath: \"data\", sandboxPath: \"data\" }, \u002F\u002F mounts \u003Ccwd>\u002Fdata → \u003Csandbox-repo>\u002Fdata\n    ],\n    \u002F\u002F Optional: SELinux volume label — \"z\" (default, shared), \"Z\" (private), or false (none).\n    \u002F\u002F No-op on non-SELinux systems (Docker Desktop on macOS\u002FWindows, Linux without SELinux).\n    selinuxLabel: \"z\",\n    \u002F\u002F Optional: provider-level env vars merged at launch time\n    env: { DOCKER_SPECIFIC: \"value\" },\n    \u002F\u002F Optional: attach container to Docker network(s) — string or string[]\n    network: \"my-network\",\n  }),\n\n  \u002F\u002F Host repo directory — replaces process.cwd() as the anchor for\n  \u002F\u002F .sandcastle\u002F artifacts (worktrees, logs, env, patches) and git operations.\n  \u002F\u002F Relative paths resolve against process.cwd(). Defaults to process.cwd().\n  cwd: \"..\u002Fother-repo\",\n\n  \u002F\u002F Branch strategy — controls how the agent's changes relate to branches.\n  \u002F\u002F Defaults to { type: \"head\" } for bind-mount and { type: \"merge-to-head\" } for isolated providers.\n  branchStrategy: { type: \"branch\", branch: \"agent\u002Ffix-42\" },\n\n  \u002F\u002F Prompt source — provide one of these, not both.\n  \u002F\u002F Note: promptFile resolves against process.cwd(), NOT cwd.\n  promptFile: \".sandcastle\u002Fprompt.md\", \u002F\u002F path to a prompt file\n  \u002F\u002F prompt: \"Fix issue #42 in this repo\", \u002F\u002F OR an inline prompt string\n\n  \u002F\u002F Values substituted for {{KEY}} placeholders in the prompt.\n  promptArgs: {\n    ISSUE_NUMBER: \"42\",\n  },\n\n  \u002F\u002F Maximum number of agent iterations to run before stopping. Default: 1\n  maxIterations: 5,\n\n  \u002F\u002F Display name for this run, shown as a prefix in log output.\n  name: \"fix-issue-42\",\n\n  \u002F\u002F Lifecycle hooks grouped by where they run: host or sandbox.\n  hooks: {\n    host: {\n      onWorktreeReady: [{ command: \"cp .env.example .env\" }],\n      onSandboxReady: [{ command: \"echo setup done\" }],\n    },\n    sandbox: {\n      onSandboxReady: [{ command: \"npm install\" }],\n    },\n  },\n\n  \u002F\u002F Host-relative file paths to copy into the sandbox before the container starts.\n  \u002F\u002F Not supported with branchStrategy: { type: \"head\" }.\n  copyToWorktree: [\".env\"],\n\n  \u002F\u002F Override default timeouts for built-in lifecycle steps.\n  \u002F\u002F Unset keys keep their defaults.\n  timeouts: {\n    copyToWorktreeMs: 120_000, \u002F\u002F default: 60_000\n  },\n\n  \u002F\u002F How to record progress. Default: write to a file under .sandcastle\u002Flogs\u002F\n  logging: {\n    type: \"file\",\n    path: \".sandcastle\u002Flogs\u002Fmy-run.log\",\n    \u002F\u002F Optional: forward the agent's output stream to your own observability system.\n    \u002F\u002F Fires for each text chunk and tool call the agent produces. Errors thrown\n    \u002F\u002F by the callback are swallowed so a broken forwarder cannot kill the run.\n    onAgentStreamEvent: (event) => {\n      \u002F\u002F event is { type: \"text\" | \"toolCall\", iteration, timestamp, ... }\n      myLogger.info(event);\n    },\n  },\n  \u002F\u002F logging: { type: \"stdout\" }, \u002F\u002F OR render an interactive UI in the terminal\n\n  \u002F\u002F String (or array of strings) the agent emits to end the iteration loop early.\n  \u002F\u002F Default: \"\u003Cpromise>COMPLETE\u003C\u002Fpromise>\"\n  completionSignal: \"\u003Cpromise>COMPLETE\u003C\u002Fpromise>\",\n\n  \u002F\u002F Idle timeout in seconds — resets whenever the agent produces output. Default: 600 (10 minutes)\n  idleTimeoutSeconds: 600,\n\n  \u002F\u002F Structured output — extract a typed payload from the agent's stdout.\n  \u002F\u002F Requires maxIterations === 1 and the tag must appear in the prompt.\n  \u002F\u002F output: Output.object({ tag: \"result\", schema: z.object({ answer: z.number() }) }),\n  \u002F\u002F output: Output.string({ tag: \"summary\" }),\n});\n\nconsole.log(result.iterations.length); \u002F\u002F number of iterations executed\nconsole.log(result.completionSignal); \u002F\u002F matched signal string, or undefined if none fired\nconsole.log(result.commits); \u002F\u002F array of { sha } for commits created\nconsole.log(result.branch); \u002F\u002F target branch name\n```\n\n### `createSandbox()` — reusable sandbox\n\nUse `createSandbox()` when you need to run multiple agents (or multiple rounds of the same agent) inside a single sandbox. It creates the sandbox once, and you call `sandbox.run()` as many times as you need. This avoids repeated container startup costs and keeps all runs on the same branch.\n\nUse `run()` instead when you only need a single one-shot invocation — it handles sandbox lifecycle automatically.\n\n#### Basic single-run usage\n\n```typescript\nimport { createSandbox, claudeCode } from \"@ai-hero\u002Fsandcastle\";\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\n\nawait using sandbox = await createSandbox({\n  branch: \"agent\u002Ffix-42\",\n  sandbox: docker(),\n});\n\nconst result = await sandbox.run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  prompt: \"Fix issue #42 in this repo.\",\n});\n\nconsole.log(result.commits); \u002F\u002F [{ sha: \"abc123\" }]\n```\n\n#### Multi-run implement-then-review\n\n```typescript\nimport { createSandbox, claudeCode } from \"@ai-hero\u002Fsandcastle\";\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\n\nawait using sandbox = await createSandbox({\n  branch: \"agent\u002Ffix-42\",\n  sandbox: docker(),\n  hooks: { sandbox: { onSandboxReady: [{ command: \"npm install\" }] } },\n});\n\n\u002F\u002F Step 1: implement\nconst implResult = await sandbox.run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  promptFile: \".sandcastle\u002Fimplement.md\",\n  maxIterations: 5,\n});\n\n\u002F\u002F Step 2: review on the same branch, same container\nconst reviewResult = await sandbox.run({\n  agent: claudeCode(\"claude-sonnet-4-6\"),\n  prompt: \"Review the changes and fix any issues.\",\n});\n```\n\nCommits from all `run()` calls accumulate on the same branch. The sandbox container stays alive between runs, so installed dependencies and build artifacts persist.\n\n#### Automatic cleanup with `await using`\n\n`await using` calls `sandbox.close()` automatically when the block exits. If the sandbox has uncommitted changes, the worktree is preserved on disk; if clean, both container and worktree are removed.\n\n#### Manual `close()` with `CloseResult`\n\n```typescript\nconst sandbox = await createSandbox({\n  branch: \"agent\u002Ffix-42\",\n  sandbox: docker(),\n});\n\u002F\u002F ... run agents ...\nconst closeResult = await sandbox.close();\nif (closeResult.preservedWorktreePath) {\n  console.log(`Worktree preserved at ${closeResult.preservedWorktreePath}`);\n}\n```\n\n#### `CreateSandboxOptions`\n\n| Option           | Type            | Default         | Description                                                          |\n| ---------------- | --------------- | --------------- | -------------------------------------------------------------------- |\n| `branch`         | string          | —               | **Required.** Explicit branch for the sandbox                        |\n| `sandbox`        | SandboxProvider | —               | **Required.** Sandbox provider (e.g. `docker()`, `podman()`)         |\n| `cwd`            | string          | `process.cwd()` | Host repo directory — relative paths resolve against `process.cwd()` |\n| `hooks`          | SandboxHooks    | —               | Lifecycle hooks (`host.*`, `sandbox.*`) — run once at creation time  |\n| `copyToWorktree` | string[]        | —               | Host-relative file paths to copy into the sandbox at creation time   |\n| `timeouts`       | Timeouts        | —               | Override default timeouts (e.g. `{ copyToWorktreeMs: 120_000 }`)     |\n\n#### `Sandbox`\n\n| Property \u002F Method       | Type                                                               | Description                                  |\n| ----------------------- | ------------------------------------------------------------------ | -------------------------------------------- |\n| `branch`                | string                                                             | The branch the sandbox is on                 |\n| `worktreePath`          | string                                                             | Host path to the worktree                    |\n| `run(options)`          | `(SandboxRunOptions) => Promise\u003CSandboxRunResult>`                 | Invoke an agent inside the existing sandbox  |\n| `interactive(options)`  | `(SandboxInteractiveOptions) => Promise\u003CSandboxInteractiveResult>` | Launch an interactive session in the sandbox |\n| `close()`               | `() => Promise\u003CCloseResult>`                                       | Tear down the container and sandbox          |\n| `[Symbol.asyncDispose]` | `() => Promise\u003Cvoid>`                                              | Auto teardown via `await using`              |\n\n#### `SandboxRunOptions`\n\n| Option               | Type               | Default                       | Description                                                         |\n| -------------------- | ------------------ | ----------------------------- | ------------------------------------------------------------------- |\n| `agent`              | AgentProvider      | —                             | **Required.** Agent provider (e.g. `claudeCode(\"claude-opus-4-7\")`) |\n| `prompt`             | string             | —                             | Inline prompt (mutually exclusive with `promptFile`)                |\n| `promptFile`         | string             | —                             | Path to prompt file (mutually exclusive with `prompt`)              |\n| `promptArgs`         | PromptArgs         | —                             | Key-value map for `{{KEY}}` placeholder substitution                |\n| `maxIterations`      | number             | `1`                           | Maximum iterations to run                                           |\n| `completionSignal`   | string \\| string[] | `\u003Cpromise>COMPLETE\u003C\u002Fpromise>` | String(s) the agent emits to stop the iteration loop early          |\n| `idleTimeoutSeconds` | number             | `600`                         | Idle timeout in seconds — resets on each agent output event         |\n| `name`               | string             | —                             | Display name for the run                                            |\n| `logging`            | object             | file (auto-generated)         | `{ type: 'file', path }` or `{ type: 'stdout' }`                    |\n| `signal`             | AbortSignal        | —                             | Cancels the run when aborted; handle stays usable afterward         |\n\n#### `SandboxRunResult`\n\n| Field              | Type                | Description                                                        |\n| ------------------ | ------------------- | ------------------------------------------------------------------ |\n| `iterations`       | `IterationResult[]` | Per-iteration results (use `.length` for the count)                |\n| `completionSignal` | string?             | The matched completion signal string, or `undefined` if none fired |\n| `stdout`           | string              | Combined agent output from all iterations                          |\n| `commits`          | `{ sha }[]`         | Commits created during the run                                     |\n| `logFilePath`      | string?             | Path to the log file (only when logging to a file)                 |\n\n#### `CloseResult`\n\n| Field                   | Type    | Description                                                              |\n| ----------------------- | ------- | ------------------------------------------------------------------------ |\n| `preservedWorktreePath` | string? | Host path to the preserved worktree, set when it had uncommitted changes |\n\n### `createWorktree()` — independent worktree lifecycle\n\nUse `createWorktree()` when you need a worktree (git worktree) as an independent, first-class concept — separate from any sandbox. This is useful when you want to run an interactive session first and then hand the same worktree to a sandboxed AFK agent.\n\nOnly `branch` and `merge-to-head` strategies are accepted; `head` is a compile-time type error since it means no worktree.\n\nPass `cwd` to target a repo other than `process.cwd()`. Relative paths resolve against `process.cwd()`; absolute paths pass through. A `CwdError` is thrown if the path does not exist or is not a directory.\n\n```typescript\nimport { createWorktree } from \"@ai-hero\u002Fsandcastle\";\n\nawait using wt = await createWorktree({\n  branchStrategy: { type: \"branch\", branch: \"agent\u002Ffix-42\" },\n  copyToWorktree: [\"node_modules\"],\n  cwd: \"\u002Fpath\u002Fto\u002Fother-repo\", \u002F\u002F optional — defaults to process.cwd()\n});\n\nconsole.log(wt.worktreePath); \u002F\u002F host path to the worktree\nconsole.log(wt.branch); \u002F\u002F \"agent\u002Ffix-42\"\n\n\u002F\u002F Run an interactive session in the worktree (defaults to noSandbox)\nawait wt.interactive({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  prompt: \"Explore the codebase and understand the bug.\",\n});\n\n\u002F\u002F Run an AFK agent in the worktree (sandbox is required)\nconst result = await wt.run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: docker({ imageName: \"sandcastle:myrepo\" }),\n  prompt: \"Fix issue #42.\",\n  maxIterations: 3,\n});\nconsole.log(result.commits); \u002F\u002F commits made during the run\n\n\u002F\u002F Create a long-lived sandbox from the worktree\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\n\nawait using sandbox = await wt.createSandbox({\n  sandbox: docker(),\n  hooks: { sandbox: { onSandboxReady: [{ command: \"npm install\" }] } },\n});\n\n\u002F\u002F sandbox.close() tears down the container only — the worktree stays\nawait sandbox.close();\n\n\u002F\u002F wt.close() cleans up the worktree\n```\n\n`wt.close()` checks for uncommitted changes: if the worktree is dirty, it's preserved on disk; if clean, it's removed. `await using` calls `close()` automatically. The worktree persists after `run()`, `interactive()`, and `createSandbox()` complete, so you can hand it to another agent or inspect it.\n\n**Split ownership**: When a sandbox is created via `wt.createSandbox()`, `sandbox.close()` tears down the container only — the worktree remains. `wt.close()` is responsible for worktree cleanup. This differs from the top-level `createSandbox()`, where `sandbox.close()` owns both container and worktree.\n\n#### `CreateWorktreeOptions`\n\n| Option           | Type                   | Default | Description                                                               |\n| ---------------- | ---------------------- | ------- | ------------------------------------------------------------------------- |\n| `branchStrategy` | WorktreeBranchStrategy | —       | **Required.** `{ type: \"branch\", branch }` or `{ type: \"merge-to-head\" }` |\n| `copyToWorktree` | string[]               | —       | Host-relative file paths to copy into the worktree at creation time       |\n| `timeouts`       | Timeouts               | —       | Override default timeouts (e.g. `{ copyToWorktreeMs: 120_000 }`)          |\n\n#### `Worktree`\n\n| Property \u002F Method        | Type                                                                  | Description                                         |\n| ------------------------ | --------------------------------------------------------------------- | --------------------------------------------------- |\n| `branch`                 | string                                                                | The branch the worktree is on                       |\n| `worktreePath`           | string                                                                | Host path to the worktree                           |\n| `run(options)`           | `(options: WorktreeRunOptions) => Promise\u003CWorktreeRunResult>`         | Run an AFK agent in the worktree (sandbox required) |\n| `interactive(options)`   | `(options: WorktreeInteractiveOptions) => Promise\u003CInteractiveResult>` | Run an interactive agent session in the worktree    |\n| `createSandbox(options)` | `(options: WorktreeCreateSandboxOptions) => Promise\u003CSandbox>`         | Create a long-lived sandbox backed by this worktree |\n| `close()`                | `() => Promise\u003CCloseResult>`                                          | Clean up the worktree (preserves if dirty)          |\n| `[Symbol.asyncDispose]`  | `() => Promise\u003Cvoid>`                                                 | Auto cleanup via `await using`                      |\n\n#### `WorktreeInteractiveOptions`\n\n| Option       | Type                   | Default       | Description                                                                                       |\n| ------------ | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------- |\n| `agent`      | AgentProvider          | —             | **Required.** Agent provider                                                                      |\n| `sandbox`    | AnySandboxProvider     | `noSandbox()` | Sandbox provider (defaults to no sandbox)                                                         |\n| `prompt`     | string                 | —             | Inline prompt (mutually exclusive with `promptFile`)                                              |\n| `promptFile` | string                 | —             | Path to prompt file                                                                               |\n| `name`       | string                 | —             | Optional session name                                                                             |\n| `hooks`      | SandboxHooks           | —             | Lifecycle hooks (`host.*`, `sandbox.*`)                                                           |\n| `promptArgs` | PromptArgs             | —             | Key-value map for `{{KEY}}` placeholder substitution                                              |\n| `env`        | Record\u003Cstring, string> | —             | Environment variables to inject into the sandbox                                                  |\n| `signal`     | AbortSignal            | —             | Cancel the session when aborted. The worktree is preserved on disk. Rejects with `signal.reason`. |\n\n#### `WorktreeRunOptions`\n\n| Option               | Type                   | Default | Description                                                                                                                         |\n| -------------------- | ---------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |\n| `agent`              | AgentProvider          | —       | **Required.** Agent provider                                                                                                        |\n| `sandbox`            | SandboxProvider        | —       | **Required.** Sandbox provider (AFK agents must be sandboxed)                                                                       |\n| `prompt`             | string                 | —       | Inline prompt (mutually exclusive with `promptFile`)                                                                                |\n| `promptFile`         | string                 | —       | Path to prompt file                                                                                                                 |\n| `maxIterations`      | number                 | 1       | Maximum iterations to run                                                                                                           |\n| `completionSignal`   | string \\| string[]     | —       | Substring(s) to stop the iteration loop early                                                                                       |\n| `idleTimeoutSeconds` | number                 | 600     | Idle timeout in seconds                                                                                                             |\n| `name`               | string                 | —       | Optional run name                                                                                                                   |\n| `logging`            | LoggingOption          | file    | Logging mode                                                                                                                        |\n| `hooks`              | SandboxHooks           | —       | Lifecycle hooks (`host.*`, `sandbox.*`)                                                                                             |\n| `promptArgs`         | PromptArgs             | —       | Key-value map for `{{KEY}}` placeholder substitution                                                                                |\n| `env`                | Record\u003Cstring, string> | —       | Environment variables to inject into the sandbox                                                                                    |\n| `resumeSession`      | string                 | —       | Resume a prior Claude Code session by ID. Incompatible with `maxIterations > 1`. Session file must exist on host.                   |\n| `signal`             | AbortSignal            | —       | Cancel the run when aborted. Kills the in-flight agent subprocess; the worktree is preserved on disk. Rejects with `signal.reason`. |\n\n#### `WorktreeRunResult`\n\n| Property           | Type                | Description                                            |\n| ------------------ | ------------------- | ------------------------------------------------------ |\n| `iterations`       | `IterationResult[]` | Per-iteration results (use `.length` for the count)    |\n| `completionSignal` | string              | The matched completion signal, or undefined            |\n| `stdout`           | string              | Combined stdout output from all agent iterations       |\n| `commits`          | { sha: string }[]   | List of commits made by the agent during the run       |\n| `branch`           | string              | The branch name the agent worked on                    |\n| `logFilePath`      | string              | Path to the log file, if logging was drained to a file |\n\n#### `WorktreeCreateSandboxOptions`\n\n| Option           | Type            | Default | Description                                                         |\n| ---------------- | --------------- | ------- | ------------------------------------------------------------------- |\n| `sandbox`        | SandboxProvider | —       | **Required.** Sandbox provider (e.g. `docker()`)                    |\n| `hooks`          | SandboxHooks    | —       | Lifecycle hooks (`host.*`, `sandbox.*`)                             |\n| `copyToWorktree` | string[]        | —       | Host-relative file paths to copy into the worktree at creation time |\n| `timeouts`       | Timeouts        | —       | Override default timeouts (e.g. `{ copyToWorktreeMs: 120_000 }`)    |\n\n## How it works\n\nSandcastle uses a **branch strategy** configured on the sandbox provider to control how the agent's changes relate to branches. There are three strategies:\n\n- **Head** (`{ type: \"head\" }`) — The agent writes directly to the host working directory. No worktree, no branch indirection. This is the default for bind-mount providers like `docker()`.\n- **Merge-to-head** (`{ type: \"merge-to-head\" }`) — Sandcastle creates a temporary branch in a git worktree. The agent works on the temp branch, and changes are merged back to HEAD when done. The temp branch is cleaned up after merge.\n- **Branch** (`{ type: \"branch\", branch: \"foo\" }`) — Commits land on an explicitly named branch in a git worktree.\n\nFor bind-mount providers (like Docker), the worktree directory is bind-mounted into the container — the agent writes directly to the host filesystem through the mount, so no sync is needed.\n\nFrom your point of view, you just configure `branchStrategy: { type: 'branch', branch: 'foo' }` on `run()`, and get a commit on branch `foo` once it's complete. All 100% local.\n\n## Prompts\n\nSandcastle uses a flexible prompt system. You write the prompt, and the engine executes it — no opinions about workflow, task management, or context sources are imposed.\n\n### Prompt resolution\n\nYou must provide exactly one of:\n\n1. `prompt: \"inline string\"` — pass an inline prompt directly via `RunOptions`\n2. `promptFile: \".\u002Fpath\u002Fto\u002Fprompt.md\"` — point to a specific file via `RunOptions`\n\n`prompt` and `promptFile` are mutually exclusive — providing both is an error. If neither is provided, `run()` throws an error asking you to supply one.\n\n**Inline prompts (`prompt: \"...\"`) are passed to the agent literally.** No `{{KEY}}` substitution, no `` !`command` `` expansion, no built-in `{{SOURCE_BRANCH}}` \u002F `{{TARGET_BRANCH}}` injection. If you need values interpolated into an inline prompt, build the string in JavaScript (`` `Work on ${branch}…` ``). Passing `promptArgs` alongside an inline prompt is an error — switch to `promptFile` to use substitution.\n\nThe substitution and expansion features below apply **only** to prompts sourced from `promptFile`.\n\n> **Convention**: `sandcastle init` scaffolds `.sandcastle\u002Fprompt.md` and all templates explicitly reference it via `promptFile: \".sandcastle\u002Fprompt.md\"`. This is a convention, not an automatic fallback — Sandcastle does not read `.sandcastle\u002Fprompt.md` unless you pass it as `promptFile`.\n\n### Dynamic context with `` !`command` ``\n\nUse `` !`command` `` expressions in your prompt to pull in dynamic context. Each expression is replaced with the command's stdout before the prompt is sent to the agent. All expressions in a prompt run **in parallel** for faster expansion.\n\nCommands run **inside the sandbox** after `sandbox.onSandboxReady` hooks complete, so they see the same repo state the agent sees (including installed dependencies).\n\n```markdown\n# Open issues\n\n!`gh issue list --state open --label Sandcastle --json number,title,body,comments,labels --limit 20`\n\n# Recent commits\n\n!`git log --oneline -10`\n```\n\nIf any command exits with a non-zero code, the run fails immediately with an error.\n\n### Prompt arguments with `{{KEY}}`\n\nUse `{{KEY}}` placeholders in your prompt to inject values from the `promptArgs` option. This is useful for reusing the same prompt file across multiple runs with different parameters.\n\n```typescript\nimport { run } from \"@ai-hero\u002Fsandcastle\";\n\nawait run({\n  promptFile: \".\u002Fmy-prompt.md\",\n  promptArgs: { ISSUE_NUMBER: 42, PRIORITY: \"high\" },\n});\n```\n\nIn the prompt file:\n\n```markdown\nWork on issue #{{ISSUE_NUMBER}} (priority: {{PRIORITY}}).\n```\n\nPrompt argument substitution runs on the host before shell expression expansion, so `{{KEY}}` placeholders inside `` !`command` `` expressions are replaced first:\n\n```markdown\n!`gh issue view {{ISSUE_NUMBER}} --json body -q .body`\n```\n\nA `{{KEY}}` placeholder with no matching prompt argument is an error. Unused prompt arguments produce a warning.\n\n`` !`command` `` expansion only runs on shell blocks written in the prompt file itself. Any `` !`…` `` pattern that appears inside an argument value is treated as inert text — it won't be executed against the host shell. This makes it safe to pass user-authored content (issue titles, PR descriptions, docs excerpts) through `promptArgs`.\n\n### Built-in prompt arguments\n\nSandcastle automatically injects two built-in prompt arguments into every prompt:\n\n| Placeholder         | Value                                                             |\n| ------------------- | ----------------------------------------------------------------- |\n| `{{SOURCE_BRANCH}}` | The branch the agent works on (determined by the branch strategy) |\n| `{{TARGET_BRANCH}}` | The host's active branch at `run()` time                          |\n\nUse them in your prompt without passing them via `promptArgs`:\n\n```markdown\nYou are working on {{SOURCE_BRANCH}}. When diffing, compare against {{TARGET_BRANCH}}.\n```\n\nPassing `SOURCE_BRANCH` or `TARGET_BRANCH` in `promptArgs` is an error — built-in prompt arguments cannot be overridden.\n\n### Early termination with `\u003Cpromise>COMPLETE\u003C\u002Fpromise>`\n\nWhen the agent outputs `\u003Cpromise>COMPLETE\u003C\u002Fpromise>`, the orchestrator stops the iteration loop early. This is a convention you document in your prompt for the agent to follow — the engine never injects it.\n\nThis is useful for task-based workflows where the agent should stop once it has finished, rather than running all remaining iterations.\n\nYou can override the default signal by passing `completionSignal` to `run()`. It accepts a single string or an array of strings:\n\n```ts\nawait run({\n  \u002F\u002F ...\n  completionSignal: \"DONE\",\n});\n\n\u002F\u002F Or pass multiple signals — the loop stops on the first match:\nawait run({\n  \u002F\u002F ...\n  completionSignal: [\"TASK_COMPLETE\", \"TASK_ABORTED\"],\n});\n```\n\nTell the agent to output your chosen string(s) in the prompt, and the orchestrator will stop when it detects any of them. The matched signal is returned as `result.completionSignal`.\n\n### Structured output\n\nUse `Output.object()` to extract a typed, schema-validated JSON payload from the agent's stdout. The agent emits its answer inside an XML tag you specify, and Sandcastle parses, validates, and returns it on `result.output`. See [ADR 0010](docs\u002Fadr\u002F0010-structured-output.md) for design rationale.\n\n```ts\nimport { run, Output, claudeCode } from \"@ai-hero\u002Fsandcastle\";\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\nimport { z } from \"zod\";\n\nconst result = await run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: docker(),\n  prompt: `Analyze the code, and output the result as JSON inside \u003Cresult> tags.\n    The result must match this schema:\n    { summary: string; score: string }\n  `,\n  output: Output.object({\n    tag: \"result\",\n    schema: z.object({ summary: z.string(), score: z.number() }),\n  }),\n});\n\nconsole.log(result.output.summary); \u002F\u002F typed as string\nconsole.log(result.output.score); \u002F\u002F typed as number\n```\n\n`Output.string({ tag })` extracts the tag contents as a plain string (trimmed, no JSON parsing). Both helpers require `maxIterations` to be `1` (the default). The resolved prompt must contain the configured opening tag literal.\n\n### Templates\n\n`sandcastle init` prompts you to choose a sandbox provider (Docker or Podman), a backlog manager (GitHub Issues or Beads), and a template, which scaffolds a ready-to-use prompt and `main.mts` suited to a specific workflow. If your project's `package.json` has `\"type\": \"module\"`, the file will be named `main.ts` instead. Five templates are available:\n\n| Template                       | Description                                                               |\n| ------------------------------ | ------------------------------------------------------------------------- |\n| `blank`                        | Bare scaffold — write your own prompt and orchestration                   |\n| `simple-loop`                  | Picks issues one by one and closes them                                   |\n| `sequential-reviewer`          | Implements issues one by one, with a code review step after each          |\n| `parallel-planner`             | Plans parallelizable issues, executes on separate branches, then merges   |\n| `parallel-planner-with-review` | Plans parallelizable issues, executes with per-branch review, then merges |\n\nSelect a template during `sandcastle init` when prompted, or re-run init in a fresh repo to try a different one.\n\n## CLI commands\n\n### `sandcastle init`\n\nScaffolds the `.sandcastle\u002F` config directory and builds the container image. This is the first command you run in a new repo. You choose a sandbox provider (Docker or Podman) during init — selecting Podman writes a `Containerfile` instead of `Dockerfile` and uses `sandcastle podman build-image` for the build step.\n\n| Option         | Required | Default                      | Description                                                          |\n| -------------- | -------- | ---------------------------- | -------------------------------------------------------------------- |\n| `--image-name` | No       | `sandcastle:\u003Crepo-dir-name>` | Docker image name                                                    |\n| `--agent`      | No       | Interactive prompt           | Agent to use (`claude-code`, `pi`, `codex`, `opencode`)              |\n| `--model`      | No       | Agent's default model        | Model to use (e.g. `claude-sonnet-4-6`). Defaults to agent's default |\n| `--template`   | No       | Interactive prompt           | Template to scaffold (e.g. `blank`, `simple-loop`)                   |\n\nCreates the following files:\n\n```\n.sandcastle\u002F\n├── Dockerfile      # Sandbox environment (customize as needed)\n├── prompt.md       # Agent instructions\n├── .env.example    # Token placeholders\n└── .gitignore      # Ignores .env, logs\u002F\n```\n\nErrors if `.sandcastle\u002F` already exists to prevent overwriting customizations.\n\n### `sandcastle docker build-image`\n\nRebuilds the Docker image from an existing `.sandcastle\u002F` directory. Use this after modifying the Dockerfile. On Linux\u002FmacOS, the build automatically passes `--build-arg AGENT_UID=$(id -u)` and `AGENT_GID=$(id -g)` so the image's `agent` user matches the host UID — this prevents permission errors on image-built files without runtime chown.\n\n| Option         | Required | Default                      | Description                                                                       |\n| -------------- | -------- | ---------------------------- | --------------------------------------------------------------------------------- |\n| `--image-name` | No       | `sandcastle:\u003Crepo-dir-name>` | Docker image name                                                                 |\n| `--dockerfile` | No       | —                            | Path to a custom Dockerfile (build context will be the current working directory) |\n\n### `sandcastle docker remove-image`\n\nRemoves the Docker image.\n\n| Option         | Required | Default                      | Description       |\n| -------------- | -------- | ---------------------------- | ----------------- |\n| `--image-name` | No       | `sandcastle:\u003Crepo-dir-name>` | Docker image name |\n\n### `sandcastle podman build-image`\n\nBuilds the Podman image from an existing `.sandcastle\u002F` directory. Use this after modifying the Containerfile.\n\n| Option            | Required | Default                      | Description                                                                          |\n| ----------------- | -------- | ---------------------------- | ------------------------------------------------------------------------------------ |\n| `--image-name`    | No       | `sandcastle:\u003Crepo-dir-name>` | Podman image name                                                                    |\n| `--containerfile` | No       | —                            | Path to a custom Containerfile (build context will be the current working directory) |\n\n### `sandcastle podman remove-image`\n\nRemoves the Podman image.\n\n| Option         | Required | Default                      | Description       |\n| -------------- | -------- | ---------------------------- | ----------------- |\n| `--image-name` | No       | `sandcastle:\u003Crepo-dir-name>` | Podman image name |\n\n### `RunOptions`\n\n| Option               | Type               | Default                       | Description                                                                                                                                                     |\n| -------------------- | ------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `agent`              | AgentProvider      | —                             | **Required.** Agent provider (e.g. `claudeCode(\"claude-opus-4-7\")`, `pi(\"claude-sonnet-4-6\")`, `codex(\"gpt-5.4-mini\")`, `opencode(\"opencode\u002Fbig-pickle\")`)      |\n| `sandbox`            | SandboxProvider    | —                             | **Required.** Sandbox provider (e.g. `docker()`, `podman()`, `docker({ imageName: \"sandcastle:local\" })`)                                                       |\n| `cwd`                | string             | `process.cwd()`               | Host repo directory — anchor for `.sandcastle\u002F` artifacts and git operations. Relative paths resolve against `process.cwd()`.                                   |\n| `prompt`             | string             | —                             | Inline prompt (mutually exclusive with `promptFile`)                                                                                                            |\n| `promptFile`         | string             | —                             | Path to prompt file (mutually exclusive with `prompt`). Resolves against `process.cwd()`, **not** `cwd`.                                                        |\n| `maxIterations`      | number             | `1`                           | Maximum iterations to run                                                                                                                                       |\n| `hooks`              | SandboxHooks       | —                             | Lifecycle hooks (`host.*`, `sandbox.*`)                                                                                                                         |\n| `name`               | string             | —                             | Display name for the run, shown as a prefix in log output                                                                                                       |\n| `promptArgs`         | PromptArgs         | —                             | Key-value map for `{{KEY}}` placeholder substitution                                                                                                            |\n| `branchStrategy`     | BranchStrategy     | per-provider default          | Branch strategy: `{ type: 'head' }`, `{ type: 'merge-to-head' }`, or `{ type: 'branch', branch: '…' }`                                                          |\n| `copyToWorktree`     | string[]           | —                             | Host-relative file paths to copy into the sandbox before start (not supported with `branchStrategy: { type: 'head' }`)                                          |\n| `logging`            | object             | file (auto-generated)         | `{ type: 'file', path }` or `{ type: 'stdout' }`                                                                                                                |\n| `completionSignal`   | string \\| string[] | `\u003Cpromise>COMPLETE\u003C\u002Fpromise>` | String or array of strings the agent emits to stop the iteration loop early                                                                                     |\n| `idleTimeoutSeconds` | number             | `600`                         | Idle timeout in seconds — resets on each agent output event                                                                                                     |\n| `resumeSession`      | string             | —                             | Resume a prior Claude Code session by ID. Incompatible with `maxIterations > 1`. Session file must exist on host.                                               |\n| `signal`             | AbortSignal        | —                             | Cancel the run when aborted. Kills the in-flight agent subprocess and cancels lifecycle hooks; the worktree is preserved on disk. Rejects with `signal.reason`. |\n| `timeouts`           | Timeouts           | —                             | Override default timeouts for built-in lifecycle steps. Currently supports `{ copyToWorktreeMs?: number }` (default: 60 000).                                   |\n| `output`             | OutputDefinition   | —                             | Structured output definition (`Output.object(…)` or `Output.string(…)`). Requires `maxIterations === 1`. See [Structured output](#structured-output).           |\n\n### `RunResult`\n\n| Field              | Type                | Description                                                        |\n| ------------------ | ------------------- | ------------------------------------------------------------------ |\n| `iterations`       | `IterationResult[]` | Per-iteration results (use `.length` for the count)                |\n| `completionSignal` | string?             | The matched completion signal string, or `undefined` if none fired |\n| `stdout`           | string              | Agent output                                                       |\n| `commits`          | `{ sha }[]`         | Commits created during the run                                     |\n| `branch`           | string              | Target branch name                                                 |\n| `logFilePath`      | string?             | Path to the log file (only when logging to a file)                 |\n| `output`           | T?                  | Typed structured output (only present when `output` option is set) |\n\n### `IterationResult`\n\n| Field             | Type              | Description                                                                                                                         |\n| ----------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- |\n| `sessionId`       | string?           | Claude Code session ID from the init line, or `undefined` for non-Claude agents                                                     |\n| `sessionFilePath` | string?           | Absolute host path to the captured session JSONL, or `undefined` when capture is off                                                |\n| `usage`           | `IterationUsage`? | Token usage snapshot from the last assistant message, or `undefined` when capture is off or provider does not support usage parsing |\n\n### `IterationUsage`\n\n| Field                      | Type   | Description                                |\n| -------------------------- | ------ | ------------------------------------------ |\n| `inputTokens`              | number | Input tokens consumed                      |\n| `cacheCreationInputTokens` | number | Tokens used to create prompt cache entries |\n| `cacheReadInputTokens`     | number | Tokens read from prompt cache              |\n| `outputTokens`             | number | Output tokens generated                    |\n\n### Session capture\n\nAfter each Claude Code iteration, Sandcastle automatically captures the agent's session JSONL from the sandbox to the host at `~\u002F.claude\u002Fprojects\u002F\u003Cencoded-path>\u002Fsessions\u002F\u003Csession-id>.jsonl`. The `cwd` fields inside each JSONL entry are rewritten to match the host repo root, so `claude --resume` works natively.\n\nSession capture is enabled by default for `claudeCode()` and can be opted out via `captureSessions: false`. Non-Claude agent providers never attempt capture. Capture failure fails the run.\n\n### Session resume\n\nPass `resumeSession` to `run()` to continue a prior Claude Code conversation inside a new sandbox:\n\n```typescript\nconst result = await run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: docker(),\n  prompt: \"Continue where you left off\",\n  resumeSession: \"abc-123-def\",\n});\n```\n\nBefore the sandbox starts, Sandcastle validates that the session file exists on the host and transfers it into the sandbox with `cwd` fields rewritten to match the sandbox-side path. The Claude Code agent receives `--resume \u003Cid>` on its print command for iteration 1.\n\nConstraints:\n\n- `resumeSession` is incompatible with `maxIterations > 1` (throws before sandbox creation).\n- The session file must exist at `~\u002F.claude\u002Fprojects\u002F\u003Cencoded-path>\u002Fsessions\u002F\u003Cid>.jsonl` (throws before sandbox creation).\n- Only iteration 1 receives the resume flag; subsequent iterations (if any) start fresh.\n- Non-Claude agent providers ignore `resumeSession`.\n\n### `ClaudeCodeOptions`\n\nThe `claudeCode()` factory accepts an optional second argument for provider-specific options:\n\n```typescript\nagent: claudeCode(\"claude-opus-4-7\", { effort: \"high\" });\n```\n\n| Option            | Type                                         | Default | Description                                               |\n| ----------------- | -------------------------------------------- | ------- | --------------------------------------------------------- |\n| `effort`          | `\"low\"` \\| `\"medium\"` \\| `\"high\"` \\| `\"max\"` | —       | Claude Code reasoning effort level (`max` is Opus only)   |\n| `env`             | `Record\u003Cstring, string>`                     | `{}`    | Environment variables injected by this agent provider     |\n| `captureSessions` | `boolean`                                    | `true`  | Capture agent session JSONL to host for `claude --resume` |\n\n### `CodexOptions`\n\nThe `codex()` factory accepts an optional second argument for provider-specific options:\n\n```typescript\nagent: codex(\"gpt-5.4\", { effort: \"high\" });\n```\n\n| Option   | Type                                           | Default | Description                                               |\n| -------- | ---------------------------------------------- | ------- | --------------------------------------------------------- |\n| `effort` | `\"low\"` \\| `\"medium\"` \\| `\"high\"` \\| `\"xhigh\"` | —       | Codex reasoning effort level via `model_reasoning_effort` |\n| `env`    | `Record\u003Cstring, string>`                       | `{}`    | Environment variables injected by this agent provider     |\n\n### Provider `env`\n\nBoth **agent providers** and **sandbox providers** accept an optional `env: Record\u003Cstring, string>` in their options. These environment variables are merged with the `.sandcastle\u002F.env` resolver output at launch time:\n\n```typescript\nawait run({\n  agent: claudeCode(\"claude-opus-4-7\", {\n    env: { ANTHROPIC_API_KEY: \"sk-ant-...\" },\n  }),\n  sandbox: docker({\n    env: { DOCKER_SPECIFIC_VAR: \"value\" },\n  }),\n  prompt: \"Fix issue #42\",\n});\n```\n\n**Merge rules:**\n\n- Provider env (agent + sandbox) overrides `.sandcastle\u002F.env` resolver output for shared keys\n- Agent provider env and sandbox provider env **must not overlap** — if they share any key, `run()` throws an error\n- When `env` is not provided, it defaults to `{}`\n\nEnvironment variables are also resolved automatically from `.sandcastle\u002F.env` and `process.env` — no need to pass them to the API. The required variables depend on the **agent provider** (see `sandcastle init` output for details).\n\n## Custom Sandbox Providers\n\nSandcastle ships with built-in providers for Docker, Podman, and Vercel, but you can create your own. A sandbox provider tells Sandcastle how to execute commands in an isolated environment. There are two kinds:\n\n- **Bind-mount** — the sandbox can mount a host directory. Sandcastle creates a worktree on the host and the provider mounts it in. No file sync needed. Use this for Docker, Podman, or any local container runtime.\n- **Isolated** — the sandbox has its own filesystem (e.g. a cloud VM). The provider handles syncing code in and out via `copyIn` and `copyFileOut`. Use this when the sandbox cannot access the host filesystem.\n\n### The sandbox handle contract\n\nBoth provider types return a **sandbox handle** from their `create()` function. The handle exposes:\n\n| Method         | Required   | Description                                                                  |\n| -------------- | ---------- | ---------------------------------------------------------------------------- |\n| `exec`         | Both       | Run a command, optionally streaming stdout line-by-line via `options.onLine` |\n| `close`        | Both       | Tear down the sandbox                                                        |\n| `copyFileIn`   | Bind-mount | Copy a single file from the host into the sandbox                            |\n| `copyFileOut`  | Both       | Copy a single file from the sandbox to the host                              |\n| `copyIn`       | Isolated   | Copy a file or directory from the host into the sandbox                      |\n| `worktreePath` | Both       | Absolute path to the repo directory inside the sandbox                       |\n\n### `ExecResult`\n\nEvery `exec` call returns an `ExecResult`:\n\n```typescript\ninterface ExecResult {\n  readonly stdout: string;\n  readonly stderr: string;\n  readonly exitCode: number;\n}\n```\n\n### Bind-mount provider example\n\nA minimal bind-mount provider that shells out to local processes (no container):\n\n```typescript\nimport {\n  createBindMountSandboxProvider,\n  type BindMountCreateOptions,\n  type BindMountSandboxHandle,\n  type ExecResult,\n} from \"@ai-hero\u002Fsandcastle\";\nimport { execFile, spawn } from \"node:child_process\";\nimport { copyFile as fsCopyFile, mkdir as fsMkdir } from \"node:fs\u002Fpromises\";\nimport { dirname } from \"node:path\";\nimport { createInterface } from \"node:readline\";\n\nconst localProcess = () =>\n  createBindMountSandboxProvider({\n    name: \"local-process\",\n    create: async (\n      options: BindMountCreateOptions,\n    ): Promise\u003CBindMountSandboxHandle> => {\n      const worktreePath = options.worktreePath;\n\n      return {\n        worktreePath,\n\n        exec: (\n          command: string,\n          opts?: { onLine?: (line: string) => void; cwd?: string },\n        ): Promise\u003CExecResult> => {\n          if (opts?.onLine) {\n            const onLine = opts.onLine;\n            return new Promise((resolve, reject) => {\n              const proc = spawn(\"sh\", [\"-c\", command], {\n                cwd: opts?.cwd ?? worktreePath,\n                stdio: [\"ignore\", \"pipe\", \"pipe\"],\n              });\n\n              const stdoutChunks: string[] = [];\n              const stderrChunks: string[] = [];\n\n              const rl = createInterface({ input: proc.stdout! });\n              rl.on(\"line\", (line) => {\n                stdoutChunks.push(line);\n                onLine(line); \u002F\u002F forward each line to Sandcastle\n              });\n\n              proc.stderr!.on(\"data\", (chunk: Buffer) => {\n                stderrChunks.push(chunk.toString());\n              });\n\n              proc.on(\"error\", (err) => reject(err));\n              proc.on(\"close\", (code) => {\n                resolve({\n                  stdout: stdoutChunks.join(\"\\n\"),\n                  stderr: stderrChunks.join(\"\"),\n                  exitCode: code ?? 0,\n                });\n              });\n            });\n          }\n\n          return new Promise((resolve, reject) => {\n            execFile(\n              \"sh\",\n              [\"-c\", command],\n              { cwd: opts?.cwd ?? worktreePath, maxBuffer: 10 * 1024 * 1024 },\n              (error, stdout, stderr) => {\n                if (error && error.code === undefined) {\n                  reject(new Error(`exec failed: ${error.message}`));\n                } else {\n                  resolve({\n                    stdout: stdout.toString(),\n                    stderr: stderr.toString(),\n                    exitCode: typeof error?.code === \"number\" ? error.code : 0,\n                  });\n                }\n              },\n            );\n          });\n        },\n\n        copyFileIn: async (hostPath: string, sandboxPath: string) => {\n          await fsMkdir(dirname(sandboxPath), { recursive: true });\n          await fsCopyFile(hostPath, sandboxPath);\n        },\n\n        copyFileOut: async (sandboxPath: string, hostPath: string) => {\n          await fsMkdir(dirname(hostPath), { recursive: true });\n          await fsCopyFile(sandboxPath, hostPath);\n        },\n\n        close: async () => {\n          \u002F\u002F nothing to tear down for a local process\n        },\n      };\n    },\n  });\n```\n\n### Isolated provider example\n\nA minimal isolated provider using a temp directory:\n\n```typescript\nimport {\n  createIsolatedSandboxProvider,\n  type IsolatedSandboxHandle,\n  type ExecResult,\n} from \"@ai-hero\u002Fsandcastle\";\nimport { execFile, spawn } from \"node:child_process\";\nimport { copyFile, mkdir, mkdtemp, rm } from \"node:fs\u002Fpromises\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { createInterface } from \"node:readline\";\n\nconst tempDir = () =>\n  createIsolatedSandboxProvider({\n    name: \"temp-dir\",\n    create: async (): Promise\u003CIsolatedSandboxHandle> => {\n      const root = await mkdtemp(join(tmpdir(), \"sandbox-\"));\n      const worktreePath = join(root, \"workspace\");\n      await mkdir(worktreePath, { recursive: true });\n\n      return {\n        worktreePath,\n\n        exec: (\n          command: string,\n          opts?: { onLine?: (line: string) => void; cwd?: string },\n        ): Promise\u003CExecResult> => {\n          if (opts?.onLine) {\n            const onLine = opts.onLine;\n            return new Promise((resolve, reject) => {\n              const proc = spawn(\"sh\", [\"-c\", command], {\n                cwd: opts?.cwd ?? worktreePath,\n                stdio: [\"ignore\", \"pipe\", \"pipe\"],\n              });\n\n              const stdoutChunks: string[] = [];\n              const stderrChunks: string[] = [];\n\n              const rl = createInterface({ input: proc.stdout! });\n              rl.on(\"line\", (line) => {\n                stdoutChunks.push(line);\n                onLine(line);\n              });\n\n              proc.stderr!.on(\"data\", (chunk: Buffer) => {\n                stderrChunks.push(chunk.toString());\n              });\n\n              proc.on(\"error\", (err) => reject(err));\n              proc.on(\"close\", (code) => {\n                resolve({\n                  stdout: stdoutChunks.join(\"\\n\"),\n                  stderr: stderrChunks.join(\"\"),\n                  exitCode: code ?? 0,\n                });\n              });\n            });\n          }\n\n          return new Promise((resolve, reject) => {\n            execFile(\n              \"sh\",\n              [\"-c\", command],\n              { cwd: opts?.cwd ?? worktreePath, maxBuffer: 10 * 1024 * 1024 },\n              (error, stdout, stderr) => {\n                if (error && error.code === undefined) {\n                  reject(new Error(`exec failed: ${error.message}`));\n                } else {\n                  resolve({\n                    stdout: stdout.toString(),\n                    stderr: stderr.toString(),\n                    exitCode: typeof error?.code === \"number\" ? error.code : 0,\n                  });\n                }\n              },\n            );\n          });\n        },\n\n        copyIn: async (hostPath: string, sandboxPath: string) => {\n          const info = await stat(hostPath);\n          if (info.isDirectory()) {\n            await cp(hostPath, sandboxPath, { recursive: true });\n          } else {\n            await mkdir(dirname(sandboxPath), { recursive: true });\n            await copyFile(hostPath, sandboxPath);\n          }\n        },\n\n        copyFileOut: async (sandboxPath: string, hostPath: string) => {\n          await mkdir(dirname(hostPath), { recursive: true });\n          await copyFile(sandboxPath, hostPath);\n        },\n\n        close: async () => {\n          await rm(root, { recursive: true, force: true });\n        },\n      };\n    },\n  });\n```\n\n### Branch strategies\n\nA branch strategy controls where the agent's commits land. Configure it when constructing the provider:\n\n| Strategy        | Behavior                                                                 | Bind-mount | Isolated  |\n| --------------- | ------------------------------------------------------------------------ | ---------- | --------- |\n| `head`          | Agent writes directly to the host working directory. No worktree created | Default    | N\u002FA       |\n| `merge-to-head` | Sandcastle creates a temp branch, merges back to HEAD when done          | Supported  | Default   |\n| `branch`        | Commits land on an explicit named branch you provide                     | Supported  | Supported |\n\n**When to use each:**\n\n- **`head`** — fast iteration during development. No branch indirection, no merge step. Only works with bind-mount providers since the agent needs direct host filesystem access.\n- **`merge-to-head`** — safe default for automation. The agent works on a throwaway branch; if something goes wrong, HEAD is untouched. Use this for CI or unattended runs.\n- **`branch`** — when you want commits on a specific branch (e.g. for a PR). Pass `{ type: \"branch\", branch: \"agent\u002Ffix-42\" }`.\n\nBranch strategy is now configured on `run()`, not on the provider:\n\n```typescript\nimport { run, claudeCode } from \"@ai-hero\u002Fsandcastle\";\nimport { docker } from \"@ai-hero\u002Fsandcastle\u002Fsandboxes\u002Fdocker\";\n\n\u002F\u002F head — direct write, bind-mount only (default for bind-mount providers)\nawait run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: docker(),\n  prompt: \"…\",\n});\n\u002F\u002F merge-to-head — temp branch, merge back (default for isolated providers)\nawait run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: tempDir(),\n  prompt: \"…\",\n});\n\u002F\u002F branch — explicit named branch\nawait run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: docker(),\n  branchStrategy: { type: \"branch\", branch: \"agent\u002Ffix-42\" },\n  prompt: \"…\",\n});\n```\n\n### Passing to `run()`\n\nPass your custom provider via the `sandbox` option — it works the same as the built-in `docker()` provider:\n\n```typescript\nimport { run, claudeCode } from \"@ai-hero\u002Fsandcastle\";\n\nconst result = await run({\n  agent: claudeCode(\"claude-opus-4-7\"),\n  sandbox: localProcess(), \u002F\u002F your custom provider\n  prompt: \"Fix issue #42 in this repo.\",\n});\n```\n\n### Reference implementations\n\nFor real-world examples, see:\n\n- [`src\u002Fsandboxes\u002Fdocker.ts`](src\u002Fsandboxes\u002Fdocker.ts) — bind-mount provider using Docker containers (with SELinux label support)\n- [`src\u002Fsandboxes\u002Fvercel.ts`](src\u002Fsandboxes\u002Fvercel.ts) — isolated provider using Vercel Firecracker microVMs via `@vercel\u002Fsandbox`\n- [`src\u002Fsandboxes\u002Fpodman.ts`](src\u002Fsandboxes\u002Fpodman.ts) — bind-mount provider using Podman containers (with SELinux label support)\n- [`src\u002Fsandboxes\u002Ftest-isolated.ts`](src\u002Fsandboxes\u002Ftest-isolated.ts) — isolated provider using temp directories (used in tests)\n\n## Configuration\n\n### Config directory (`.sandcastle\u002F`)\n\nAll per-repo sandbox configuration lives in `.sandcastle\u002F`. Run `sandcastle init` to create it.\n\n### Custom Dockerfile\n\nThe `.sandcastle\u002FDockerfile` controls the sandbox environment. The default template installs:\n\n- **Node.js 22** (base image)\n- **git**, **curl**, **jq** (system dependencies)\n- **GitHub CLI** (`gh`)\n- **Claude Code CLI**\n- A non-root `agent` user (required — Claude runs as this user)\n\nWhen customizing the Dockerfile, ensure you keep:\n\n- A non-root user (the default `agent` user) for Claude to run as\n- `git` (required for commits and branch operations)\n- `gh` (required for issue fetching)\n- Claude Code CLI installed and on PATH\n\nAdd your project-specific dependencies (e.g., language runtimes, build tools) to the Dockerfile as needed.\n\n### Hooks\n\nHooks are grouped by **where** they run — `host` (on the developer's machine) or `sandbox` (inside the container):\n\n```ts\nhooks: {\n  host: {\n    onWorktreeReady: [{ command: \"cp .env.example .env\" }],\n    onSandboxReady:  [{ command: \"echo sandbox is up\" }],\n  },\n  sandbox: {\n    onSandboxReady: [\n      { command: \"npm install\", timeoutMs: 300_000 },\n      { command: \"apt-get install -y ffmpeg\", sudo: true },\n    ],\n  },\n}\n```\n\n| Hook                     | Runs on | When                                         | Working directory                           |\n| ------------------------ | ------- | --","Sandcastle 是一个用于在隔离沙箱中编排 AI 编码代理的 TypeScript 库。通过简单的 `sandcastle.run()` 方法即可调用代理，该库负责使用可配置的分支策略对代理进行沙箱处理，并将分支上的提交合并回主分支。Sandcastle 支持多种沙箱提供者，包括 Docker、Podman 和 Vercel，同时也允许用户自定义沙箱环境。这使得它非常适合需要并行运行多个自动化代理、构建代码审查流水线或管理个人编码代理的场景。其灵活性和易用性为开发者提供了强大的工具来提高开发效率和安全性。",2,"2026-06-11 03:50:53","high_star"]