@ai-hero/sandcastle 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +322 -42
  2. package/dist/AgentProvider.d.ts +0 -10
  3. package/dist/AgentProvider.d.ts.map +1 -1
  4. package/dist/AgentProvider.js +28 -45
  5. package/dist/AgentProvider.js.map +1 -1
  6. package/dist/DockerLifecycle.d.ts +5 -1
  7. package/dist/DockerLifecycle.d.ts.map +1 -1
  8. package/dist/DockerLifecycle.js +8 -1
  9. package/dist/DockerLifecycle.js.map +1 -1
  10. package/dist/InitService.d.ts.map +1 -1
  11. package/dist/InitService.js +2 -0
  12. package/dist/InitService.js.map +1 -1
  13. package/dist/Orchestrator.d.ts +0 -1
  14. package/dist/Orchestrator.d.ts.map +1 -1
  15. package/dist/Orchestrator.js +2 -13
  16. package/dist/Orchestrator.js.map +1 -1
  17. package/dist/SandboxFactory.d.ts +22 -11
  18. package/dist/SandboxFactory.d.ts.map +1 -1
  19. package/dist/SandboxFactory.js +166 -209
  20. package/dist/SandboxFactory.js.map +1 -1
  21. package/dist/SandboxProvider.d.ts +141 -0
  22. package/dist/SandboxProvider.d.ts.map +1 -0
  23. package/dist/SandboxProvider.js +27 -0
  24. package/dist/SandboxProvider.js.map +1 -0
  25. package/dist/cli.d.ts +7 -5
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +12 -11
  28. package/dist/cli.js.map +1 -1
  29. package/dist/createSandbox.d.ts +3 -2
  30. package/dist/createSandbox.d.ts.map +1 -1
  31. package/dist/createSandbox.js +19 -32
  32. package/dist/createSandbox.js.map +1 -1
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/run.d.ts +4 -25
  38. package/dist/run.d.ts.map +1 -1
  39. package/dist/run.js +21 -35
  40. package/dist/run.js.map +1 -1
  41. package/dist/sandboxExec.d.ts +12 -0
  42. package/dist/sandboxExec.d.ts.map +1 -0
  43. package/dist/sandboxExec.js +26 -0
  44. package/dist/sandboxExec.js.map +1 -0
  45. package/dist/sandboxes/docker.d.ts +28 -0
  46. package/dist/sandboxes/docker.d.ts.map +1 -0
  47. package/dist/sandboxes/docker.js +134 -0
  48. package/dist/sandboxes/docker.js.map +1 -0
  49. package/dist/sandboxes/test-isolated.d.ts +21 -0
  50. package/dist/sandboxes/test-isolated.d.ts.map +1 -0
  51. package/dist/sandboxes/test-isolated.js +87 -0
  52. package/dist/sandboxes/test-isolated.js.map +1 -0
  53. package/dist/syncIn.d.ts +24 -0
  54. package/dist/syncIn.d.ts.map +1 -0
  55. package/dist/syncIn.js +107 -0
  56. package/dist/syncIn.js.map +1 -0
  57. package/dist/syncOut.d.ts +27 -0
  58. package/dist/syncOut.d.ts.map +1 -0
  59. package/dist/syncOut.js +271 -0
  60. package/dist/syncOut.js.map +1 -0
  61. package/dist/templates/blank/.env.example +1 -0
  62. package/dist/templates/blank/main.mts +2 -0
  63. package/dist/templates/parallel-planner/.env.example +1 -0
  64. package/dist/templates/parallel-planner/main.mts +5 -2
  65. package/dist/templates/sequential-reviewer/.env.example +1 -0
  66. package/dist/templates/sequential-reviewer/main.mts +3 -1
  67. package/dist/templates/simple-loop/.env.example +1 -0
  68. package/dist/templates/simple-loop/main.mts +4 -0
  69. package/package.json +5 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  A TypeScript library for orchestrating AI coding agents in isolated Docker containers:
12
12
 
13
13
  1. You invoke agents with a single `sandcastle.run()`.
