@dynamicagents/plugins 0.16.1 → 0.18.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
@@ -38,7 +38,7 @@ export class Coder extends StepAgent<Env> {
38
38
 
39
39
  override getPlugins() {
40
40
  return [
41
- browser({ binding: this.env.BROWSER }),
41
+ browser({ binding: this.env.BROWSER, loader: this.env.LOADER }),
42
42
  computer({ shell: "bash" }),
43
43
  repo(repoConfig(this.env))
44
44
  ];
@@ -66,9 +66,9 @@ plugin to the tools an install names.
66
66
 
67
67
  | Subpath | What it adds | Needs |
68
68
  | ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
69
- | [`/browser`](src/browser/) | Read web pages via Browser Rendering Quick Actions | `BROWSER` (paid plan) |
69
+ | [`/browser`](src/browser/) | Drive a persistent browser by writing code, over Browser Run | `BROWSER` (paid plan), `LOADER` |
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 | — |
@@ -1,28 +1,25 @@
1
1
  import type { AgentPlugin } from "@dynamicagents/core";
2
- import type { QuickActionToolName } from "agents/browser/ai";
3
- import type { QuickActionBinding, QuickActionCommonOptions } from "agents/browser";
2
+ import type { BrowserBinding, BrowserRunOptions } from "agents/browser";
4
3
  export interface BrowserConfig {
5
4
  /**
6
5
  * The `BROWSER` binding. Browser Rendering is a **paid-plan** feature, and
7
6
  * `wrangler dev` needs `remote: true` to reach it.
8
7
  */
9
- binding: QuickActionBinding;
8
+ binding: BrowserBinding;
10
9
  /**
11
- * Character ceiling per result. Defaults to 20,000 — well under the SDK's own
12
- * 50,000, because a page that fills a small chat model's context window costs
13
- * it the conversation it was reading the page for.
10
+ * The `LOADER` binding, declared as `"worker_loaders": [{ "binding":
11
+ * "LOADER" }]`. Each call gets a fresh Dynamic Worker to run the model's
12
+ * code in, with no network access of its own.
14
13
  */
15
- maxChars?: number;
14
+ loader: WorkerLoader;
15
+ /** How long one call may take, in milliseconds. The SDK defaults to 60,000. */
16
+ timeoutMs?: number;
16
17
  /**
17
- * Which tools to expose. Defaults to the SDK's text-returning set; `content`
18
- * (raw HTML) stays opt-in because it is large and rarely what a model wants.
18
+ * Browser Run launch options: how long an idle browser lives, hostname
19
+ * guardrails, session recording, internal hostnames. Fixed when the browser
20
+ * starts rather than per call, and host-supplied, so the model never sees or
21
+ * chooses any of it.
19
22
  */
20
- actions?: QuickActionToolName[];
21
- /**
22
- * Browser Rendering options merged into every request — cookies, auth headers,
23
- * viewport. Host-supplied and never exposed to the model, which only ever
24
- * chooses the page and the action-specific fields.
25
- */
26
- options?: QuickActionCommonOptions;
23
+ provider?: BrowserRunOptions;
27
24
  }
28
25
  export declare function browser(config: BrowserConfig): AgentPlugin;
@@ -1,42 +1,104 @@
1
1
  import { definePlugin } from "@dynamicagents/core";
2
- import { createQuickActionTools } from "agents/browser/ai";
2
+ import { Browser, DurableBrowserSessionStore, browserRun } from "agents/browser";
3
+ import { browserTool } from "agents/browser/ai-sdk";
3
4
  /**
4
- * `@dynamicagents/plugins/browser` — read the web, via Cloudflare Browser Rendering
5
- * Quick Actions.
5
+ * `@dynamicagents/plugins/browser` — drive a real browser, via Cloudflare
6
+ * Browser Run.
6
7
  *
7
- * Four tools (`browser_markdown`, `browser_extract`, `browser_links`,
8
- * `browser_scrape`), the same for whichever agent installs the plugin: a parent
9
- * reads a page to answer a question directly, and a research sub-agent reads a
10
- * dozen of them.
8
+ * One tool, `browser`: the model writes JavaScript that drives a persistent
9
+ * browser over CDP, and that code runs in a Dynamic Worker with no network of
10
+ * its own. The browser outlives each call, so tabs, cookies and logins carry
11
+ * over between them, and the model never starts, closes or resets it.
11
12
  *
12
- * The binding is closed over at instantiation, so it is never model input.
13
+ * The binding and the loader are closed over at instantiation, so neither is
14
+ * ever model input.
15
+ */
16
+ /**
17
+ * The browser's name in its own store. Two `Browser` objects on one Durable
18
+ * Object need different names, so this says which one is the plugin's rather
19
+ * than taking the SDK's default and colliding with a host that also built one.
20
+ */
21
+ const BROWSER_NAME = "browser";
22
+ /**
23
+ * What the model is told about the domain. Deliberately *not* the `browser.*`
24
+ * function reference: `browserTool` puts that in the tool's own description and
25
+ * keeps it in step with the SDK, and a copy here would be a second home for
26
+ * someone else's text. What is left is what this plugin knows and the SDK
27
+ * cannot: when reaching for a browser is the right move at all.
13
28
  */
14
- /** How many characters of a page result the model is allowed to see. */
15
- const DEFAULT_MAX_CHARS = 20_000;
16
29
  const CONTEXT = [
17
- "You can read web pages directly:",
18
- "- `browser_markdown` fetches a page and returns it as Markdown. This is the default — use it first.",
19
- "- `browser_extract` pulls structured fields out of a page when you know what you are looking for.",
20
- "- `browser_links` lists a page's links, for following a trail.",
21
- "- `browser_scrape` returns the text of specific CSS selectors.",
22
- "Results are truncated to protect your context window, so prefer a specific page over a search-results page, and extract rather than reading in full when you already know the fields you need."
30
+ "You can drive a real browser by writing JavaScript, with the `browser` tool. The tool's own description carries the API; what matters in choosing it:",
31
+ "- The browser is **persistent**. A later call lands on the same page, logged in, with the same cookies. Navigate in one call and read in the next rather than rebuilding state each time.",
32
+ "- Because it is persistent, read the page before acting on it. A call that submits a form or clicks through a checkout may be a repeat of one that already succeeded, and the page in front of you is the evidence of which.",
33
+ "- Prefer one call that does several steps over several calls that each do one: every call pays a round trip to the browser, and the page is still there either way.",
34
+ "- Return the part of the page you need rather than its whole text or markup. The tool's rules say where the ceiling is.",
35
+ "- A plain HTTP fetch is cheaper than a browser. Reach for this when a page needs rendering, a session, or interaction."
23
36
  ].join("\n");
24
37
  export function browser(config) {
25
- const { binding, maxChars = DEFAULT_MAX_CHARS, actions, options } = config;
38
+ const { binding, loader, timeoutMs, provider } = config;
39
+ /**
40
+ * One `Browser` per installing object, and the lock is why.
41
+ *
42
+ * `browserTool` opens every run as a locking session, so runs in one scope
43
+ * wait for each other rather than driving one tab at once. But the SDK keeps
44
+ * its lock queues in a `WeakMap` keyed by the **`Browser` object itself**, so
45
+ * two instances over the same stored session hold two unrelated locks and
46
+ * serialise nothing. `getTools()` runs per turn, so a fresh `Browser` each
47
+ * time would leave overlapping turns in one object free to navigate each
48
+ * other's page.
49
+ *
50
+ * Keyed on the installing object's storage, which is the same granularity the
51
+ * SDK's locks have ("per isolate, which is per Durable Object"), and weak so
52
+ * an evicted object's browser handle goes with it. A `WeakMap` rather than one
53
+ * slot because nothing stops a host from hoisting this plugin to module scope
54
+ * and sharing it between objects in one isolate.
55
+ */
56
+ const browsers = new WeakMap();
57
+ const browserFor = (storage) => {
58
+ const known = browsers.get(storage);
59
+ if (known !== undefined)
60
+ return known;
61
+ const made = new Browser({
62
+ name: BROWSER_NAME,
63
+ provider: browserRun(binding, provider),
64
+ // **The store is what keeps this out of the host's Lifecycle.** A
65
+ // `Browser` with no store defaults to one over the host object's storage
66
+ // and requires `Lifecycle.use()` to install it, which a plugin has no way
67
+ // to ask for: the contract offers tools, actions, context and `requires`
68
+ // and nothing else. Passing the store explicitly is the SDK's own answer,
69
+ // and it is what lets this capability stay whole inside the plugin.
70
+ store: new DurableBrowserSessionStore(storage)
71
+ });
72
+ browsers.set(storage, made);
73
+ return made;
74
+ };
26
75
  return definePlugin({
27
76
  name: "browser",
28
- // Built per call: the SDK returns a fresh ToolSet each time, and a tool
29
- // object is not obviously safe to share across two concurrent executions.
30
- tools: () => createQuickActionTools({
31
- browser: binding,
32
- maxChars,
33
- ...(actions ? { actions } : {}),
34
- ...(options ? { options } : {})
77
+ // **No `scope`.** A scope separates callers sharing one browser, and there
78
+ // is only ever one here: the record lives in the installing object's own
79
+ // storage, a parent object is keyed by its caller, and a sub-agent runs in
80
+ // its own facet with storage of its own. Naming a scope would imply an
81
+ // isolation the store already provides, and the default one still locks.
82
+ //
83
+ // **Nothing truncates here**, and the tool is handed over as the SDK built
84
+ // it. `browserTool` bounds its own output in two places: `execute` cuts the
85
+ // result and the error *structurally*, leaving valid JSON of the original
86
+ // shape with the largest values cut first and markers where content went,
87
+ // and `toModelOutput` then redacts base64 payloads, renders a screenshot as
88
+ // a note, and bounds the logs the same way. Wrapping either to add a flat
89
+ // ceiling of our own would collapse that JSON into a string, which is
90
+ // strictly worse than the bound it would be enforcing.
91
+ tools: (ctx) => ({
92
+ browser: browserTool({
93
+ browser: browserFor(ctx.storage),
94
+ loader,
95
+ ...(timeoutMs === undefined ? {} : { timeoutMs })
96
+ })
35
97
  }),
36
98
  context: [{ provider: { get: async () => CONTEXT } }],
37
- // A plugin cannot add its own wrangler binding, so declaring it turns a
99
+ // A plugin cannot add its own wrangler binding, so declaring them turns a
38
100
  // missing one into a startup error naming this plugin, rather than a failed
39
101
  // tool call inside a request someone is waiting on.
40
- requires: { bindings: ["BROWSER"] }
102
+ requires: { bindings: ["BROWSER", "LOADER"] }
41
103
  });
42
104
  }
@@ -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.317";
@@ -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.317";
@@ -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
  }