@dynamicagents/plugins 0.16.1 → 0.17.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 CHANGED
@@ -68,7 +68,7 @@ plugin to the tools an install names.
68
68
  | ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
69
69
  | [`/browser`](src/browser/) | Read web pages via Browser Rendering Quick Actions | `BROWSER` (paid plan) |
70
70
  | [`/claude-code`](src/claude-code/) | Sub-agents whose model is a Claude Code session in a sandbox | one or more `claude setup-token` credentials |
71
- | [`/computer`](src/computer/) | An agent's tools over a sandbox — `bash`, `grep`, `edit` — and the workspace Think's file tools use | a `/sandbox` object |
71
+ | [`/computer`](src/computer/) | An agent's tools over a sandbox — `exec`, `grep`, `edit` — and the workspace Think's file tools use | a `/sandbox` object |
72
72
  | [`/repo-github`](src/repo-github/) | Clone, commit, push a branch, open a pull request on GitHub — over any container | `GITHUB_TOKEN` |
73
73
  | [`/sandbox`](src/sandbox/) | A container whose disk is the checkout, driven from a Durable Object through `agents`' `ContainerHarness` | `@cloudflare/sandbox`, an image running its daemon |
74
74
  | [`/scratch`](src/scratch/) | A throwaway git repository with no remote, for work that needs a container but no checkout | — |
@@ -48,10 +48,15 @@ export declare function toolResultCard(event: ToolResultEvent): Card;
48
48
  export declare function thinkingCard(durationMs: number | undefined, messageId: string | undefined): Card;
49
49
  /** A session starting — every exec, a resume or a follow-up included. */
50
50
  export declare function initCard(event: InitEvent): Card;
51
- /** A session's end, with what its `result` line says about it. */
51
+ /**
52
+ * A session's end, with what its `result` line says about it. A session the API
53
+ * failed reports `success` as its subtype, so the card names the API instead.
54
+ */
52
55
  export declare function resultCard(result: ClaudeCodeResult): Card;
53
56
  /** A session its run gave up on before reading its end, and why. */
54
57
  export declare function lostCard(why: string): Card;
55
58
  /** A session's container lost mid-turn, and the session resuming in a new one. */
56
59
  export declare function containerLostCard(reason: string): Card;
60
+ /** A session the API failed, resuming after `waitMs`. */
61
+ export declare function apiErrorCard(error: string, waitMs: number): Card;
57
62
  export {};
@@ -241,11 +241,19 @@ export function initCard(event) {
241
241
  ].filter(Boolean);
242
242
  return { text: parts.join(" · "), detail: { title: "Session" } };
243
243
  }
244
- /** A session's end, with what its `result` line says about it. */
244
+ /**
245
+ * A session's end, with what its `result` line says about it. A session the API
246
+ * failed reports `success` as its subtype, so the card names the API instead.
247
+ */
245
248
  export function resultCard(result) {
246
249
  const failed = result.isError || result.subtype !== "success";
250
+ const why = result.subtype !== "success"
251
+ ? result.subtype
252
+ : result.apiErrorStatus === null
253
+ ? "API error"
254
+ : `API ${result.apiErrorStatus}`;
247
255
  const parts = [
248
- failed ? `Session failed (${result.subtype})` : "Session finished",
256
+ failed ? `Session failed (${why})` : "Session finished",
249
257
  result.numTurns
250
258
  ? `${result.numTurns} turn${result.numTurns === 1 ? "" : "s"}`
251
259
  : undefined,
@@ -283,6 +291,17 @@ export function containerLostCard(reason) {
283
291
  }
284
292
  };
285
293
  }