14
- 2. Sandcastle handles building worktrees and sandboxing the agent.
14
+ 2. Sandcastle handles sandboxing the agent with a configurable branch strategy.
15
15
  3. The commits made on the branches get merged back.
16
16
 
17
17
  Great for parallelizing multiple AFK agents, creating review pipelines, or even just orchestrating your own agents.
@@ -35,7 +35,7 @@ npm install @ai-hero/sandcastle
35
35
  npx sandcastle init
36
36
  ```
37
37
 
38
- 3. Edit `.sandcastle/.env` and fill in your default values for `ANTHROPIC_API_KEY`
38
+ 3. 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).
39
39
 
40
40
  ```bash
41
41
  cp .sandcastle/.env.example .sandcastle/.env
@@ -50,9 +50,11 @@ npx tsx .sandcastle/main.ts
50
50
  ```typescript
51
51
  // 3. Run the agent via the JS API
52
52
  import { run, claudeCode } from "@ai-hero/sandcastle";
53
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
53
54
 
54
55
  await run({
55
56
  agent: claudeCode("claude-opus-4-6"),
57
+ sandbox: docker(),
56
58
  promptFile: ".sandcastle/prompt.md",
57
59
  });
58
60
  ```
@@ -63,9 +65,11 @@ Sandcastle exports a programmatic `run()` function for use in scripts, CI pipeli
63
65
 
64
66
  ```typescript
65
67
  import { run, claudeCode } from "@ai-hero/sandcastle";
68
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
66
69
 
67
70
  const result = await run({
68
71
  agent: claudeCode("claude-opus-4-6"),
72
+ sandbox: docker(),
69
73
  promptFile: ".sandcastle/prompt.md",
70
74
  });
71
75
 
@@ -78,12 +82,20 @@ console.log(result.branch); // target branch name
78
82
 
79
83
  ```typescript
80
84
  import { run, claudeCode } from "@ai-hero/sandcastle";
85
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
81
86
 
