{"_id":"@amartya18/sandcastle","name":"@amartya18/sandcastle","dist-tags":{"latest":"0.5.7"},"versions":{"0.5.7":{"name":"@amartya18/sandcastle","version":"0.5.7","description":"CLI for orchestrating AI agents in isolated sandbox environments","type":"module","main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":"./dist/index.js","types":"./dist/index.d.ts"},"./sandboxes/docker":{"import":"./dist/sandboxes/docker.js","types":"./dist/sandboxes/docker.d.ts"},"./sandboxes/vercel":{"import":"./dist/sandboxes/vercel.js","types":"./dist/sandboxes/vercel.d.ts"},"./sandboxes/podman":{"import":"./dist/sandboxes/podman.js","types":"./dist/sandboxes/podman.d.ts"},"./sandboxes/daytona":{"import":"./dist/sandboxes/daytona.js","types":"./dist/sandboxes/daytona.d.ts"},"./sandboxes/no-sandbox":{"import":"./dist/sandboxes/no-sandbox.js","types":"./dist/sandboxes/no-sandbox.d.ts"}},"bin":{"sandcastle":"dist/main.js"},"scripts":{"build":"tsgo --project tsconfig.build.json","postbuild":"rm -rf dist/templates && cp -r src/templates dist/templates","test":"vitest run","test:watch":"vitest","typecheck":"tsgo --noEmit","format":"prettier --write .","format:check":"prettier --check .","prepare":"husky","release":"changeset publish","sandcastle":"npm run build && tsx .sandcastle/run.ts","test-podman":"npm run build && tsx --env-file=.sandcastle/.env .sandcastle/test-podman.ts","test-vercel":"npm run build && tsx --env-file=.sandcastle/.env .sandcastle/test-vercel.ts","test-interactive":"npm run build && tsx --env-file=.sandcastle/.env .sandcastle/test-interactive.ts"},"keywords":["cli","sandbox","docker","ai","agent"],"packageManager":"npm@10.9.2","repository":{"type":"git","url":"git+https://github.com/amartya18/sandcastle.git"},"license":"MIT","devDependencies":{"@changesets/cli":"^2.30.0","@daytona/sdk":"^0.164.0","@effect/vitest":"^0.28.0","@types/node":"^25.5.0","@typescript/native-preview":"^7.0.0-dev.20260317.1","husky":"^9.1.7","lint-staged":"^15.5.1","prettier":"^3.5.3","tsx":"^4.21.0","vitest":"^3.2.0"},"dependencies":{"@clack/prompts":"^1.1.0","@effect/cli":"^0.74.0","@effect/platform":"^0.95.0","@effect/platform-node":"^0.105.0","@effect/printer":"^0.48.0","@effect/printer-ansi":"^0.48.0","effect":"^3.20.0"},"peerDependencies":{"@daytona/sdk":"^0.164.0","@vercel/sandbox":">=1.0.0"},"peerDependenciesMeta":{"@vercel/sandbox":{"optional":true},"@daytona/sdk":{"optional":true}},"lint-staged":{"*.{ts,tsx,js,jsx,json,md}":"prettier --write"},"gitHead":"4018c313c4dfb2201b7238c7af4b966538170532","_id":"@amartya18/sandcastle@0.5.7","bugs":{"url":"https://github.com/amartya18/sandcastle/issues"},"homepage":"https://github.com/amartya18/sandcastle#readme","_nodeVersion":"24.14.1","_npmVersion":"11.12.1","dist":{"integrity":"sha512-9D0DWkSfmfKHCNJw/4aXaYnykqTW5YOXHv0XZ2rz2b6MHwfu7+nu8jWWXdA5A36E3Mq52ZTaVv4D/BRk9rbNCw==","shasum":"4af0042a5f1ecc77413f57013611e86639787cd7","tarball":"https://registry.npmjs.org/@amartya18/sandcastle/-/sandcastle-0.5.7.tgz","fileCount":222,"unpackedSize":808488,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIGhZOM0HwvPE48Widz5lCxz1Ou+Hjl+M40cQ6n2LGthiAiBMjfo5xZqb4+AcllGQ6J2Ta6g0ONIP7baMmZZM+7B+Cw=="}]},"_npmUser":{"name":"amartya18","email":"amartya.sk@gmail.com"},"directories":{},"maintainers":[{"name":"amartya18","email":"amartya.sk@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/sandcastle_0.5.7_1777708282942_0.09961090393020045"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-02T07:51:22.837Z","0.5.7":"2026-05-02T07:51:23.143Z","modified":"2026-05-02T07:51:23.350Z"},"maintainers":[{"name":"amartya18","email":"amartya.sk@gmail.com"}],"description":"CLI for orchestrating AI agents in isolated sandbox environments","homepage":"https://github.com/amartya18/sandcastle#readme","keywords":["cli","sandbox","docker","ai","agent"],"repository":{"type":"git","url":"git+https://github.com/amartya18/sandcastle.git"},"bugs":{"url":"https://github.com/amartya18/sandcastle/issues"},"license":"MIT","readme":"<div align=\"center\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://res.cloudinary.com/total-typescript/image/upload/v1775033787/readme-sandcastle-ondark_2x.png\">\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://res.cloudinary.com/total-typescript/image/upload/v1775033787/readme-sandcastle-onlight_2x.png\">\n    <img alt=\"Sandcastle\" src=\"https://res.cloudinary.com/total-typescript/image/upload/v1775033787/readme-sandcastle-onlight_2x.png\" height=\"200\" style=\"margin-bottom: 20px;\">\n  </picture>\n</div>\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://git-scm.com/)\n- A sandbox provider — Sandcastle needs an isolated environment to run agents in. Built-in options:\n  - [Docker Desktop](https://www.docker.com/) — most common for local development\n  - [Podman](https://podman.io/) — rootless alternative to Docker\n  - [Vercel](https://vercel.com/) — cloud-based Firecracker microVMs via `@vercel/sandbox`\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 @amartya18/sandcastle\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/.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://github.com/mattpocock/sandcastle/issues/191).\n\n```bash\ncp .sandcastle/.env.example .sandcastle/.env\n```\n\n4. Run the `.sandcastle/main.ts` (or `main.mts`) file with `npx tsx`\n\n```bash\nnpx tsx .sandcastle/main.ts\n```\n\n```typescript\n// 3. Run the agent via the JS API\nimport { run, claudeCode } from \"@amartya18/sandcastle\";\nimport { docker } from \"@amartya18/sandcastle/sandboxes/docker\";\n\nawait run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: docker(), // or podman(), vercel(), or your own provider\n  promptFile: \".sandcastle/prompt.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     | `@amartya18/sandcastle/sandboxes/docker`     | Bind-mount | `run()`, `createSandbox()`, `interactive()`   |\n| Podman     | `@amartya18/sandcastle/sandboxes/podman`     | Bind-mount | `run()`, `createSandbox()`, `interactive()`   |\n| Vercel     | `@amartya18/sandcastle/sandboxes/vercel`     | Isolated   | `run()`, `createSandbox()`, `interactive()`   |\n| No-sandbox | `@amartya18/sandcastle/sandboxes/no-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 \"@amartya18/sandcastle/sandboxes/docker\";\nimport { podman } from \"@amartya18/sandcastle/sandboxes/podman\";\nimport { vercel } from \"@amartya18/sandcastle/sandboxes/vercel\";\nimport { noSandbox } from \"@amartya18/sandcastle/sandboxes/no-sandbox\";\n\n// Docker, Podman, and Vercel are interchangeable in run() and createSandbox():\nawait run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: docker(),\n  prompt: \"...\",\n});\n\n// No-sandbox runs the agent directly on the host — interactive() only:\nawait interactive({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: noSandbox(),\n  prompt: \"...\", // optional — omit to launch the TUI with no initial prompt\n  cwd: \"/path/to/other-repo\", // 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 \"@amartya18/sandcastle\";\nimport { docker } from \"@amartya18/sandcastle/sandboxes/docker\";\n\nconst result = await run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: docker(),\n  promptFile: \".sandcastle/prompt.md\",\n});\n\nconsole.log(result.iterations.length); // number of iterations executed\nconsole.log(result.iterations); // per-iteration results with optional sessionId\nconsole.log(result.commits); // array of { sha } for commits created\nconsole.log(result.branch); // target branch name\n```\n\n### All options\n\n```typescript\nimport { run, claudeCode } from \"@amartya18/sandcastle\";\nimport { docker } from \"@amartya18/sandcastle/sandboxes/docker\";\n\nconst result = await run({\n  // Agent provider — required. Pass a model string to claudeCode().\n  // Optional second arg for provider-specific options like effort level.\n  agent: claudeCode(\"claude-opus-4-6\", { effort: \"high\" }),\n\n  // Sandbox provider — required. Any SandboxProvider works (docker, podman, vercel, or custom).\n  // Provider-specific config (like imageName, mounts) lives inside the provider factory call.\n  sandbox: docker({\n    imageName: \"sandcastle:local\",\n    // Optional: mount host directories into the sandbox (e.g. package manager caches)\n    // hostPath supports absolute, tilde-expanded (~), and relative paths (resolved from cwd).\n    // sandboxPath supports absolute and relative paths (resolved from the sandbox repo directory).\n    mounts: [\n      { hostPath: \"~/.npm\", sandboxPath: \"/home/agent/.npm\", readonly: true },\n      { hostPath: \"data\", sandboxPath: \"data\" }, // mounts <cwd>/data → <sandbox-repo>/data\n    ],\n    // Optional: provider-level env vars merged at launch time\n    env: { DOCKER_SPECIFIC: \"value\" },\n    // Optional: attach container to Docker network(s) — string or string[]\n    network: \"my-network\",\n  }),\n\n  // Host repo directory — replaces process.cwd() as the anchor for\n  // .sandcastle/ artifacts (worktrees, logs, env, patches) and git operations.\n  // Relative paths resolve against process.cwd(). Defaults to process.cwd().\n  cwd: \"../other-repo\",\n\n  // Branch strategy — controls how the agent's changes relate to branches.\n  // Defaults to { type: \"head\" } for bind-mount and { type: \"merge-to-head\" } for isolated providers.\n  branchStrategy: { type: \"branch\", branch: \"agent/fix-42\" },\n\n  // Prompt source — provide one of these, not both.\n  // Note: promptFile resolves against process.cwd(), NOT cwd.\n  promptFile: \".sandcastle/prompt.md\", // path to a prompt file\n  // prompt: \"Fix issue #42 in this repo\", // OR an inline prompt string\n\n  // Values substituted for {{KEY}} placeholders in the prompt.\n  promptArgs: {\n    ISSUE_NUMBER: \"42\",\n  },\n\n  // Maximum number of agent iterations to run before stopping. Default: 1\n  maxIterations: 5,\n\n  // Display name for this run, shown as a prefix in log output.\n  name: \"fix-issue-42\",\n\n  // 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  // Host-relative file paths to copy into the sandbox before the container starts.\n  // Not supported with branchStrategy: { type: \"head\" }.\n  copyToWorktree: [\".env\"],\n\n  // Override default timeouts for built-in lifecycle steps.\n  // Unset keys keep their defaults.\n  timeouts: {\n    copyToWorktreeMs: 120_000, // default: 60_000\n  },\n\n  // How to record progress. Default: write to a file under .sandcastle/logs/\n  logging: {\n    type: \"file\",\n    path: \".sandcastle/logs/my-run.log\",\n    // Optional: forward the agent's output stream to your own observability system.\n    // Fires for each text chunk and tool call the agent produces. Errors thrown\n    // by the callback are swallowed so a broken forwarder cannot kill the run.\n    onAgentStreamEvent: (event) => {\n      // event is { type: \"text\" | \"toolCall\", iteration, timestamp, ... }\n      myLogger.info(event);\n    },\n  },\n  // logging: { type: \"stdout\" }, // OR render an interactive UI in the terminal\n\n  // String (or array of strings) the agent emits to end the iteration loop early.\n  // Default: \"<promise>COMPLETE</promise>\"\n  completionSignal: \"<promise>COMPLETE</promise>\",\n\n  // Idle timeout in seconds — resets whenever the agent produces output. Default: 600 (10 minutes)\n  idleTimeoutSeconds: 600,\n});\n\nconsole.log(result.iterations.length); // number of iterations executed\nconsole.log(result.completionSignal); // matched signal string, or undefined if none fired\nconsole.log(result.commits); // array of { sha } for commits created\nconsole.log(result.branch); // 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 \"@amartya18/sandcastle\";\nimport { docker } from \"@amartya18/sandcastle/sandboxes/docker\";\n\nawait using sandbox = await createSandbox({\n  branch: \"agent/fix-42\",\n  sandbox: docker(),\n});\n\nconst result = await sandbox.run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  prompt: \"Fix issue #42 in this repo.\",\n});\n\nconsole.log(result.commits); // [{ sha: \"abc123\" }]\n```\n\n#### Multi-run implement-then-review\n\n```typescript\nimport { createSandbox, claudeCode } from \"@amartya18/sandcastle\";\nimport { docker } from \"@amartya18/sandcastle/sandboxes/docker\";\n\nawait using sandbox = await createSandbox({\n  branch: \"agent/fix-42\",\n  sandbox: docker(),\n  hooks: { sandbox: { onSandboxReady: [{ command: \"npm install\" }] } },\n});\n\n// Step 1: implement\nconst implResult = await sandbox.run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  promptFile: \".sandcastle/implement.md\",\n  maxIterations: 5,\n});\n\n// 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/fix-42\",\n  sandbox: docker(),\n});\n// ... 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 / 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<SandboxRunResult>`                 | Invoke an agent inside the existing sandbox  |\n| `interactive(options)`  | `(SandboxInteractiveOptions) => Promise<SandboxInteractiveResult>` | Launch an interactive session in the sandbox |\n| `close()`               | `() => Promise<CloseResult>`                                       | Tear down the container and sandbox          |\n| `[Symbol.asyncDispose]` | `() => Promise<void>`                                              | 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-6\")`) |\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[] | `<promise>COMPLETE</promise>` | 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 \"@amartya18/sandcastle\";\n\nawait using wt = await createWorktree({\n  branchStrategy: { type: \"branch\", branch: \"agent/fix-42\" },\n  copyToWorktree: [\"node_modules\"],\n  cwd: \"/path/to/other-repo\", // optional — defaults to process.cwd()\n});\n\nconsole.log(wt.worktreePath); // host path to the worktree\nconsole.log(wt.branch); // \"agent/fix-42\"\n\n// Run an interactive session in the worktree (defaults to noSandbox)\nawait wt.interactive({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  prompt: \"Explore the codebase and understand the bug.\",\n});\n\n// Run an AFK agent in the worktree (sandbox is required)\nconst result = await wt.run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: docker({ imageName: \"sandcastle:myrepo\" }),\n  prompt: \"Fix issue #42.\",\n  maxIterations: 3,\n});\nconsole.log(result.commits); // commits made during the run\n\n// Create a long-lived sandbox from the worktree\nimport { docker } from \"@amartya18/sandcastle/sandboxes/docker\";\n\nawait using sandbox = await wt.createSandbox({\n  sandbox: docker(),\n  hooks: { sandbox: { onSandboxReady: [{ command: \"npm install\" }] } },\n});\n\n// sandbox.close() tears down the container only — the worktree stays\nawait sandbox.close();\n\n// 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 / 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<WorktreeRunResult>`         | Run an AFK agent in the worktree (sandbox required) |\n| `interactive(options)`   | `(options: WorktreeInteractiveOptions) => Promise<InteractiveResult>` | Run an interactive agent session in the worktree    |\n| `createSandbox(options)` | `(options: WorktreeCreateSandboxOptions) => Promise<Sandbox>`         | Create a long-lived sandbox backed by this worktree |\n| `close()`                | `() => Promise<CloseResult>`                                          | Clean up the worktree (preserves if dirty)          |\n| `[Symbol.asyncDispose]`  | `() => Promise<void>`                                                 | 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<string, 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<string, 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: \"./path/to/prompt.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}}` / `{{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/prompt.md` and all templates explicitly reference it via `promptFile: \".sandcastle/prompt.md\"`. This is a convention, not an automatic fallback — Sandcastle does not read `.sandcastle/prompt.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 \"@amartya18/sandcastle\";\n\nawait run({\n  promptFile: \"./my-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 `<promise>COMPLETE</promise>`\n\nWhen the agent outputs `<promise>COMPLETE</promise>`, 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  // ...\n  completionSignal: \"DONE\",\n});\n\n// Or pass multiple signals — the loop stops on the first match:\nawait run({\n  // ...\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### 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 GitHub 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/` 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:<repo-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/\n├── Dockerfile      # Sandbox environment (customize as needed)\n├── prompt.md       # Agent instructions\n├── .env.example    # Token placeholders\n└── .gitignore      # Ignores .env, logs/\n```\n\nErrors if `.sandcastle/` already exists to prevent overwriting customizations.\n\n### `sandcastle docker build-image`\n\nRebuilds the Docker image from an existing `.sandcastle/` directory. Use this after modifying the Dockerfile.\n\n| Option         | Required | Default                      | Description                                                                       |\n| -------------- | -------- | ---------------------------- | --------------------------------------------------------------------------------- |\n| `--image-name` | No       | `sandcastle:<repo-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:<repo-dir-name>` | Docker image name |\n\n### `sandcastle podman build-image`\n\nBuilds the Podman image from an existing `.sandcastle/` directory. Use this after modifying the Containerfile.\n\n| Option            | Required | Default                      | Description                                                                          |\n| ----------------- | -------- | ---------------------------- | ------------------------------------------------------------------------------------ |\n| `--image-name`    | No       | `sandcastle:<repo-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:<repo-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-6\")`, `pi(\"claude-sonnet-4-6\")`, `codex(\"gpt-5.4-mini\")`, `opencode(\"opencode/big-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/` 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[] | `<promise>COMPLETE</promise>` | 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\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\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 `~/.claude/projects/<encoded-path>/sessions/<session-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-6\"),\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 <id>` 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 `~/.claude/projects/<encoded-path>/sessions/<id>.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-6\", { 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<string, 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<string, string>`                       | `{}`    | Environment variables injected by this agent provider     |\n\n### Provider `env`\n\nBoth **agent providers** and **sandbox providers** accept an optional `env: Record<string, string>` in their options. These environment variables are merged with the `.sandcastle/.env` resolver output at launch time:\n\n```typescript\nawait run({\n  agent: claudeCode(\"claude-opus-4-6\", {\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/.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/.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 \"@amartya18/sandcastle\";\nimport { execFile, spawn } from \"node:child_process\";\nimport { copyFile as fsCopyFile, mkdir as fsMkdir } from \"node:fs/promises\";\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<BindMountSandboxHandle> => {\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<ExecResult> => {\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); // 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          // 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 \"@amartya18/sandcastle\";\nimport { execFile, spawn } from \"node:child_process\";\nimport { copyFile, mkdir, mkdtemp, rm } from \"node:fs/promises\";\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<IsolatedSandboxHandle> => {\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<ExecResult> => {\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/A       |\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/fix-42\" }`.\n\nBranch strategy is now configured on `run()`, not on the provider:\n\n```typescript\nimport { run, claudeCode } from \"@amartya18/sandcastle\";\nimport { docker } from \"@amartya18/sandcastle/sandboxes/docker\";\n\n// head — direct write, bind-mount only (default for bind-mount providers)\nawait run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: docker(),\n  prompt: \"…\",\n});\n// merge-to-head — temp branch, merge back (default for isolated providers)\nawait run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: tempDir(),\n  prompt: \"…\",\n});\n// branch — explicit named branch\nawait run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: docker(),\n  branchStrategy: { type: \"branch\", branch: \"agent/fix-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 \"@amartya18/sandcastle\";\n\nconst result = await run({\n  agent: claudeCode(\"claude-opus-4-6\"),\n  sandbox: localProcess(), // 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/sandboxes/docker.ts`](src/sandboxes/docker.ts) — bind-mount provider using Docker containers\n- [`src/sandboxes/vercel.ts`](src/sandboxes/vercel.ts) — isolated provider using Vercel Firecracker microVMs via `@vercel/sandbox`\n- [`src/sandboxes/podman.ts`](src/sandboxes/podman.ts) — bind-mount provider using Podman containers (with SELinux label support)\n- [`src/sandboxes/test-isolated.ts`](src/sandboxes/test-isolated.ts) — isolated provider using temp directories (used in tests)\n\n## Configuration\n\n### Config directory (`.sandcastle/`)\n\nAll per-repo sandbox configuration lives in `.sandcastle/`. Run `sandcastle init` to create it.\n\n### Custom Dockerfile\n\nThe `.sandcastle/Dockerfile` 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| ------------------------ | ------- | -------------------------------------------- | ------------------------------------------- |\n| `host.onWorktreeReady`   | Host    | After `copyToWorktree`, before sandbox start | Worktree path (host repo root under `head`) |\n| `host.onSandboxReady`    | Host    | After sandbox is up                          | Worktree path (host repo root under `head`) |\n| `sandbox.onSandboxReady` | Sandbox | After sandbox is up                          | Sandbox repo directory                      |\n\n**Ordering:** `copyToWorktree` -> `host.onWorktreeReady` (sequential) -> sandbox created -> `host.onSandboxReady` + `sandbox.onSandboxReady` (parallel).\n\n- **Host hooks** accept `{ command: string; timeoutMs?: number }` — no `sudo`, no `cwd`. Use `cd` or inline env in the command string.\n- **Sandbox hooks** accept `{ command: string; sudo?: boolean; timeoutMs?: number }` — set `sudo: true` for elevated privileges.\n- **`timeoutMs`** overrides the default 60 s per-hook timeout. Useful for long-running setup commands like dependency installs (e.g. `timeoutMs: 300_000` for 5 minutes).\n- Within each hook point, sandbox hooks run in parallel; host hooks within `onSandboxReady` also run in parallel with sandbox hooks. `host.onWorktreeReady` hooks run sequentially in declared order.\n- If any hook exits non-zero, setup fails fast.\n- When a `signal` is passed to `run()`, it is threaded to all hooks — aborting the signal cancels any in-flight hook commands.\n\n## Development\n\n```bash\nnpm install\nnpm run build    # Build with tsgo\nnpm test         # Run tests with vitest\nnpm run typecheck # Type-check\n```\n\n## License\n\nMIT\n","readmeFilename":"README.md","_rev":"1-8bb79d2b4a501d75f43442eccb085a27"}