@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.
- package/README.md +322 -42
- package/dist/AgentProvider.d.ts +0 -10
- package/dist/AgentProvider.d.ts.map +1 -1
- package/dist/AgentProvider.js +28 -45
- package/dist/AgentProvider.js.map +1 -1
- package/dist/DockerLifecycle.d.ts +5 -1
- package/dist/DockerLifecycle.d.ts.map +1 -1
- package/dist/DockerLifecycle.js +8 -1
- package/dist/DockerLifecycle.js.map +1 -1
- package/dist/InitService.d.ts.map +1 -1
- package/dist/InitService.js +2 -0
- package/dist/InitService.js.map +1 -1
- package/dist/Orchestrator.d.ts +0 -1
- package/dist/Orchestrator.d.ts.map +1 -1
- package/dist/Orchestrator.js +2 -13
- package/dist/Orchestrator.js.map +1 -1
- package/dist/SandboxFactory.d.ts +22 -11
- package/dist/SandboxFactory.d.ts.map +1 -1
- package/dist/SandboxFactory.js +166 -209
- package/dist/SandboxFactory.js.map +1 -1
- package/dist/SandboxProvider.d.ts +141 -0
- package/dist/SandboxProvider.d.ts.map +1 -0
- package/dist/SandboxProvider.js +27 -0
- package/dist/SandboxProvider.js.map +1 -0
- package/dist/cli.d.ts +7 -5
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -11
- package/dist/cli.js.map +1 -1
- package/dist/createSandbox.d.ts +3 -2
- package/dist/createSandbox.d.ts.map +1 -1
- package/dist/createSandbox.js +19 -32
- package/dist/createSandbox.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/run.d.ts +4 -25
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +21 -35
- package/dist/run.js.map +1 -1
- package/dist/sandboxExec.d.ts +12 -0
- package/dist/sandboxExec.d.ts.map +1 -0
- package/dist/sandboxExec.js +26 -0
- package/dist/sandboxExec.js.map +1 -0
- package/dist/sandboxes/docker.d.ts +28 -0
- package/dist/sandboxes/docker.d.ts.map +1 -0
- package/dist/sandboxes/docker.js +134 -0
- package/dist/sandboxes/docker.js.map +1 -0
- package/dist/sandboxes/test-isolated.d.ts +21 -0
- package/dist/sandboxes/test-isolated.d.ts.map +1 -0
- package/dist/sandboxes/test-isolated.js +87 -0
- package/dist/sandboxes/test-isolated.js.map +1 -0
- package/dist/syncIn.d.ts +24 -0
- package/dist/syncIn.d.ts.map +1 -0
- package/dist/syncIn.js +107 -0
- package/dist/syncIn.js.map +1 -0
- package/dist/syncOut.d.ts +27 -0
- package/dist/syncOut.d.ts.map +1 -0
- package/dist/syncOut.js +271 -0
- package/dist/syncOut.js.map +1 -0
- package/dist/templates/blank/.env.example +1 -0
- package/dist/templates/blank/main.mts +2 -0
- package/dist/templates/parallel-planner/.env.example +1 -0
- package/dist/templates/parallel-planner/main.mts +5 -2
- package/dist/templates/sequential-reviewer/.env.example +1 -0
- package/dist/templates/sequential-reviewer/main.mts +3 -1
- package/dist/templates/simple-loop/.env.example +1 -0
- package/dist/templates/simple-loop/main.mts +4 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
205
|
-
| --------------- |
|
|
206
|
-
| `branch` | string
|
|
207
|
-
| `
|
|
208
|
-
| `hooks` | object
|
|
209
|
-
| `copyToSandbox` | string[]
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/dist/AgentProvider.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|