82
87
  const result = await run({
83
88
  // Agent provider — required. Pass a model string to claudeCode().
84
89
  // Optional second arg for provider-specific options like effort level.
85
90
  agent: claudeCode("claude-opus-4-6", { effort: "high" }),
86
91
 
92
+ // Sandbox provider — required. Import from "@ai-hero/sandcastle/sandboxes/docker".
93
+ // Provider-specific config (like imageName and branchStrategy) lives inside the provider factory call.
94
+ sandbox: docker({
95
+ imageName: "sandcastle:local",
96
+ branchStrategy: { type: "branch", branch: "agent/fix-42" },
97
+ }),
98
+
87
99
  // Prompt source — provide one of these, not both:
88
100
  promptFile: ".sandcastle/prompt.md", // path to a prompt file
89
101
  // prompt: "Fix issue #42 in this repo", // OR an inline prompt string
@@ -96,25 +108,17 @@ const result = await run({
96
108
  // Maximum number of agent iterations to run before stopping. Default: 1
97
109
  maxIterations: 5,
98
110
 
99
- // Worktree mode for sandbox work. Defaults to { mode: 'temp-branch' }.
100
- // { mode: 'none' } — bind-mount host working directory directly (no worktree).
101
- // { mode: 'temp-branch' } — create a temp worktree, merge back.
102
- // { mode: 'branch', branch } — create a worktree on an explicit branch.
103
- worktree: { mode: "branch", branch: "agent/fix-42" },
104
-
105
- // Docker image used for the sandbox. Default: "sandcastle:<repo-dir-name>"
106
- imageName: "sandcastle:local",
107
-
108
111
  // Display name for this run, shown as a prefix in log output.
109
112
  name: "fix-issue-42",
110
113
 
111
114
  // Lifecycle hooks — arrays of shell commands run sequentially inside the sandbox.
112
115
  hooks: {
113
- // Runs after the worktree is mounted into the sandbox.
116
+ // Runs after the sandbox is ready.
114
117
  onSandboxReady: [{ command: "npm install" }],
115
118
  },
116
119
 
117
- // Host-relative file paths to copy into the worktree before the container starts.
120
+ // Host-relative file paths to copy into the sandbox before the container starts.
121
+ // Not supported with branchStrategy: { type: "head" }.
118
122
  copyToSandbox: [".env"],
119
123
 
120
124
  // How to record progress. Default: write to a file under .sandcastle/logs/
@@ -137,7 +141,7 @@ console.log(result.branch); // target branch name
137
141
 
138
142
  ### `createSandbox()` — reusable sandbox
139
143
 
140
- Use `createSandbox()` when you need to run multiple agents (or multiple rounds of the same agent) inside a single sandbox. It creates the worktree and container 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.
144
+ Use `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.
141
145
 
142
146
  Use `run()` instead when you only need a single one-shot invocation — it handles sandbox lifecycle automatically.
143
147
 
@@ -145,9 +149,11 @@ Use `run()` instead when you only need a single one-shot invocation — it handl
145
149
 
146
150
  ```typescript
147
151
  import { createSandbox, claudeCode } from "@ai-hero/sandcastle";
152
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
148
153
 
149
154
  await using sandbox = await createSandbox({
150
155
  branch: "agent/fix-42",
156
+ sandbox: docker(),
151
157
  });
152
158
 
153
159
  const result = await sandbox.run({
@@ -162,9 +168,11 @@ console.log(result.commits); // [{ sha: "abc123" }]
162
168
 
163
169
  ```typescript
164
170
  import { createSandbox, claudeCode } from "@ai-hero/sandcastle";
171
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
165
172
 
166
173
  await using sandbox = await createSandbox({
167
174
  branch: "agent/fix-42",
175
+ sandbox: docker(),
168
176
  hooks: { onSandboxReady: [{ command: "npm install" }] },
169
177
  });
170
178
 
@@ -186,12 +194,15 @@ Commits from all `run()` calls accumulate on the same branch. The sandbox contai
186
194
 
187
195
  #### Automatic cleanup with `await using`
188
196
 
189
- `await using` calls `sandbox.close()` automatically when the block exits. If the worktree has uncommitted changes, it is preserved on disk; if clean, both container and worktree are removed.
197
+ `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.
190
198
 
191
199
  #### Manual `close()` with `CloseResult`
192
200
 
193
201
  ```typescript
194
- const sandbox = await createSandbox({ branch: "agent/fix-42" });
202
+ const sandbox = await createSandbox({
203
+ branch: "agent/fix-42",
204
+ sandbox: docker(),
205
+ });
195
206
  // ... run agents ...
196
207
  const closeResult = await sandbox.close();
197
208
  if (closeResult.preservedWorktreePath) {
@@ -201,21 +212,21 @@ if (closeResult.preservedWorktreePath) {
201
212
 
202
213
  #### `CreateSandboxOptions`
203
214
 
204
- | Option | Type | Default | Description |
205
- | --------------- | -------- | ---------------------------- | ------------------------------------------------------------------- |
206
- | `branch` | string | — | **Required.** Explicit branch for the worktree |
207
- | `imageName` | string | `sandcastle:<repo-dir-name>` | Docker image name |
208
- | `hooks` | object | — | Lifecycle hooks (`onSandboxReady`) — run once at creation time |
209
- | `copyToSandbox` | string[] | — | Host-relative file paths to copy into the worktree at creation time |
215
+ | Option | Type | Default | Description |
216
+ | --------------- | --------------- | ------- | ------------------------------------------------------------------ |
217
+ | `branch` | string | — | **Required.** Explicit branch for the sandbox |
218
+ | `sandbox` | SandboxProvider | | **Required.** Sandbox provider (e.g. `docker()`) |
219
+ | `hooks` | object | — | Lifecycle hooks (`onSandboxReady`) — run once at creation time |
220
+ | `copyToSandbox` | string[] | — | Host-relative file paths to copy into the sandbox at creation time |
210
221
 
211
222
  #### `Sandbox`
212
223
 
213
224
  | Property / Method | Type | Description |
214
225
  | ----------------------- | -------------------------------------------------- | ------------------------------------------- |
215
- | `branch` | string | The branch the worktree is on |
226
+ | `branch` | string | The branch the sandbox is on |
216
227
  | `worktreePath` | string | Host path to the worktree |
217
228
  | `run(options)` | `(SandboxRunOptions) => Promise<SandboxRunResult>` | Invoke an agent inside the existing sandbox |
218
- | `close()` | `() => Promise<CloseResult>` | Tear down the container and worktree |
229
+ | `close()` | `() => Promise<CloseResult>` | Tear down the container and sandbox |
219
230
  | `[Symbol.asyncDispose]` | `() => Promise<void>` | Auto teardown via `await using` |
220
231
 
221
232
  #### `SandboxRunOptions`
@@ -250,14 +261,15 @@ if (closeResult.preservedWorktreePath) {
250
261
 
251
262
  ## How it works
252
263
 
253
- Sandcastle uses a worktree-based architecture for agent execution:
264
+ Sandcastle uses a **branch strategy** configured on the sandbox provider to control how the agent's changes relate to branches. There are three strategies:
265
+
266
+ - **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()`.
267
+ - **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.
268
+ - **Branch** (`{ type: "branch", branch: "foo" }`) — Commits land on an explicitly named branch in a git worktree.
254
269
 
255
- - **Worktree**: Sandcastle creates a git worktree on the host at `.sandcastle/worktrees/`. The worktree is a just a normal `git worktree`.
256
- - **Bind-mount**: The worktree directory is bind-mounted into the sandbox container as the agent's working directory. The agent writes directly to the host filesystem through the mount.
257
- - **No sync needed**: Because the agent writes directly to the host filesystem, there are no sync-in or sync-out operations. Commits made by the agent are immediately visible on the host.
258
- - **Merge back**: After the run completes, the temp worktree branch is fast-forward merged back to the target branch, and the worktree is cleaned up.
270
+ For 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.
259
271
 
260
- From your point of view, you just run `sandcastle.run({ worktree: { mode: 'branch', branch: 'foo' } })`, and get a commit on branch `foo` once it's complete. All 100% local.
272
+ From your point of view, you just configure `docker({ branchStrategy: { type: 'branch', branch: 'foo' } })`, and get a commit on branch `foo` once it's complete. All 100% local.
261
273
 
262
274
  ## Prompts
263
275
 
@@ -278,7 +290,7 @@ You must provide exactly one of:
278
290
 
279
291
  Use `` !`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.
280
292
 
281
- Commands run **inside the sandbox** after the worktree is mounted and `onSandboxReady` hooks complete, so they see the same repo state the agent sees (including installed dependencies).
293
+ Commands run **inside the sandbox** after `onSandboxReady` hooks complete, so they see the same repo state the agent sees (including installed dependencies).
282
294
 
283
295
  ```markdown
284
296
  # Open issues
@@ -323,10 +335,10 @@ A `{{KEY}}` placeholder with no matching prompt argument is an error. Unused pro
323
335
 
324
336
  Sandcastle automatically injects two built-in prompt arguments into every prompt:
325
337
 
326
- | Placeholder | Value |
327
- | ------------------- | -------------------------------------------------------------------- |
328
- | `{{SOURCE_BRANCH}}` | The branch the agent works on inside the worktree (temp or explicit) |
329
- | `{{TARGET_BRANCH}}` | The host's active branch at `run()` time |
338
+ | Placeholder | Value |
339
+ | ------------------- | ----------------------------------------------------------------- |
340
+ | `{{SOURCE_BRANCH}}` | The branch the agent works on (determined by the branch strategy) |
341
+ | `{{TARGET_BRANCH}}` | The host's active branch at `run()` time |
330
342
 
331
343
  Use them in your prompt without passing them via `promptArgs`:
332
344
 
@@ -392,12 +404,12 @@ Creates the following files:
392
404
  ├── Dockerfile # Sandbox environment (customize as needed)
393
405
  ├── prompt.md # Agent instructions
394
406
  ├── .env.example # Token placeholders
395
- └── .gitignore # Ignores .env, logs/, worktrees/
407
+ └── .gitignore # Ignores .env, logs/
396
408
  ```
397
409
 
398
410
  Errors if `.sandcastle/` already exists to prevent overwriting customizations.
399
411
 
400
- ### `sandcastle build-image`
412
+ ### `sandcastle docker build-image`
401
413
 
402
414
  Rebuilds the Docker image from an existing `.sandcastle/` directory. Use this after modifying the Dockerfile.
403
415
 
@@ -406,7 +418,7 @@ Rebuilds the Docker image from an existing `.sandcastle/` directory. Use this af
406
418
  | `--image-name` | No | `sandcastle:<repo-dir-name>` | Docker image name |
407
419
  | `--dockerfile` | No | — | Path to a custom Dockerfile (build context will be the current working directory) |
408
420
 
409
- ### `sandcastle remove-image`
421
+ ### `sandcastle docker remove-image`
410
422
 
411
423
  Removes the Docker image.
412
424
 
@@ -419,15 +431,14 @@ Removes the Docker image.
419
431
  | Option | Type | Default | Description |
420
432
  | -------------------- | ------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
421
433
  | `agent` | AgentProvider | — | **Required.** Agent provider (e.g. `claudeCode("claude-opus-4-6")`, `pi("claude-sonnet-4-6")`, `codex("gpt-5.4-mini")`) |
434
+ | `sandbox` | SandboxProvider | — | **Required.** Sandbox provider (e.g. `docker()`, `docker({ imageName: "sandcastle:local" })`) |
422
435
  | `prompt` | string | — | Inline prompt (mutually exclusive with `promptFile`) |
423
436
  | `promptFile` | string | — | Path to prompt file (mutually exclusive with `prompt`) |
424
437
  | `maxIterations` | number | `1` | Maximum iterations to run |
425
438
  | `hooks` | object | — | Lifecycle hooks (`onSandboxReady`) |
426
- | `worktree` | WorktreeMode | `{ mode: 'temp-branch' }` | Worktree mode: `{ mode: 'none' }`, `{ mode: 'temp-branch' }`, or `{ mode: 'branch', branch }` |
427
- | `imageName` | string | `sandcastle:<repo-dir-name>` | Docker image name for the sandbox |
428
439
  | `name` | string | — | Display name for the run, shown as a prefix in log output |
429
440
  | `promptArgs` | PromptArgs | — | Key-value map for `{{KEY}}` placeholder substitution |
430
- | `copyToSandbox` | string[] | — | Host-relative file paths to copy into the worktree before start (not supported with `mode: 'none'`) |
441
+ | `copyToSandbox` | string[] | — | Host-relative file paths to copy into the sandbox before start (not supported with `branchStrategy: { type: 'head' }`) |
431
442
  | `logging` | object | file (auto-generated) | `{ type: 'file', path }` or `{ type: 'stdout' }` |
432
443
  | `completionSignal` | string \| string[] | `<promise>COMPLETE</promise>` | String or array of strings the agent emits to stop the iteration loop early |
433
444
  | `idleTimeoutSeconds` | number | `600` | Idle timeout in seconds — resets on each agent output event |
@@ -457,6 +468,275 @@ agent: claudeCode("claude-opus-4-6", { effort: "high" });
457
468
 
458
469
  Environment variables are 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).
459
470
 
471
+ ## Custom Sandbox Providers
472
+
473
+ Sandcastle ships with a Docker provider, but you can create your own. A sandbox provider tells Sandcastle how to execute commands in an isolated environment. There are two kinds:
474
+
475
+ - **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.
476
+ - **Isolated** — the sandbox has its own filesystem (e.g. a cloud VM). The provider handles syncing code in and out via `copyIn` and `copyOut`. Use this when the sandbox cannot access the host filesystem.
477
+
478
+ ### The sandbox handle contract
479
+
480
+ Both provider types return a **sandbox handle** from their `create()` function. The handle exposes:
481
+
482
+ | Method | Required | Description |
483
+ | --------------- | -------- | ------------------------------------------------- |
484
+ | `exec` | Both | Run a command, return `ExecResult` when done |
485
+ | `execStreaming` | Both | Run a command, call `onLine` for each stdout line |
486
+ | `close` | Both | Tear down the sandbox |
487
+ | `copyIn` | Isolated | Copy a file from the host into the sandbox |
488
+ | `copyOut` | Isolated | Copy a file from the sandbox to the host |
489
+ | `workspacePath` | Both | Absolute path to the workspace inside the sandbox |
490
+
491
+ ### `ExecResult`
492
+
493
+ Every `exec` and `execStreaming` call returns an `ExecResult`:
494
+
495
+ ```typescript
496
+ interface ExecResult {
497
+ readonly stdout: string;
498
+ readonly stderr: string;
499
+ readonly exitCode: number;
500
+ }
501
+ ```
502
+
503
+ ### Bind-mount provider example
504
+
505
+ A minimal bind-mount provider that shells out to local processes (no container):
506
+
507
+ ```typescript
508
+ import {
509
+ createBindMountSandboxProvider,
510
+ type BindMountCreateOptions,
511
+ type BindMountSandboxHandle,
512
+ type ExecResult,
513
+ } from "@ai-hero/sandcastle";
514
+ import { execFile, spawn } from "node:child_process";
515
+ import { createInterface } from "node:readline";
516
+
517
+ const localProcess = () =>
518
+ createBindMountSandboxProvider({
519
+ name: "local-process",
520
+ branchStrategy: { type: "merge-to-head" },
521
+ create: async (
522
+ options: BindMountCreateOptions,
523
+ ): Promise<BindMountSandboxHandle> => {
524
+ const workspacePath = options.worktreePath;
525
+
526
+ return {
527
+ workspacePath,
528
+
529
+ exec: (command: string, opts?: { cwd?: string }): Promise<ExecResult> =>
530
+ new Promise((resolve, reject) => {
531
+ execFile(
532
+ "sh",
533
+ ["-c", command],
534
+ { cwd: opts?.cwd ?? workspacePath, maxBuffer: 10 * 1024 * 1024 },
535
+ (error, stdout, stderr) => {
536
+ if (error && error.code === undefined) {
537
+ reject(new Error(`exec failed: ${error.message}`));
538
+ } else {
539
+ resolve({
540
+ stdout: stdout.toString(),
541
+ stderr: stderr.toString(),
542
+ exitCode: typeof error?.code === "number" ? error.code : 0,
543
+ });
544
+ }
545
+ },
546
+ );
547
+ }),
548
+
549
+ execStreaming: (
550
+ command: string,
551
+ onLine: (line: string) => void,
552
+ opts?: { cwd?: string },
553
+ ): Promise<ExecResult> =>
554
+ new Promise((resolve, reject) => {
555
+ const proc = spawn("sh", ["-c", command], {
556
+ cwd: opts?.cwd ?? workspacePath,
557
+ stdio: ["ignore", "pipe", "pipe"],
558
+ });
559
+
560
+ const stdoutChunks: string[] = [];
561
+ const stderrChunks: string[] = [];
562
+
563
+ const rl = createInterface({ input: proc.stdout! });
564
+ rl.on("line", (line) => {
565
+ stdoutChunks.push(line);
566
+ onLine(line); // forward each line to Sandcastle
567
+ });
568
+
569
+ proc.stderr!.on("data", (chunk: Buffer) => {
570
+ stderrChunks.push(chunk.toString());
571
+ });
572
+
573
+ proc.on("error", (err) => reject(err));
574
+ proc.on("close", (code) => {
575
+ resolve({
576
+ stdout: stdoutChunks.join("\n"),
577
+ stderr: stderrChunks.join(""),
578
+ exitCode: code ?? 0,
579
+ });
580
+ });
581
+ }),
582
+
583
+ close: async () => {
584
+ // nothing to tear down for a local process
585
+ },
586
+ };
587
+ },
588
+ });
589
+ ```
590
+
591
+ ### Isolated provider example
592
+
593
+ A minimal isolated provider using a temp directory:
594
+
595
+ ```typescript
596
+ import {
597
+ createIsolatedSandboxProvider,
598
+ type IsolatedSandboxHandle,
599
+ type ExecResult,
600
+ } from "@ai-hero/sandcastle";
601
+ import { execFile, spawn } from "node:child_process";
602
+ import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
603
+ import { tmpdir } from "node:os";
604
+ import { dirname, join } from "node:path";
605
+ import { createInterface } from "node:readline";
606
+
607
+ const tempDir = () =>
608
+ createIsolatedSandboxProvider({
609
+ name: "temp-dir",
610
+ branchStrategy: { type: "merge-to-head" },
611
+ create: async (): Promise<IsolatedSandboxHandle> => {
612
+ const root = await mkdtemp(join(tmpdir(), "sandbox-"));
613
+ const workspacePath = join(root, "workspace");
614
+ await mkdir(workspacePath, { recursive: true });
615
+
616
+ return {
617
+ workspacePath,
618
+
619
+ exec: (command: string, opts?: { cwd?: string }): Promise<ExecResult> =>
620
+ new Promise((resolve, reject) => {
621
+ execFile(
622
+ "sh",
623
+ ["-c", command],
624
+ { cwd: opts?.cwd ?? workspacePath, maxBuffer: 10 * 1024 * 1024 },
625
+ (error, stdout, stderr) => {
626
+ if (error && error.code === undefined) {
627
+ reject(new Error(`exec failed: ${error.message}`));
628
+ } else {
629
+ resolve({
630
+ stdout: stdout.toString(),
631
+ stderr: stderr.toString(),
632
+ exitCode: typeof error?.code === "number" ? error.code : 0,
633
+ });
634
+ }
635
+ },
636
+ );
637
+ }),
638
+
639
+ execStreaming: (
640
+ command: string,
641
+ onLine: (line: string) => void,
642
+ opts?: { cwd?: string },
643
+ ): Promise<ExecResult> =>
644
+ new Promise((resolve, reject) => {
645
+ const proc = spawn("sh", ["-c", command], {
646
+ cwd: opts?.cwd ?? workspacePath,
647
+ stdio: ["ignore", "pipe", "pipe"],
648
+ });
649
+
650
+ const stdoutChunks: string[] = [];
651
+ const stderrChunks: string[] = [];
652
+
653
+ const rl = createInterface({ input: proc.stdout! });
654
+ rl.on("line", (line) => {
655
+ stdoutChunks.push(line);
656
+ onLine(line);
657
+ });
658
+
659
+ proc.stderr!.on("data", (chunk: Buffer) => {
660
+ stderrChunks.push(chunk.toString());
661
+ });
662
+
663
+ proc.on("error", (err) => reject(err));
664
+ proc.on("close", (code) => {
665
+ resolve({
666
+ stdout: stdoutChunks.join("\n"),
667
+ stderr: stderrChunks.join(""),
668
+ exitCode: code ?? 0,
669
+ });
670
+ });
671
+ }),
672
+
673
+ copyIn: async (hostPath: string, sandboxPath: string) => {
674
+ await mkdir(dirname(sandboxPath), { recursive: true });
675
+ await copyFile(hostPath, sandboxPath);
676
+ },
677
+
678
+ copyOut: async (sandboxPath: string, hostPath: string) => {
679
+ await mkdir(dirname(hostPath), { recursive: true });
680
+ await copyFile(sandboxPath, hostPath);
681
+ },
682
+
683
+ close: async () => {
684
+ await rm(root, { recursive: true, force: true });
685
+ },
686
+ };
687
+ },
688
+ });
689
+ ```
690
+
691
+ ### Branch strategies
692
+
693
+ A branch strategy controls where the agent's commits land. Configure it when constructing the provider:
694
+
695
+ | Strategy | Behavior | Bind-mount | Isolated |
696
+ | --------------- | ------------------------------------------------------------------------ | ---------- | --------- |
697
+ | `head` | Agent writes directly to the host working directory. No worktree created | Default | N/A |
698
+ | `merge-to-head` | Sandcastle creates a temp branch, merges back to HEAD when done | Supported | Default |
699
+ | `branch` | Commits land on an explicit named branch you provide | Supported | Supported |
700
+
701
+ **When to use each:**
702
+
703
+ - **`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.
704
+ - **`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.
705
+ - **`branch`** — when you want commits on a specific branch (e.g. for a PR). Pass `{ type: "branch", branch: "agent/fix-42" }`.
706
+
707
+ ```typescript
708
+ // head — direct write, bind-mount only
709
+ const provider = localProcess();
710
+ // merge-to-head — temp branch, merge back (default for isolated)
711
+ const provider = tempDir();
712
+ // branch — explicit named branch
713
+ import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
714
+ const provider = docker({
715
+ branchStrategy: { type: "branch", branch: "agent/fix-42" },
716
+ });
717
+ ```
718
+
719
+ ### Passing to `run()`
720
+
721
+ Pass your custom provider via the `sandbox` option — it works the same as the built-in `docker()` provider:
722
+
723
+ ```typescript
724
+ import { run, claudeCode } from "@ai-hero/sandcastle";
725
+
726
+ const result = await run({
727
+ agent: claudeCode("claude-opus-4-6"),
728
+ sandbox: localProcess(), // your custom provider
729
+ prompt: "Fix issue #42 in this repo.",
730
+ });
731
+ ```
732
+
733
+ ### Reference implementations
734
+
735
+ For real-world examples, see:
736
+
737
+ - [`src/sandboxes/docker.ts`](src/sandboxes/docker.ts) — bind-mount provider using Docker containers
738
+ - [`src/sandboxes/test-isolated.ts`](src/sandboxes/test-isolated.ts) — isolated provider using temp directories (used in tests)
739
+
460
740
  ## Configuration
461
741
 
462
742
  ### Config directory (`.sandcastle/`)
@@ -490,7 +770,7 @@ Hooks are arrays of `{ "command": "..." }` objects executed sequentially inside
490
770
  | ---------------- | -------------------------- | ---------------------- |
491
771
  | `onSandboxReady` | After the sandbox is ready | Sandbox repo directory |
492
772
 
493
- **`onSandboxReady`** runs after the worktree is mounted into the sandbox. Use it for dependency installation or build steps (e.g., `npm install`).
773
+ **`onSandboxReady`** runs after the sandbox is ready. Use it for dependency installation or build steps (e.g., `npm install`).
494
774
 
495
775
  Pass hooks programmatically via `run()`:
496
776
 
@@ -1,19 +1,9 @@
1
- export interface TokenUsage {
2
- readonly input_tokens: number;
3
- readonly output_tokens: number;
4
- readonly cache_read_input_tokens: number;
5
- readonly cache_creation_input_tokens: number;
6
- readonly total_cost_usd: number;
7
- readonly num_turns: number;
8
- readonly duration_ms: number;
9
- }
10
1
  export type ParsedStreamEvent = {
11
2
  type: "text";
12
3
  text: string;
13
4
  } | {
14
5
  type: "result";
15
6
  result: string;
16
- usage: TokenUsage | null;
17
7
  } | {
18
8
  type: "tool_call";
19
9
  name: string;
@@ -1 +1 @@
1
- {"version":3,"file":"AgentProvider.d.ts","sourceRoot":"","sources":["../src/AgentProvider.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,uBAAuB,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,2BAA2B,EAAE,MAAM,CAAC;IAC7C,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAA;CAAE,GAC5D;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAwFtD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1C,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC/C,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAAC;CACpD;AAED,eAAO,MAAM,aAAa,oBAAoB,CAAC;AAsD/C,eAAO,MAAM,EAAE,kCAcb,CAAC;AAwCH,eAAO,MAAM,KAAK,kCAchB,CAAC;AAMH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC;CACrD;AAED,eAAO,MAAM,UAAU,2EAoBrB,CAAC"}
1
+ {"version":3,"file":"AgentProvider.d.ts","sourceRoot":"","sources":["../src/AgentProvider.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AA6DtD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1C,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC/C,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,EAAE,CAAC;CACpD;AAED,eAAO,MAAM,aAAa,oBAAoB,CAAC;AA2D/C,eAAO,MAAM,EAAE,kCAcb,CAAC;AAwCH,eAAO,MAAM,KAAK,kCAchB,CAAC;AAMH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC;CACrD;AAED,eAAO,MAAM,UAAU,2EAoBrB,CAAC"}