@dynamicagents/plugins 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +59 -79
  2. package/dist/browser/index.d.ts +0 -2
  3. package/dist/browser/index.js +21 -27
  4. package/dist/claude-code/cards.d.ts +53 -0
  5. package/dist/claude-code/cards.js +272 -0
  6. package/dist/claude-code/config.d.ts +73 -31
  7. package/dist/claude-code/config.js +0 -2
  8. package/dist/claude-code/credentials.d.ts +21 -9
  9. package/dist/claude-code/credentials.js +43 -9
  10. package/dist/claude-code/egress.d.ts +15 -14
  11. package/dist/claude-code/egress.js +51 -14
  12. package/dist/claude-code/events.d.ts +153 -9
  13. package/dist/claude-code/events.js +289 -58
  14. package/dist/claude-code/github.d.ts +39 -0
  15. package/dist/claude-code/github.js +82 -0
  16. package/dist/claude-code/index.d.ts +24 -80
  17. package/dist/claude-code/index.js +19 -234
  18. package/dist/claude-code/model.d.ts +152 -0
  19. package/dist/claude-code/model.js +342 -0
  20. package/dist/claude-code/run.d.ts +283 -92
  21. package/dist/claude-code/run.js +620 -114
  22. package/dist/claude-code/session.d.ts +131 -0
  23. package/dist/claude-code/session.js +285 -0
  24. package/dist/claude-code/spec.d.ts +46 -0
  25. package/dist/claude-code/spec.js +83 -0
  26. package/dist/claude-code/verified.d.ts +15 -0
  27. package/dist/claude-code/verified.js +15 -0
  28. package/dist/computer/context.d.ts +19 -0
  29. package/dist/computer/context.js +222 -0
  30. package/dist/computer/file-lock.d.ts +5 -0
  31. package/dist/computer/file-lock.js +40 -0
  32. package/dist/computer/gate.d.ts +12 -12
  33. package/dist/computer/gate.js +12 -12
  34. package/dist/computer/index.d.ts +57 -255
  35. package/dist/computer/index.js +98 -1039
  36. package/dist/computer/paths.d.ts +47 -22
  37. package/dist/computer/paths.js +64 -49
  38. package/dist/computer/proxy.d.ts +45 -0
  39. package/dist/computer/proxy.js +234 -0
  40. package/dist/computer/render.d.ts +27 -38
  41. package/dist/computer/render.js +32 -65
  42. package/dist/computer/tools-exec.d.ts +3 -0
  43. package/dist/computer/tools-exec.js +268 -0
  44. package/dist/computer/tools-file.d.ts +12 -0
  45. package/dist/computer/tools-file.js +64 -0
  46. package/dist/computer/tools-grep.d.ts +3 -0
  47. package/dist/computer/tools-grep.js +129 -0
  48. package/dist/repo/checkout.d.ts +35 -6
  49. package/dist/repo/checkout.js +64 -6
  50. package/dist/repo/context.d.ts +135 -0
  51. package/dist/repo/context.js +582 -0
  52. package/dist/repo/index.d.ts +79 -57
  53. package/dist/repo/index.js +118 -1112
  54. package/dist/repo/tools-checks.d.ts +3 -0
  55. package/dist/repo/tools-checks.js +112 -0
  56. package/dist/repo/tools-clone.d.ts +4 -0
  57. package/dist/repo/tools-clone.js +192 -0
  58. package/dist/repo/tools-pr.d.ts +14 -0
  59. package/dist/repo/tools-pr.js +196 -0
  60. package/dist/repo/tools-review.d.ts +12 -0
  61. package/dist/repo/tools-review.js +393 -0
  62. package/dist/repo/tools-worktree.d.ts +4 -0
  63. package/dist/repo/tools-worktree.js +305 -0
  64. package/dist/repo/tools-worktrees.d.ts +12 -0
  65. package/dist/repo/tools-worktrees.js +48 -0
  66. package/dist/repo/url.d.ts +2 -2
  67. package/dist/repo/url.js +7 -7
  68. package/dist/scratch/index.d.ts +4 -4
  69. package/dist/scratch/index.js +12 -11
  70. package/dist/{computer → workspace}/advisory.d.ts +33 -20
  71. package/dist/{computer → workspace}/advisory.js +32 -7
  72. package/dist/workspace/ca-trust.d.ts +95 -0
  73. package/dist/workspace/ca-trust.js +312 -0
  74. package/dist/workspace/container-deps.d.ts +54 -0
  75. package/dist/workspace/container-deps.js +224 -0
  76. package/dist/workspace/container-fault.d.ts +38 -0
  77. package/dist/workspace/container-fault.js +73 -0
  78. package/dist/workspace/exec.d.ts +94 -0
  79. package/dist/workspace/exec.js +153 -0
  80. package/dist/workspace/format.d.ts +36 -0
  81. package/dist/workspace/format.js +57 -0
  82. package/dist/workspace/git-host.d.ts +79 -0
  83. package/dist/workspace/git-host.js +226 -0
  84. package/dist/workspace/git-identity.d.ts +57 -0
  85. package/dist/workspace/git-identity.js +147 -0
  86. package/dist/workspace/index.d.ts +43 -47
  87. package/dist/workspace/index.js +52 -112
  88. package/dist/workspace/install-job.d.ts +121 -0
  89. package/dist/workspace/install-job.js +866 -0
  90. package/dist/{computer → workspace}/install.d.ts +15 -19
  91. package/dist/{computer → workspace}/install.js +8 -11
  92. package/dist/workspace/object.d.ts +502 -0
  93. package/dist/workspace/object.js +1427 -0
  94. package/dist/workspace/open.d.ts +95 -0
  95. package/dist/workspace/open.js +63 -0
  96. package/dist/workspace/read.d.ts +16 -0
  97. package/dist/workspace/read.js +22 -0
  98. package/dist/workspace/shell.d.ts +45 -0
  99. package/dist/workspace/shell.js +80 -0
  100. package/dist/workspace/sync.d.ts +111 -0
  101. package/dist/workspace/sync.js +124 -0
  102. package/dist/workspace/wake.d.ts +19 -0
  103. package/dist/workspace/wake.js +10 -0
  104. package/package.json +28 -42
  105. package/dist/arc-agi/analysis.d.ts +0 -336
  106. package/dist/arc-agi/analysis.js +0 -829
  107. package/dist/arc-agi/client.d.ts +0 -82
  108. package/dist/arc-agi/client.js +0 -113
  109. package/dist/arc-agi/game-tools.d.ts +0 -25
  110. package/dist/arc-agi/game-tools.js +0 -26
  111. package/dist/arc-agi/index.d.ts +0 -42
  112. package/dist/arc-agi/index.js +0 -175
  113. package/dist/arc-agi/main-agent.d.ts +0 -27
  114. package/dist/arc-agi/main-agent.js +0 -65
  115. package/dist/arc-agi/recipe.d.ts +0 -51
  116. package/dist/arc-agi/recipe.js +0 -100
  117. package/dist/arc-agi/scorecard.d.ts +0 -118
  118. package/dist/arc-agi/scorecard.js +0 -163
  119. package/dist/arc-agi/soul.d.ts +0 -40
  120. package/dist/arc-agi/soul.js +0 -90
  121. package/dist/arc-agi/store.d.ts +0 -154
  122. package/dist/arc-agi/store.js +0 -216
  123. package/dist/arc-agi/tools.d.ts +0 -36
  124. package/dist/arc-agi/tools.js +0 -576
  125. package/dist/arc-agi/types.d.ts +0 -212
  126. package/dist/arc-agi/types.js +0 -19
  127. package/dist/claude-code/recipe.d.ts +0 -53
  128. package/dist/claude-code/recipe.js +0 -100
  129. package/dist/computer/read.d.ts +0 -137
  130. package/dist/computer/read.js +0 -203
  131. package/dist/recall/index.d.ts +0 -99
  132. package/dist/recall/index.js +0 -171
  133. package/dist/triage/index.d.ts +0 -355
  134. package/dist/triage/index.js +0 -302