294
+ /** A session the API failed, resuming after `waitMs`. */
295
+ export function apiErrorCard(error, waitMs) {
296
+ return {
297
+ text: `Model call failed — the session resumes in ${duration(waitMs)}`,
298
+ detail: {
299
+ title: "Session",
300
+ status: "error",
301
+ sections: [section("Why", error, "text")]
302
+ }
303
+ };
304
+ }
286
305
  function duration(ms) {
287
306
  const s = Math.round(ms / 1000);
288
307
  if (s < 60)
@@ -64,6 +64,8 @@ export interface SessionOutcome {
64
64
  * `container_lost`, and this submits the next attempt to the same harness
65
65
  * session, whose CLI conversation the harness kept: the session is told what
66
66
  * happened, not briefed again.
67
+ * - **So does an API error the CLI gave up on** — see {@link transientApiError}
68
+ * — after a backoff.
67
69
  */
68
70
  /** The sandbox RPCs a run uses. */
69
71
  export interface SandboxRuns {
@@ -156,4 +158,6 @@ export interface ClaudeCodeModelOptions {
156
158
  export declare const DEFAULT_TIMEOUT_MS: number;
157
159
  /** What a session is told when it resumes in a new container. */
158
160
  export declare const CONTAINER_LOST_PROMPT: string;
161
+ /** What a session is told when it resumes after an API error. */
162
+ export declare const API_ERROR_PROMPT: string;
159
163
  export declare function claudeCodeModel(options: ClaudeCodeModelOptions): LanguageModelV3;
@@ -1,7 +1,7 @@
1
1
  import { isPlatformTransientError } from "agents";
2
2
  import { SANDBOX_CONTRACT } from "../sandbox/constants.js";
3
3
  import { withRunHeader } from "../sandbox/header.js";
4
- import { containerLostCard, lostCard } from "./cards.js";
4
+ import { apiErrorCard, containerLostCard, lostCard } from "./cards.js";
5
5
  import { decodeNote, decodeRunEnd, decodeSessionId } from "./harness-wire.js";
6
6
  import { lastUserText, reportingModel } from "./language-model.js";
7
7
  /**
@@ -22,6 +22,17 @@ export const CONTAINER_LOST_PROMPT = "The container this session was running in
22
22
  const LOST_RESUBMITS = 3;
23
23
  /** Why an operation settled that a new attempt may get past. */
24
24
  const LOST_REASONS = new Set(["container_lost", "container_unavailable"]);
25
+ /** What a session is told when it resumes after an API error. */
26
+ export const API_ERROR_PROMPT = "Your last response was cut off by an API error, and the session has " +
27
+ "resumed. What you completed before it is kept, but a tool call it " +
28
+ "interrupted never ran. Carry on with the task from your last completed step.";
29
+ /**
30
+ * Resumes after an API error. One that recurs on every attempt would otherwise
31
+ * be resumed, a full-context call each time, until the session's limit.
32
+ */
33
+ const API_ERROR_RESUMES = 5;
34
+ /** The first resume's wait, doubled for each after it. Every wait follows the CLI's own retries. */
35
+ const API_ERROR_BACKOFF_MS = 15_000;
25
36
  /** Polls in a row a note may fail to file before it is given up on. */
26
37
  const NOTE_TRIES = 3;
27
38
  /** The sandbox's own ceiling on one poll's wait. */
@@ -122,6 +133,12 @@ export function claudeCodeModel(options) {
122
133
  await file(`${operationId}:lost`, card.text, card.detail);
123
134
  throw reason;
124
135
  };
136
+ /** Stop the session, unless it is still within its limit. */
137
+ const withinLimit = async (operationId, startedAt) => {
138
+ if (Date.now() - startedAt <= timeoutMs)
139
+ return;
140
+ await stop(operationId, new Error(`the session ran past its limit of ${Math.round(timeoutMs / 60_000)} minutes`));
141
+ };
125
142
  /**
126
143
  * One step of the run, to its end: the brief's session, or the follow-up.
127
144
  * Both count against one limit, from the session's first submission.
@@ -161,11 +178,21 @@ export function claudeCodeModel(options) {
161
178
  }
162
179
  for (;;) {
163
180
  const operationId = `${runId}:${step}:a${state.attempt}`;
181
+ if (state.retryAt !== undefined && state.retryAt > Date.now()) {
182
+ await untilAborted(sleep(state.retryAt - Date.now()), signal).catch(() => undefined);
183
+ if (signal?.aborted)
184
+ await stop(operationId, signal.reason ?? new Error("the session was stopped"));
185
+ }
186
+ // Before the submission, which starts a model call: a turn recovered
187
+ // past its limit would otherwise start one only for the poll to stop it.
188
+ await withinLimit(operationId, state.startedAt);
164
189
  // A resubmission that reached the CLI is told what happened, in the
165
190
  // conversation it already had; one that never did is the step again.
166
191
  const { fork: _fork, ...resumed } = header;
167
192
  const text = state.attempt > 0 && state.sessionId
168
- ? withRunHeader(resumed, CONTAINER_LOST_PROMPT)
193
+ ? withRunHeader(resumed, state.resumeWith === "api-error"
194
+ ? API_ERROR_PROMPT
195
+ : CONTAINER_LOST_PROMPT)
169
196
  : withRunHeader(header, prompt);
170
197
  try {
171
198
  await rpc((sandbox) => sandbox.submit(runId, text, operationId));
@@ -182,9 +209,7 @@ export function claudeCodeModel(options) {
182
209
  while (!result) {
183
210
  if (signal?.aborted)
184
211
  await stop(operationId, signal.reason ?? new Error("the session was stopped"));
185
- if (Date.now() - state.startedAt > timeoutMs) {
186
- await stop(operationId, new Error(`the session ran past its limit of ${Math.round(timeoutMs / 60_000)} minutes`));
187
- }
212
+ await withinLimit(operationId, state.startedAt);
188
213
  let polled;
189
214
  try {
190
215
  polled = await rpc((sandbox) => sandbox.poll(runId, operationId, POLL_WAIT_MS, state.from));
@@ -232,6 +257,26 @@ export function claudeCodeModel(options) {
232
257
  if (!end?.result) {
233
258
  throw new Error(`claude-code: the sandbox settled the session with no end it could read: ${String(result.text).slice(0, 200)}`);
234
259
  }
260
+ const apiErrors = state.apiErrors ?? 0;
261
+ const waitMs = API_ERROR_BACKOFF_MS * 2 ** apiErrors;
262
+ const retryAt = Date.now() + waitMs;
263
+ if (transientApiError(end.result) &&
264
+ apiErrors < API_ERROR_RESUMES &&
265
+ retryAt - state.startedAt < timeoutMs &&
266
+ !signal?.aborted) {
267
+ console.warn("[claude-code] the session ended on an API error — resuming", { runId, operationId, error: end.result.text, waitMs });
268
+ const card = apiErrorCard(end.result.text, waitMs);
269
+ await file(`${operationId}:api-error`, card.text, card.detail);
270
+ await save({
271
+ ...state,
272
+ attempt: state.attempt + 1,
273
+ from: 0,
274
+ apiErrors: apiErrors + 1,
275
+ resumeWith: "api-error",
276
+ retryAt
277
+ });
278
+ continue;
279
+ }
235
280
  // No code is a process a signal ended.
236
281
  return sessionEnd(end, end.exitCode ?? -1, state.sessionId);
237
282
  }
@@ -241,7 +286,8 @@ export function claudeCodeModel(options) {
241
286
  return sessionEnd(end, end.exitCode ?? 1, state.sessionId);
242
287
  if (signal?.aborted)
243
288
  await stop(operationId, signal.reason ?? new Error("the session was stopped"));
244
- if (!LOST_REASONS.has(reason) || state.attempt >= LOST_RESUBMITS) {
289
+ const lost = state.lost ?? 0;
290
+ if (!LOST_REASONS.has(reason) || lost >= LOST_RESUBMITS) {
245
291
  const card = lostCard(reason || "the sandbox gave no reason");
246
292
  await file(`${operationId}:lost`, card.text, card.detail);
247
293
  throw new Error(`claude-code: the sandbox ended the session: ${reason || "no reason given"}`);
@@ -253,7 +299,13 @@ export function claudeCodeModel(options) {
253
299
  });
254
300
  const card = containerLostCard(reason);
255
301
  await file(`${operationId}:container-lost`, card.text, card.detail);
256
- await save({ ...state, attempt: state.attempt + 1, from: 0 });
302
+ await save({
303
+ ...state,
304
+ attempt: state.attempt + 1,
305
+ from: 0,
306
+ lost: lost + 1,
307
+ resumeWith: "container-lost"
308
+ });
257
309
  }
258
310
  };
259
311
  /** File one polled message's note, or read the session id it carries. */
@@ -335,6 +387,21 @@ export function claudeCodeModel(options) {
335
387
  };
336
388
  return reportingModel(options.model ?? "claude-code", run);
337
389
  }
390
+ /**
391
+ * Whether a step's end is an API error another attempt may get past.
392
+ *
393
+ * The CLI retries a failed call itself, but never one cut off after a block of
394
+ * the response was complete, since a resend could run a tool call twice; its
395
+ * documented recovery is to resume the session. No status is the transport — a
396
+ * dropped or stalled stream, a body that would not decompress — and a 5xx or
397
+ * 529 the API's own. A 4xx is the request's, 429 included: the egress has
398
+ * already moved the credential pool on (`./egress.ts`).
399
+ */
400
+ function transientApiError(result) {
401
+ if (!result.isError || !result.text.startsWith("API Error"))
402
+ return false;
403
+ return result.apiErrorStatus === null || result.apiErrorStatus >= 500;
404
+ }
338
405
  function sessionEnd(end, exitCode, seen) {
339
406
  const sessionId = end.sessionId ?? seen;
340
407
  return {
@@ -12,4 +12,4 @@
12
12
  * and the capture move together. AGENTS.md's "Updating Claude Code" is the
13
13
  * procedure.
14
14
  */
15
- export declare const VERIFIED_CLAUDE_CODE_VERSION = "2.1.296";
15
+ export declare const VERIFIED_CLAUDE_CODE_VERSION = "2.1.310";
@@ -12,4 +12,4 @@
12
12
  * and the capture move together. AGENTS.md's "Updating Claude Code" is the
13
13
  * procedure.
14
14
  */
15
- export const VERIFIED_CLAUDE_CODE_VERSION = "2.1.296";
15
+ export const VERIFIED_CLAUDE_CODE_VERSION = "2.1.310";
@@ -27,8 +27,11 @@ export interface FoundEntry {
27
27
  type: "file" | "dir";
28
28
  }
29
29
  export interface StatResult {
30
+ name: string;
30
31
  size: number;
31
32
  mtime: number;
33
+ /** The file's Linux mode, as the container reports it. */
34
+ mode: number;
32
35
  isFile: boolean;
33
36
  isDirectory: boolean;
34
37
  isSymbolicLink: boolean;
@@ -39,6 +42,7 @@ export interface DirEntry {
39
42
  mtime?: number;
40
43
  isFile: boolean;
41
44
  isDirectory: boolean;
45
+ isSymbolicLink?: boolean;
42
46
  }
43
47
  export interface ComputerFs {
44
48
  readFile(path: string): Promise<ReadableStream<Uint8Array>>;
@@ -54,7 +58,11 @@ export interface ComputerFs {
54
58
  exclude?: string[];
55
59
  }): Promise<FoundEntry[]>;
56
60
  grep(pattern: string, path: string, options?: GrepOptions): Promise<GrepMatch[]>;
57
- writeFile(path: string, content: string): Promise<void>;
61
+ /**
62
+ * Bytes as well as text: Think writes evicted media through this, and the
63
+ * `read` tool hands the model back an image it can look at again.
64
+ */
65
+ writeFile(path: string, content: string | Uint8Array): Promise<void>;
58
66
  mkdir(path: string, options?: {
59
67
  recursive?: boolean;
60
68
  }): Promise<void>;
@@ -180,7 +180,7 @@ export function computerContext(workspace, config, advisories, lockScope) {
180
180
  * only recover from what it is told, so this returns the sentence rather than
181
181
  * throwing it.
182
182
  *
183
- * `bash` deliberately does not use it: it logs, and carries an install
183
+ * `exec` deliberately does not use it: it logs, and carries an install
184
184
  * warning through the failure path — a bespoke catch saying something this
185
185
  * cannot.
186
186
  */
@@ -4,7 +4,7 @@ import type { SandboxAdvisory } from "../sandbox/advisory.js";
4
4
  import type { ComputerClient } from "./client.js";
5
5
  /**
6
6
  * `@dynamicagents/plugins/computer` — an agent's tools over a `/sandbox`
7
- * container: `bash`, `grep` and `edit`, and `computerWorkspace`, the agent's own
7
+ * container: `exec`, `grep` and `edit`, and `computerWorkspace`, the agent's own
8
8
  * `this.workspace`, which Think's `read`, `write`, `delete`, `find` and `list`
9
9
  * go through.
10
10
  *
@@ -14,7 +14,7 @@ import type { ComputerClient } from "./client.js";
14
14
  * backend.
15
15
  *
16
16
  * The agent's own workspace has to be the sandbox's — `computerWorkspace` — or
17
- * Think's `read` and `write` would work on a different tree from `bash`. The
17
+ * Think's `read` and `write` would work on a different tree from `exec`. The
18
18
  * plugin refuses to start otherwise; see `./proxy.ts`. `paths.ts` owns what a
19
19
  * path may be and what walks drop.
20
20
  */
@@ -24,7 +24,7 @@ import type { ComputerClient } from "./client.js";
24
24
  * exports must not move when the files behind it do.
25
25
  */
26
26
  export { isGitInternal, WALK_SKIPS } from "./paths.js";
27
- export { packBlocks, renderGrepMatches, renderResult } from "./render.js";
27
+ export { packBlocks, renderGrepMatches } from "./render.js";
28
28
  export { execGate, needsDependencies, writeGate, type ExecGate } from "./gate.js";
29
29
  export { sandboxClient, computerWorkspace, type SandboxComputerConfig, type SandboxComputerHost } from "./sandbox.js";
30
30
  export type { ComputerClient } from "./client.js";
@@ -60,10 +60,14 @@ export interface ComputerToolsConfig {
60
60
  * characters far better than they track UTF-8 bytes. So the name moved to meet
61
61
  * the implementation rather than the other way round.
62
62
  *
63
+ * `exec` is the exception, and spends this as **bytes**: the ceiling there is
64
+ * Computer's own tool's, which takes no character form. The two coincide
65
+ * until the output is not ASCII, where the model is shown a little less than
66
+ * this asked for.
63
67
  */
64
68
  maxOutputChars?: number;
65
69
  /**
66
- * How long `bash` waits for a running dependency install before giving the
70
+ * How long `exec` waits for a running dependency install before giving the
67
71
  * turn back. Defaults to 90 seconds.
68
72
  *
69
73
  * Bounded rather than open-ended because the wait happens inside a turn, and
@@ -78,8 +82,8 @@ export interface ComputerToolsConfig {
78
82
  *
79
83
  * ## Put no secret here
80
84
  *
81
- * *Every* command gets this environment, and `bash`'s command is written by
82
- * the model: `bash("printenv")` prints all of it, and so does a
85
+ * *Every* command gets this environment, and `exec`'s command is written by
86
+ * the model: `exec("printenv")` prints all of it, and so does a
83
87
  * `postinstall` script in a repository the agent was asked to clone, which
84
88
  * nobody vetted.
85
89
  *
@@ -6,7 +6,7 @@ import { fileTools } from "./tools-file.js";
6
6
  import { grepTools } from "./tools-grep.js";
7
7
  /**
8
8
  * `@dynamicagents/plugins/computer` — an agent's tools over a `/sandbox`
9
- * container: `bash`, `grep` and `edit`, and `computerWorkspace`, the agent's own
9
+ * container: `exec`, `grep` and `edit`, and `computerWorkspace`, the agent's own
10
10
  * `this.workspace`, which Think's `read`, `write`, `delete`, `find` and `list`
11
11
  * go through.
12
12
  *
@@ -16,7 +16,7 @@ import { grepTools } from "./tools-grep.js";
16
16
  * backend.
17
17
  *
18
18
  * The agent's own workspace has to be the sandbox's — `computerWorkspace` — or
19
- * Think's `read` and `write` would work on a different tree from `bash`. The
19
+ * Think's `read` and `write` would work on a different tree from `exec`. The
20
20
  * plugin refuses to start otherwise; see `./proxy.ts`. `paths.ts` owns what a
21
21
  * path may be and what walks drop.
22
22
  */
@@ -26,7 +26,7 @@ import { grepTools } from "./tools-grep.js";
26
26
  * exports must not move when the files behind it do.
27
27
  */
28
28
  export { isGitInternal, WALK_SKIPS } from "./paths.js";
29
- export { packBlocks, renderGrepMatches, renderResult } from "./render.js";
29
+ export { packBlocks, renderGrepMatches } from "./render.js";
30
30
  export { execGate, needsDependencies, writeGate } from "./gate.js";
31
31
  export { sandboxClient, computerWorkspace } from "./sandbox.js";
32
32
  /**
@@ -42,18 +42,35 @@ export function buildComputerTools(workspace, config, advisories, lockScope) {
42
42
  ...grepTools(ctx)
43
43
  };
44
44
  }
45
- /** What the model is told about the container, once per turn. */
46
- const CONTEXT = [
47
- "You have a Linux container with a shell, a package manager and network access:",
48
- "- `bash` runs any shell command — builds, tests, installs, git.",
49
- "- `read` / `write` / `edit` / `delete` work on files; `edit` replaces an exact unique string and is the right tool for a small change.",
50
- "- `grep` searches file contents; `list` lists a directory; `find` finds files by glob.",
51
- "Search with `grep` and `find` rather than running them through `bash`: they come back with line numbers and a bounded result instead of a wall of text.",
52
- "Command output is truncated from the middle when large, so run targeted commands and read specific files rather than printing everything.",
53
- "The checkout is the container's own disk, and it is kept between tasks: a stopped container starts again from it, so it may already contain work from an earlier task — check before assuming it is empty.",
54
- "`node_modules` is on that disk beside the checkout and is kept with it. The file tools leave it out — use `bash` there.",
55
- "`.git` is off limits to these tools, and searches and recursive listings skip it. It is git's internal state — reading it tells you less than the repository tools do, and writing it corrupts the checkout. Repository work goes through the repo tools (`repo_status`, `repo_diff`, `repo_commit`, `repo_push`); if a task needs git state you cannot get that way, say so in your result rather than reaching into `.git` yourself."
56
- ].join("\n");
45
+ /**
46
+ * What the model is told about the container, once per turn.
47
+ *
48
+ * A function of the config rather than a constant, because two of the
49
+ * guarantees here are the configured shell's: without one, a pipeline reports
50
+ * its last stage and a promise of first-failure reporting would be false.
51
+ */
52
+ function contextFor(config) {
53
+ return [
54
+ "You have a Linux container with a shell, a package manager and network access:",
55
+ "- `exec` runs any shell command — builds, tests, installs, git.",
56
+ "- `read` / `write` / `edit` / `delete` work on files; `edit` replaces an exact unique string and is the right tool for a small change.",
57
+ "- `grep` searches file contents; `ls` lists a directory; `find` finds files by glob.",
58
+ "Search with `grep` and `find` rather than running them through `exec`: they come back with line numbers and a bounded result instead of a wall of text.",
59
+ "A command's output is cut to its last lines when it is large, so an error printed early in a long run is gone from what you read: run targeted commands, and read specific files rather than printing everything.",
60
+ // Two of these are the configured shell's, not this plugin's: `-o pipefail`
61
+ // is what makes a pipeline report its first failing stage, and without a
62
+ // shell the wrapper that would carry it does not exist. Promising it anyway
63
+ // is the failure `./shell.ts` documents at length — a model that cannot
64
+ // trust a piped exit code re-runs the whole gate to get one.
65
+ config.shell
66
+ ? "Do not pipe into `head` or `tail` to shorten output: it is already cut, and piping costs you the end you wanted. A pipeline reports its first failing stage, so a piped command reports its real failure — but `cmd | head` may report 141 when `head` closes the pipe early, which is not a failure of `cmd`."
67
+ : "Do not pipe into `head` or `tail` to shorten output: it is already cut, and piping costs you the end you wanted. A pipeline reports only its **last** stage, so `npm run check | cat` reports the `cat` and says nothing about the check: read the exit code of the command you care about rather than the pipe's.",
68
+ "The checkout is the container's own disk, and it is kept between tasks: a stopped container starts again from it, so it may already contain work from an earlier task — check before assuming it is empty.",
69
+ "`/workspace` holds one directory per checkout, plus directories of your own that are not part of any repository: `attachments/` keeps media taken out of the conversation, `fetched/` keeps fetched pages, `.agents/` keeps projected skills. A message that says a file was preserved at one of those paths means you can read it back. Searches from `/workspace` skip them; repository work happens inside a checkout.",
70
+ "`node_modules` is on that disk beside the checkout and is kept with it. The file tools leave it out — use `exec` there.",
71
+ "`.git` is off limits to these tools, and searches and recursive listings skip it. It is git's internal state — reading it tells you less than the repository tools do, and writing it corrupts the checkout. Repository work goes through the repo tools (`repo_status`, `repo_diff`, `repo_commit`, `repo_push`); if a task needs git state you cannot get that way, say so in your result rather than reaching into `.git` yourself."
72
+ ].join("\n");
73
+ }
57
74
  /**
58
75
  * The container plugin over a `/sandbox` object. Its tools reach the sandbox
59
76
  * through the agent's own `computerWorkspace` — the one Think's `read` and
@@ -68,7 +85,7 @@ export function computer(config) {
68
85
  // so a missing workspace fails the start rather than a turn.
69
86
  const route = workspaceRoute(ctx.workspace());
70
87
  if (!route)
71
- throw new PluginSetupError(`plugin "computer" runs \`bash\` in a container, but ${ctx.agentName}'s ` +
88
+ throw new PluginSetupError(`plugin "computer" runs \`exec\` in a container, but ${ctx.agentName}'s ` +
72
89
  "workspace is not that container's, so Think's `read` and `write` " +
73
90
  "would work on a different tree. Set it on the agent class: " +
74
91
  "`override workspace = computerWorkspace(config, () => this.pluginContext().runtime())`.");
@@ -82,6 +99,6 @@ export function computer(config) {
82
99
  // two places.
83
100
  () => route.advisories(route.name()), route.name);
84
101
  },
85
- context: [{ provider: { get: async () => CONTEXT } }]
102
+ context: [{ provider: { get: async () => contextFor(config) } }]
86
103
  });
87
104
  }
@@ -21,11 +21,35 @@
21
21
  */
22
22
  export declare function isGitInternal(path: string): boolean;
23
23
  /**
24
- * What every walk steps over, as directory names. Both the workspace's `glob`
24
+ * What every walk steps over, as directory names. Both the workspace's `find`
25
25
  * and the `grep` tool hand these on as `**\/<name>` exclusions, which prunes
26
26
  * them: no directory named here is descended into, so none costs a read.
27
27
  */
28
28
  export declare const WALK_SKIPS: readonly [".git", "node_modules"];
29
+ /**
30
+ * The directories Think writes on its own account, directly under the
31
+ * workspace root: evicted media under `attachments/evicted/`, a fetch tool's
32
+ * spilled responses under `fetched/`, projected skills under `.agents/skills`.
33
+ * Think builds those paths from a root of its own and offers no way to move
34
+ * them, so a walk of the root reaches them.
35
+ *
36
+ * Pruned from a walk of the root and from nowhere else, which is what
37
+ * {@link walkSkips} spells: these are *names at the root*, not names anywhere,
38
+ * so a checkout with its own `attachments/` is still searched. A direct read is
39
+ * not refused either, because an eviction marker hands the model one of these
40
+ * paths and expects it to be readable.
41
+ */
42
+ export declare const AGENT_OWNED: readonly ["attachments", "fetched", ".agents"];
43
+ /**
44
+ * The exclusions one walk hands on, for a walk rooted at `directory` inside a
45
+ * workspace whose root is `root`.
46
+ *
47
+ * Every pattern is matched against the walked directory's *relative* path, so a
48
+ * root-anchored absolute pattern would match nothing. {@link AGENT_OWNED} is
49
+ * therefore relative to `directory` and only added when the walk starts at the
50
+ * root; {@link WALK_SKIPS} is a name at any depth.
51
+ */
52
+ export declare function walkSkips(directory: string, root: string): string[];
29
53
  /**
30
54
  * The path check every file tool and every workspace call makes, before it
31
55
  * opens anything.
@@ -40,7 +64,7 @@ export declare const WALK_SKIPS: readonly [".git", "node_modules"];
40
64
  * actually happens is a model reaching into `.git/HEAD` to fix a merge.
41
65
  *
42
66
  * It is not containment, and this is the only place worth saying so. An agent
43
- * that can write files here almost always holds `bash` too, which reads and
67
+ * that can write files here almost always holds `exec` too, which reads and
44
68
  * writes `.git` directly.
45
69
  *
46
70
  * Symlinks are not resolved, for the same reason: a tracked
@@ -10,6 +10,7 @@
10
10
  * Everything here is a string comparison: no filesystem, no `await`, which is
11
11
  * what lets {@link guardPath} run before a tool opens the workspace at all.
12
12
  */
13
+ import { normalizeAbsolute } from "../sandbox/header.js";
13
14
  /** Whether any segment of `path` is exactly `segment`. */
14
15
  function hasSegment(path, segment) {
15
16
  return path.split("/").includes(segment);
@@ -27,15 +28,60 @@ export function isGitInternal(path) {
27
28
  return hasSegment(path, ".git");
28
29
  }
29
30
  /**
30
- * What every walk steps over, as directory names. Both the workspace's `glob`
31
+ * What every walk steps over, as directory names. Both the workspace's `find`
31
32
  * and the `grep` tool hand these on as `**\/<name>` exclusions, which prunes
32
33
  * them: no directory named here is descended into, so none costs a read.
33
34
  */
34
35
  export const WALK_SKIPS = [".git", "node_modules"];
36
+ /**
37
+ * The directories Think writes on its own account, directly under the
38
+ * workspace root: evicted media under `attachments/evicted/`, a fetch tool's
39
+ * spilled responses under `fetched/`, projected skills under `.agents/skills`.
40
+ * Think builds those paths from a root of its own and offers no way to move
41
+ * them, so a walk of the root reaches them.
42
+ *
43
+ * Pruned from a walk of the root and from nowhere else, which is what
44
+ * {@link walkSkips} spells: these are *names at the root*, not names anywhere,
45
+ * so a checkout with its own `attachments/` is still searched. A direct read is
46
+ * not refused either, because an eviction marker hands the model one of these
47
+ * paths and expects it to be readable.
48
+ */
49
+ export const AGENT_OWNED = ["attachments", "fetched", ".agents"];
50
+ /**
51
+ * The exclusions one walk hands on, for a walk rooted at `directory` inside a
52
+ * workspace whose root is `root`.
53
+ *
54
+ * Every pattern is matched against the walked directory's *relative* path, so a
55
+ * root-anchored absolute pattern would match nothing. {@link AGENT_OWNED} is
56
+ * therefore relative to `directory` and only added when the walk starts at the
57
+ * root; {@link WALK_SKIPS} is a name at any depth.
58
+ */
59
+ export function walkSkips(directory, root) {
60
+ const anywhere = WALK_SKIPS.map((segment) => `**/${segment}`);
61
+ return atRoot(directory, root)
62
+ ? [...anywhere, ...AGENT_OWNED.map((name) => `${name}/**`)]
63
+ : anywhere;
64
+ }
65
+ /**
66
+ * Whether a walk starts at the workspace root, by what the two paths mean
67
+ * rather than by how they are spelled.
68
+ *
69
+ * The directory is the model's to spell, and `/workspace/`, `/workspace/.` and
70
+ * `/workspace/repo/..` are all spellings it produces. Every one of them is this
71
+ * root to the sandbox, which normalises before it confines, so a comparison
72
+ * here that read them literally would answer no to a walk that then went ahead
73
+ * from the root and came back with the agent's own directories in it. The
74
+ * normaliser is {@link file://../sandbox/header.ts normalizeAbsolute}, shared
75
+ * with that confinement so the two cannot disagree.
76
+ */
77
+ function atRoot(directory, root) {
78
+ const resolved = normalizeAbsolute(directory);
79
+ return resolved !== undefined && resolved === normalizeAbsolute(root);
80
+ }
35
81
  /** The sentence a `node_modules` path gets. */
36
82
  function dependencyTreeNote(path, verb) {
37
83
  return (`${path} is inside node_modules, which ${verb} leaves out: it is installed ` +
38
- `output, not source. Use bash there — \`cat\`, \`ls\`, \`rg\`.`);
84
+ `output, not source. Use exec there — \`cat\`, \`ls\`, \`rg\`.`);
39
85
  }
40
86
  /**
41
87
  * The sentence a `.git` path gets.
@@ -43,9 +89,9 @@ function dependencyTreeNote(path, verb) {
43
89
  * Names a route deliberately: a refusal with no destination is worse than no
44
90
  * refusal, because the model retries and then works around it.
45
91
  *
46
- * What it must never name is `bash`. That hands back the exact capability
92
+ * What it must never name is `exec`. That hands back the exact capability
47
93
  * being withheld, in the one place the model is already looking for a way around
48
- * it, with the tool's own authority behind it. `bash` is unguarded because a
94
+ * it, with the tool's own authority behind it. `exec` is unguarded because a
49
95
  * shell takes an opaque command string and pattern-matching git out of one is
50
96
  * neither reliable nor this guard's job — a fact about the implementation, not a
51
97
  * route to advertise.
@@ -72,7 +118,7 @@ function gitInternalNote(path, verb) {
72
118
  * actually happens is a model reaching into `.git/HEAD` to fix a merge.
73
119
  *
74
120
  * It is not containment, and this is the only place worth saying so. An agent
75
- * that can write files here almost always holds `bash` too, which reads and
121
+ * that can write files here almost always holds `exec` too, which reads and
76
122
  * writes `.git` directly.
77
123
  *
78
124
  * Symlinks are not resolved, for the same reason: a tracked
@@ -1,4 +1,4 @@
1
- import type { WorkspaceLike } from "@cloudflare/think";
1
+ import type { ThinkWorkspace } from "@cloudflare/think";
2
2
  import type { SandboxAdvisory } from "../sandbox/advisory.js";
3
3
  import type { ComputerClient } from "./client.js";
4
4
  /**
@@ -6,7 +6,7 @@ import type { ComputerClient } from "./client.js";
6
6
  *
7
7
  * Think's `read`, `write`, `edit` and `delete` run against whatever an agent's
8
8
  * workspace is, and the computer tools replace only the ones they name. So an
9
- * agent with a container and Think's default workspace would run `bash` in the
9
+ * agent with a container and Think's default workspace would run `exec` in the
10
10
  * container and `write` in its own SQLite, and nothing would say so. An agent
11
11
  * that installs the tools sets its workspace to `computerWorkspace`
12
12
  * (`./sandbox.ts`), and the plugin refuses to start without it — and reaches
@@ -28,4 +28,11 @@ export interface WorkspaceRoute {
28
28
  /** The route of a workspace {@link routedWorkspace} built, or `undefined`. */
29
29
  export declare function workspaceRoute(workspace: unknown): WorkspaceRoute | undefined;
30
30
  /** Think's file tools over `route`, carrying it for the computer tools to find. */
31
- export declare function routedWorkspace(route: WorkspaceRoute, cwd: string): WorkspaceLike;
31
+ /**
32
+ * `root` is the sandbox's served root and never a working directory. The
33
+ * directories Think writes its own files into are fixed under that root, so
34
+ * anchoring their exclusions anywhere else both hides a checkout's own
35
+ * `attachments/` and leaves Think's in a search of the root: see
36
+ * {@link file://./paths.ts walkSkips}.
37
+ */
38
+ export declare function routedWorkspace(route: WorkspaceRoute, root: string): ThinkWorkspace;