@@ -1,3 +1,4 @@
1
+ import { truncateOutput } from "../workspace/format.js";
1
2
  /**
2
3
  * Turning a result into the text a model reads.
3
4
  *
@@ -15,43 +16,9 @@
15
16
  * A grep match carries the whole line, and a minified bundle is one line of
16
17
  * megabytes — a single match in `dist/` would otherwise be the entire result. The
17
18
  * head is kept because a match's file and line number are what the model acts on;
18
- * it reads the region with `sb_read` next.
19
+ * it reads the region with `read` next.
19
20
  */
20
21
  const MAX_MATCH_LINE_CHARS = 200;
21
- /**
22
- * Middle-out truncation, so both the first error and the final summary survive.
23
- *
24
- * The guard is not defensive padding. Without it a `max` at or below the marker's
25
- * own length makes `half` zero or negative, and `slice(-0)` is `slice(0)` — the
26
- * *whole* string — so the function returns more than it was given: 500 characters
27
- * in, 543 out at `max: 80`. A silent inversion of the one thing it does,
28
- * reachable from a public config field.
29
- */
30
- export function truncateOutput(text, max) {
31
- if (text.length <= max)
32
- return text;
33
- const marker = (dropped) => `\n\n… [${dropped} characters omitted from the middle] …\n\n`;
34
- // No budget for two halves plus the marker: keep the head, which is where the
35
- // first error is, and say nothing clever.
36
- const half = Math.floor((max - marker(text.length).length) / 2);
37
- if (half < 1)
38
- return text.slice(0, Math.max(0, max));
39
- return (text.slice(0, half) + marker(text.length - half * 2) + text.slice(-half));
40
- }
41
- /**
42
- * A byte count in the form a model can act on.
43
- *
44
- * A listing that says a file is 4.2 MB tells the model the read it is about to do
45
- * comes back with a hole in the middle — the one thing it cannot infer from the
46
- * truncated result itself.
47
- */
48
- export function humanBytes(bytes) {
49
- if (bytes < 1024)
50
- return `${bytes} B`;
51
- if (bytes < 1024 * 1024)
52
- return `${(bytes / 1024).toFixed(1)} KB`;
53
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
54
- }
55
22
  /** One matching line, bounded — see {@link MAX_MATCH_LINE_CHARS}. */
56
23
  function capLine(text) {
57
24
  // A CRLF file leaves the carriage return on every line, since the reader splits
@@ -135,13 +102,6 @@ export function renderGrepMatches(matches, maxChars) {
135
102
  // Only a line that actually made it out is worth explaining.
136
103
  return { body, shown, capped: hasLongLine.slice(0, shown).includes(true) };
137
104
  }
138
- /** Elapsed time in the shape a sentence wants. */
139
- export function humanMs(ms) {
140
- const s = Math.round(ms / 1000);
141
- return s < 60
142
- ? `${s}s`
143
- : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
144
- }
145
105
  /**
146
106
  * Render a completed command the way a terminal would, minus the noise.
147
107
  *
@@ -177,31 +137,38 @@ export function renderResult(result, maxChars) {
177
137
  const body = truncateOutput(parts.join("\n"), maxChars);
178
138
  const state = result.status && result.status !== "completed" ? ` (${result.status})` : "";
179
139
  const verdict = `--- exit ${result.exitCode}${state} ---`;
180
- return body ? `${body}\n${verdict}` : `(no output)\n${verdict}`;
140
+ const pending = syncPendingNote(result.sync);
141
+ const transcript = body ? `${body}\n${verdict}` : `(no output)\n${verdict}`;
142
+ return pending ? `${transcript}\n${pending}` : transcript;
181
143
  }
182
144
  /**
183
- * Why a command has nothing to show for itself.
184
- *
185
- * {@link renderResult} states a non-`completed` status on its verdict line;
186
- * {@link computerExec} has no verdict line, only the four fields its caller
187
- * branches on. A *killed* process writes nothing, so without this a `git clone`
188
- * that hit the ten-minute ceiling reaches the model through
189
- * `@dynamicagents/plugins/repo` as `clone failed:` with an empty reason — which reads
190
- * like a bug in the plugin rather than a limit it can work within.
191
- *
192
- * Only `cancelled`. A `failed` status is an ordinary non-zero exit, where the
193
- * command's own stderr is the better explanation and this would be noise on top
194
- * of it.
195
- *
196
- * Both plausible causes are named because the exit code cannot separate them:
197
- * 137 is SIGKILL, which is what the timeout sends *and* what the kernel sends a
198
- * container that ran out of memory.
145
+ * The command ran; its changes have not landed in the workspace yet.
146
+ *
147
+ * Every command is bracketed by a sync, and the pull afterwards is what moves
148
+ * what it wrote from the container into the Durable Object. That pull can fail
149
+ * while the command itself succeeds — a transport that dropped, a container
150
+ * replaced underneath — and the runtime reports it in `sync` rather than in the
151
+ * exit code, because the command genuinely did run.
152
+ *
153
+ * Worth a sentence to the model because the *next* thing it does is usually read
154
+ * what it just wrote, and the file tools read the workspace rather than the
155
+ * container. Without this the file looks unchanged and the obvious conclusion is
156
+ * that the command did not work, which is the one conclusion that is wrong.
157
+ *
158
+ * Recovery is deliberately not the model's, and is not automatic either: the
159
+ * runtime schedules no retry of its own, so an outstanding pull lands when the
160
+ * host drives one or when a later command's own bracket carries it. What the
161
+ * model is told therefore stops short of "it will be there in a moment" — it is
162
+ * told the read may be stale, that re-running the command is the wrong fix
163
+ * because it would repeat the side effects, and to say so in its result if the
164
+ * workspace still disagrees with what it wrote.
199
165
  */
200
- export function cancelledNote(status, exitCode, timeoutMs) {
201
- if (status !== "cancelled")
166
+ export function syncPendingNote(sync) {
167
+ if (sync?.status !== "pending")
202
168
  return undefined;
203
- return (`the command was killed rather than exiting on its own (exit ${exitCode}), so ` +
204
- `anything it had not yet written is gone. The two usual causes are the ` +
205
- `per-command time limit — ${humanMs(timeoutMs)} here — and the container ` +
206
- `running out of memory.`);
169
+ return ("(The command finished, but what it wrote has not reached the workspace " +
170
+ "yet, so the file tools may still show the previous contents. Do not re-run " +
171
+ "the command to force it — that repeats whatever it already did. Read again " +
172
+ "in a moment, and if the workspace still disagrees with what the command " +
173
+ "wrote, say so in your result rather than working around it.)");
207
174
  }
@@ -0,0 +1,3 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { ComputerContext } from "./context.js";
3
+ export declare function execTools(ctx: ComputerContext): ToolSet;
@@ -0,0 +1,268 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ import { tool } from "ai";
54
+ import { z } from "zod";
55
+ import { withAbort } from "@dynamicagents/core";
56
+ import { renderResult } from "./render.js";
57
+ import { execLostNote } from "./gate.js";
58
+ import { withShellTranscript } from "../workspace/shell.js";
59
+ /** Running one command in the container. */
60
+ /**
61
+ * Wait for a disposable resource without outliving `signal`.
62
+ *
63
+ * `withAbort` can abandon the wait but not the resource, and one that arrives
64
+ * after its caller has given up has nobody left to dispose it. So it is released
65
+ * on arrival instead — disposed by default, or handed to `release` where
66
+ * disposing alone would leave work running.
67
+ */
68
+ function acquire(signal, opening, release = (resource) => resource[Symbol.dispose]()) {
69
+ return withAbort(signal, opening, () => {
70
+ void opening.then(release).catch(() => { });
71
+ });
72
+ }
73
+ /**
74
+ * How an exec handle that arrived too late is released. Disposing only detaches
75
+ * this side, so the process is killed first.
76
+ */
77
+ async function killLate(handle) {
78
+ try {
79
+ await handle.kill("SIGTERM");
80
+ }
81
+ finally {
82
+ handle[Symbol.dispose]();
83
+ }
84
+ }
85
+ export function execTools(ctx) {
86
+ const { config, cwd, timeoutMs, maxChars, workspace, awaitAdvisories, definedEnv } = ctx;
87
+ return {
88
+ bash: tool({
89
+ /**
90
+ * States what the tool guarantees, rather than what it might withhold.
91
+ *
92
+ * A description that leads with "output is truncated if it is large" and
93
+ * gives no exit code hands the model two independent reasons to re-run a
94
+ * command and capture the output "properly" — at 60 seconds a go on a real
95
+ * gate, whose few hundred characters were never near the ceiling anyway.
96
+ *
97
+ * Two of these sentences are only true when a shell is configured, so the
98
+ * description says whichever is. `withShellTranscript` is a no-op without
99
+ * {@link file://./index.ts ComputerConfig.shell}: no `2>&1`, so the
100
+ * streams arrive separate and {@link renderResult} labels them; no
101
+ * `-o pipefail`, so `/bin/sh` reports a pipeline's *last* stage. Promising
102
+ * a transcript and first-failure semantics there would be the lie
103
+ * `wrapped` explains the cost of.
104
+ */
105
+ description: "Run a shell command in the container and return its output. Use this for builds, tests, package installs, git, and anything else a terminal can do. " +
106
+ (config.shell
107
+ ? "The result is the command's full transcript — stdout and stderr interleaved in the order they were written — followed by a line reporting the exit code, e.g. `--- exit 0 ---`. A command killed at the time limit says so on that line. " +
108
+ "You do not need to append `echo $?`, add `2>&1`, or redirect to a file to see any of this. " +
109
+ "Do not pipe into `head` or `tail` to shorten output: long output is already truncated from the middle, keeping the beginning and the end, and piping costs you the parts you wanted. Pipelines report the first failing stage, so a piped command reports its real failure rather than the pipe's — but `cmd | head` may report 141 when `head` closes the pipe early, which is not a failure of `cmd`. "
110
+ : "The result is the command's stdout, then any stderr under a `--- stderr ---` heading, then a line reporting the exit code, e.g. `--- exit 0 ---`. A command killed at the time limit says so on that line. " +
111
+ "You do not need to append `echo $?` to see the exit code. Add `2>&1` yourself if you need the two streams in the order they were written. " +
112
+ "Do not pipe into `head` or `tail` to shorten output: long output is already truncated from the middle, keeping the beginning and the end, and piping costs you the parts you wanted. A pipeline reports its **last** stage, so check the exit code of the command you care about rather than the pipe's. ") +
113
+ "Prefer targeted commands over ones that print everything.",
114
+ inputSchema: z.object({
115
+ command: z.string().describe("The shell command, e.g. 'npm test'"),
116
+ cwd: z
117
+ .string()
118
+ .optional()
119
+ .describe(`Working directory (default: ${cwd})`)
120
+ }),
121
+ execute: async ({ command, cwd: overrideCwd }, { abortSignal }) => {
122
+ /**
123
+ * One line per command, and it is the only view of where a task's wall
124
+ * clock actually goes.
125
+ *
126
+ * Everything on the Worker side of a container command is an `await`, so
127
+ * Workers Observability records the invocation at ~0% CPU and a long wall
128
+ * time and cannot say what ran. Without this line, diagnosing a 59-minute
129
+ * task means inferring the shape of each command from the *gaps between
130
+ * AI Gateway calls*. The three numbers below — how long the install gate
131
+ * held, how long the command took, what it exited with — answer it
132
+ * directly.
133
+ *
134
+ * Timed around the gate as well as the command, since an agent blocked
135
+ * waiting for `npm ci` and one running a slow test suite are
136
+ * indistinguishable from the outside and want opposite fixes.
137
+ */
138
+ const startedAtMs = Date.now();
139
+ /**
140
+ * What the model is told when this call stops before the command
141
+ * finishes. Said plainly, because the likeliest next move after a bare
142
+ * error is the same command again — and whether that is right depends on
143
+ * where the call stopped. A command that outran the limit once will again;
144
+ * one that never started because the workspace did not answer may not.
145
+ *
146
+ * `bash` reads its call's signal rather than leaving the wait to Think,
147
+ * and this is why: an abandoned wait can say only that the command may
148
+ * still be running, and the kill below is what stops it.
149
+ */
150
+ const stopped = (gateMs, started) => {
151
+ const timedOut = abortSignal?.reason?.name ===
152
+ "TimeoutError";
153
+ console.info("[computer] bash stopped", {
154
+ command,
155
+ gateMs,
156
+ durationMs: Date.now() - startedAtMs - gateMs,
157
+ started,
158
+ reason: timedOut ? "time limit" : "cancelled"
159
+ });
160
+ if (!timedOut)
161
+ return "the command was stopped because this call was cancelled. Anything it changed before then is still changed.";
162
+ return started
163
+ ? "the command was stopped: it ran past this call's time limit. Anything it changed before then is still changed. Try something narrower."
164
+ : "the command did not run: this call reached its time limit while the workspace was still getting ready, so nothing was changed. Try again; if it happens again, the workspace is not responding.";
165
+ };
166
+ // Only `bash` waits on an install. The file tools read and write
167
+ // source, which is in the workspace and unaffected by an install in
168
+ // flight — blocking them would stop the agent doing the reading it
169
+ // could usefully do while it waits. They do consult `writeGate`, which
170
+ // is a different question: not "is the tree ready" but "does a write
171
+ // survive at all".
172
+ let gate;
173
+ try {
174
+ gate = await awaitAdvisories(command, abortSignal);
175
+ }
176
+ catch (err) {
177
+ // Only a cancel gets out of the gate; a failed advisory read opens it.
178
+ if (abortSignal?.aborted)
179
+ return stopped(Date.now() - startedAtMs, false);
180
+ throw err;
181
+ }
182
+ const gateMsWaited = Date.now() - startedAtMs;
183
+ if (gate.block) {
184
+ console.info("[computer] bash blocked by a workspace advisory", {
185
+ command,
186
+ gateMs: gateMsWaited
187
+ });
188
+ return gate.block;
189
+ }
190
+ // Prepended to whatever happens next, success or failure. The warning
191
+ // is context for the output, not a substitute for it — which is why it
192
+ // is carried through the catch as well.
193
+ const note = (body) => gate.warn ? `${gate.warn}\n\n${body}` : body;
194
+ // Set once the exec is sent. See `stopped`.
195
+ let started = false;
196
+ try {
197
+ const env_1 = { stack: [], error: void 0, hasError: false };
198
+ try {
199
+ // A workspace that stops answering would hold the call before any of the
200
+ // cancellation below is reached.
201
+ abortSignal?.throwIfAborted();
202
+ const ws = __addDisposableResource(env_1, await acquire(abortSignal, workspace()), false);
203
+ // Read once per command. This is the *host's* thunk, so calling it
204
+ // twice in the construction of one command's options is two chances to
205
+ // disagree — the check and the value would come from different reads.
206
+ const commandEnv = definedEnv();
207
+ const options = {
208
+ cwd: overrideCwd ?? cwd,
209
+ encoding: "utf8",
210
+ timeoutMs,
211
+ ...(commandEnv ? { env: commandEnv } : {})
212
+ };
213
+ // Transcript, not two streams: this result goes to a model, which
214
+ // reads it as a terminal session rather than parsing it.
215
+ // Not started at all on a signal that has already fired: a process that
216
+ // is killed on the next line still ran long enough to change something.
217
+ abortSignal?.throwIfAborted();
218
+ // From here a stop cannot promise nothing ran: the exec may reach the
219
+ // container before its handle reaches this side.
220
+ started = true;
221
+ const handle = __addDisposableResource(env_1, await acquire(abortSignal, ws.runtime.exec(withShellTranscript(command, config.shell), options), killLate), false);
222
+ // The runtime takes no signal, so stopping the wait and stopping the
223
+ // process are two acts. Disposing the handle is neither — it releases
224
+ // this side's attachment and leaves the command running.
225
+ const result = await withAbort(abortSignal, handle.result(), () => handle.kill("SIGTERM"));
226
+ console.info("[computer] bash", {
227
+ command,
228
+ exitCode: result.exitCode,
229
+ // Split so a slow command and a slow *wait* never look alike.
230
+ gateMs: gateMsWaited,
231
+ durationMs: Date.now() - startedAtMs - gateMsWaited,
232
+ // The ceiling this was measured against — a duration sitting on it is
233
+ // a timeout wearing a normal-looking number.
234
+ timeoutMs
235
+ });
236
+ return note(renderResult(result, maxChars));
237
+ }
238
+ catch (e_1) {
239
+ env_1.error = e_1;
240
+ env_1.hasError = true;
241
+ }
242
+ finally {
243
+ __disposeResources(env_1);
244
+ }
245
+ }
246
+ catch (err) {
247
+ if (abortSignal?.aborted)
248
+ return note(stopped(gateMsWaited, started));
249
+ const lost = execLostNote(err);
250
+ console.warn("[computer] bash failed", {
251
+ command,
252
+ gateMs: gateMsWaited,
253
+ durationMs: Date.now() - startedAtMs - gateMsWaited,
254
+ timeoutMs,
255
+ // Distinguished in the log for the same reason it is distinguished
256
+ // for the model: a container replacement and a broken command want
257
+ // different people looking at them.
258
+ ...(lost ? { lost: true } : {}),
259
+ err: String(err)
260
+ });
261
+ // Returned, not thrown: a failed command is usually the model's to
262
+ // recover from, and it can only recover from what it is told.
263
+ return note(lost ?? `error running command: ${String(err)}`);
264
+ }
265
+ }
266
+ })
267
+ };
268
+ }
@@ -0,0 +1,12 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { ComputerContext } from "./context.js";
3
+ /**
4
+ * Editing a file in place — the one file tool this plugin keeps. Think's
5
+ * `read`, `write` and `delete` reach the workspace through the agent's own
6
+ * `this.workspace`, which is `computerWorkspace` (`./proxy.ts`).
7
+ *
8
+ * Not Think's `edit`, because that one is two calls through the workspace — a
9
+ * `readFile`, then a `writeFile` — and a lock inside the workspace cannot span
10
+ * the pair. See `./file-lock.ts`.
11
+ */
12
+ export declare function fileTools(ctx: ComputerContext): ToolSet;
@@ -0,0 +1,64 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { guardPath } from "./paths.js";
4
+ import { withFileLock } from "./file-lock.js";
5
+ /**
6
+ * Editing a file in place — the one file tool this plugin keeps. Think's
7
+ * `read`, `write` and `delete` reach the workspace through the agent's own
8
+ * `this.workspace`, which is `computerWorkspace` (`./proxy.ts`).
9
+ *
10
+ * Not Think's `edit`, because that one is two calls through the workspace — a
11
+ * `readFile`, then a `writeFile` — and a lock inside the workspace cannot span
12
+ * the pair. See `./file-lock.ts`.
13
+ */
14
+ export function fileTools(ctx) {
15
+ const { inWorkspace, refuseWrite, scope } = ctx;
16
+ return {
17
+ edit: tool({
18
+ description: "Replace an exact string in a file. old_string must be non-empty and must appear exactly once — if it appears zero times or more than once the edit is refused, so include enough surrounding context to make it unique. To create a file, use write.",
19
+ inputSchema: z.object({
20
+ path: z.string().describe("Absolute path"),
21
+ old_string: z
22
+ .string()
23
+ // Empty is refused at the schema rather than in the body, because
24
+ // `split("")` does not count empty-string occurrences: on a file of
25
+ // one character it reports none, on a longer one it reports one per
26
+ // character, and on an *empty* file it reports -1 — which slips past
27
+ // both guards below and writes `new_string` into the file as if an
28
+ // edit had been found. A write tool must not write what nobody asked
29
+ // for.
30
+ .min(1)
31
+ .describe("Exact text to replace, unique within the file"),
32
+ new_string: z.string().describe("Replacement text")
33
+ }),
34
+ execute: async ({ path, old_string, new_string }) => {
35
+ const refusal = guardPath(path, "edit");
36
+ if (refusal)
37
+ return refusal;
38
+ const lost = await refuseWrite();
39
+ if (lost)
40
+ return lost;
41
+ return inWorkspace("editing", path, (fs) => withFileLock(scope(), path, async () => {
42
+ const content = await fs.readFile(path, "utf8");
43
+ const occurrences = content.split(old_string).length - 1;
44
+ // Refusing an ambiguous edit is the whole value of this tool over
45
+ // `write`: a silent first-match replace corrupts the file in a way
46
+ // that surfaces much later, usually as a confusing test failure.
47
+ if (occurrences === 0)
48
+ return `no match for that text in ${path}`;
49
+ if (occurrences > 1)
50
+ return `that text appears ${occurrences} times in ${path} — add surrounding context to make it unique`;
51
+ // A replacer function, not the string itself. `String.replace`
52
+ // interprets `$$`, `$&`, `` $` `` and `$'` in a *string* replacement
53
+ // even when the pattern is a plain string, so the text written is not
54
+ // the text the model sent: `echo $$` becomes `echo $`, and `$'` splices
55
+ // in everything before the match. Those two are not exotic — `$$`
56
+ // escapes a dollar in a Makefile and reads a PID in shell, and `$'…'`
57
+ // is bash ANSI-C quoting. A function's return value is used verbatim.
58
+ await fs.writeFile(path, content.replace(old_string, () => new_string));
59
+ return `edited ${path}`;
60
+ }));
61
+ }
62
+ })
63
+ };
64
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { ComputerContext } from "./context.js";
3
+ export declare function grepTools(ctx: ComputerContext): ToolSet;
@@ -0,0 +1,129 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { guardPath, WALK_SKIPS } from "./paths.js";
4
+ import { renderGrepMatches } from "./render.js";
5
+ /**
6
+ * Searching file contents, under Think's name so it replaces Think's `grep` —
7
+ * which lists every file under the root and reads each one through the
8
+ * workspace, one round trip per file. Think's `find` and `list` stay Think's:
9
+ * over `computerWorkspace` they walk with `.git` and `node_modules` pruned.
10
+ */
11
+ /**
12
+ * How many matches one `grep` returns.
13
+ *
14
+ * Bounded at the source: without a `limit`, `fs.grep` reads *every* file under
15
+ * the path looking for more, so an unbounded search of `/workspace` answers only
16
+ * after streaming the whole tree through the isolate. The limit stops that walk
17
+ * early; `exclude` keeps it out of `.git` and the dependency tree entirely.
18
+ */
19
+ const DEFAULT_MAX_MATCHES = 200;
20
+ /**
21
+ * The most `context` lines one `grep` shows around each match.
22
+ *
23
+ * Clamped rather than refused, since a refusal costs the model a step to retry.
24
+ * The bound is {@link file://./render.ts packBlocks}: it ships the first match's
25
+ * block whole, so a block has to stay well inside the output budget.
26
+ */
27
+ const MAX_CONTEXT_LINES = 10;
28
+ export function grepTools(ctx) {
29
+ const { cwd, maxChars, inWorkspace } = ctx;
30
+ return {
31
+ /**
32
+ * Search, without the container.
33
+ *
34
+ * The alternative is `bash("grep -rn …")`, and the case for a native one is
35
+ * not that shelling out fails — it is where the search runs. `fs.grep` reads
36
+ * the Durable Object's SQLite, so it answers while the container is being
37
+ * replaced or an install is still running, which is exactly the window the
38
+ * install gate leaves an agent with nothing to do. It is also why this tool
39
+ * is not gated: see {@link file://./context.ts awaitAdvisories}.
40
+ *
41
+ * And not Think's `grep`, which globs every file under the root and reads
42
+ * each one through the workspace.
43
+ *
44
+ * Two lesser reasons that still matter. The query arrives as a value rather
45
+ * than through `shellQuote` and a shell that would re-parse it. And the result
46
+ * is bounded by a `limit` at the source instead of being middle-truncated
47
+ * afterwards, which for a match list means losing whole files silently.
48
+ */
49
+ grep: tool({
50
+ description: "Search file contents across the workspace. Returns matching lines grouped by file, each with its line number. " +
51
+ "The query is matched literally — set `regex` to interpret it as a regular expression. " +
52
+ "Pass `include` to limit which files are searched, e.g. '**/*.ts' — without it every file under `path` is read, which is slower and rarely what you meant. " +
53
+ "A cut result reports the `offset` that continues it. Use `context` to see the lines around a match. " +
54
+ "Results from `.git` and `node_modules` are left out — search node_modules with bash.",
55
+ inputSchema: z.object({
56
+ query: z.string().describe("Text to find, e.g. 'buildComputerTools'"),
57
+ path: z
58
+ .string()
59
+ .optional()
60
+ .describe(`Absolute file or directory to search (default: ${cwd})`),
61
+ include: z
62
+ .string()
63
+ .optional()
64
+ .describe("Glob relative to path limiting which files are searched, e.g. '**/*.ts'"),
65
+ regex: z
66
+ .boolean()
67
+ .optional()
68
+ .describe("Interpret query as a regular expression"),
69
+ ignoreCase: z.boolean().optional().describe("Ignore letter case"),
70
+ context: z
71
+ .number()
72
+ .int()
73
+ .min(0)
74
+ .optional()
75
+ .describe(`Lines of surrounding context to include with each match, at most ${MAX_CONTEXT_LINES}`),
76
+ offset: z
77
+ .number()
78
+ .int()
79
+ .min(0)
80
+ .optional()
81
+ .describe("Matches to skip — use the offset a cut result reports")
82
+ }),
83
+ execute: async ({ query, path, include, regex, ignoreCase, context, offset }) => {
84
+ const target = path ?? cwd;
85
+ const refusal = guardPath(target, "grep");
86
+ if (refusal)
87
+ return refusal;
88
+ const from = offset ?? 0;
89
+ const around = context === undefined
90
+ ? undefined
91
+ : Math.min(context, MAX_CONTEXT_LINES);
92
+ return inWorkspace("searching", target, async (fs) => {
93
+ // Pruned in the store, not filtered here: an excluded directory is
94
+ // never descended into, so `node_modules` costs nothing to skip and
95
+ // `offset` counts only matches the model can actually be shown. A
96
+ // filter on this side could not do either — it pages blind, so a
97
+ // crowded page could hide every real match behind skipped ones.
98
+ const matches = await fs.grep(query, target, {
99
+ include,
100
+ regex,
101
+ ignoreCase,
102
+ context: around,
103
+ limit: DEFAULT_MAX_MATCHES + 1,
104
+ offset: from,
105
+ exclude: WALK_SKIPS.map((segment) => `**/${segment}`)
106
+ });
107
+ if (matches.length === 0)
108
+ return `no matches for ${JSON.stringify(query)} in ${target}${include ? ` (${include})` : ""}${from > 0 ? ` past offset ${from}` : ""}`;
109
+ const { body, shown, capped } = renderGrepMatches(matches.slice(0, DEFAULT_MAX_MATCHES), maxChars);
110
+ return (body +
111
+ // `shown` falls short of what came back either because the page was
112
+ // capped or because the render budget ran out; both continue here.
113
+ (shown < matches.length
114
+ ? `\n\n… showed ${shown} matches; there are more. Continue with ` +
115
+ `\`offset: ${from + shown}\`, or narrow with \`include\` — ` +
116
+ `narrowing is cheaper, since an offset still walks everything ` +
117
+ `it skips.`
118
+ : "") +
119
+ (capped
120
+ ? `\n(Some lines were shortened. Read one in full with \`read\`, passing its line number as \`offset\`.)`
121
+ : "") +
122
+ (around !== context
123
+ ? `\n(\`context\` is at most ${MAX_CONTEXT_LINES}, so ${context} was clamped to ${MAX_CONTEXT_LINES}. Read a wider region with \`read\`.)`
124
+ : ""));
125
+ });
126
+ }
127
+ })
128
+ };
129
+ }