@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.
@@ -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;
@@ -52,78 +52,31 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
52
52
  });
53
53
  import { withFileLock } from "./file-lock.js";
54
54
  import { writeGate } from "./gate.js";
55
- import { guardPath, WALK_SKIPS } from "./paths.js";
55
+ import { guardPath, walkSkips } from "./paths.js";
56
56
  /**
57
57
  * The key the route is carried under. `Symbol.for`, so a second copy of this
58
58
  * module in one bundle still recognises the first's workspaces.
59
59
  */
60
60
  const ROUTE = Symbol.for("@dynamicagents/plugins/computer:workspace");
61
- /**
62
- * The most one `glob` returns. Its caller is Think's `find`, which shows 200
63
- * and says when there were more — so one past that is all it can use, and
64
- * reading the rest of a large checkout into the object would buy nothing.
65
- */
66
- const GLOB_LIMIT = 201;
67
61
  /** The route of a workspace {@link routedWorkspace} built, or `undefined`. */
68
62
  export function workspaceRoute(workspace) {
69
63
  if (typeof workspace !== "object" || workspace === null)
70
64
  return undefined;
71
65
  return workspace[ROUTE];
72
66
  }
73
- /**
74
- * Missing, as the filesystem says it across the boundary. The code does not
75
- * always survive RPC, so the message is read too.
76
- */
77
- function isMissing(err) {
78
- if (!err || typeof err !== "object")
79
- return false;
80
- const { code, message } = err;
81
- if (code === "ENOENT")
82
- return true;
83
- return typeof message === "string" && /ENOENT|no such/i.test(message);
84
- }
85
- /** `null` for a path that is not there, which is what Think's tools expect. */
86
- async function orNull(read) {
87
- try {
88
- return await read;
89
- }
90
- catch (err) {
91
- if (isMissing(err))
92
- return null;
93
- throw err;
94
- }
95
- }
67
+ /** The bytes of a stream, for a host RPC that takes a value rather than one. */
96
68
  async function drain(stream) {
97
69
  return new Uint8Array(await new Response(stream).arrayBuffer());
98
70
  }
99
- function fileInfo(entry) {
100
- const type = entry.isDirectory ? "directory" : "file";
101
- return {
102
- path: entry.path,
103
- name: entry.path.slice(entry.path.lastIndexOf("/") + 1),
104
- type,
105
- // Generic on purpose: Think's `read` sniffs a generic type from the bytes,
106
- // and an extension is not evidence of what a file holds.
107
- mimeType: type === "directory" ? "inode/directory" : "application/octet-stream",
108
- size: entry.size ?? 0,
109
- createdAt: entry.mtime ?? 0,
110
- updatedAt: entry.mtime ?? 0
111
- };
112
- }
113
- /** The directory a glob walks from, and the pattern relative to it. */
114
- function splitGlob(pattern, cwd) {
115
- const absolute = pattern.startsWith("/") ? pattern : `${cwd}/${pattern}`;
116
- const wildcard = absolute.search(/[*?[{]/);
117
- const cut = wildcard === -1
118
- ? absolute.lastIndexOf("/")
119
- : absolute.lastIndexOf("/", wildcard);
120
- return {
121
- dir: cut <= 0 ? "/" : absolute.slice(0, cut),
122
- rest: absolute.slice(cut + 1)
123
- };
124
- }
125
71
  /** Think's file tools over `route`, carrying it for the computer tools to find. */
126
- export function routedWorkspace(route, cwd) {
72
+ /**
73
+ * `root` is the sandbox's served root and never a working directory. The
74
+ * directories Think writes its own files into are fixed under that root, so
75
+ * anchoring their exclusions anywhere else both hides a checkout's own
76
+ * `attachments/` and leaves Think's in a search of the root: see
77
+ * {@link file://./paths.ts walkSkips}.
78
+ */
79
+ export function routedWorkspace(route, root) {
127
80
  /** One call, against the workspace this turn names. */
128
81
  const withFs = async (path, verb, body) => {
129
82
  const env_1 = { stack: [], error: void 0, hasError: false };
@@ -158,42 +111,79 @@ export function routedWorkspace(route, cwd) {
158
111
  throw new Error(lost);
159
112
  return body(fs, name);
160
113
  });
161
- const workspace = {
162
- readFile: (path) =>
163
- // Whole, never truncated: Think's `edit` writes back what it read.
164
- withFs(path, "read", (fs) => orNull(fs.readFile(path, "utf8"))),
165
- readFileBytes: (path) => withFs(path, "read", async (fs) => {
166
- const stream = await orNull(fs.readFile(path));
167
- return stream === null ? null : drain(stream);
168
- }),
169
- stat: (path) => withFs(path, "read", async (fs) => {
170
- const stat = await orNull(fs.stat(path));
171
- return stat === null ? null : fileInfo({ ...stat, path });
172
- }),
173
- readDir: (dir = cwd, opts) => withFs(dir, "list", async (fs) => (await fs.readdir(dir, opts)).map((entry) => fileInfo({
114
+ /**
115
+ * Read whole or ranged, and as text or as bytes, in the one overloaded
116
+ * method Computer's filesystem declares. The options go through untouched:
117
+ * an `edit` reads text and writes back what it read, and a `read` of an
118
+ * evicted image asks for the bytes.
119
+ *
120
+ * **A host's `readFile` has to materialise what it answers**, because the
121
+ * client is released when this call returns and a stream that outlived it
122
+ * would be read against a disposed one. `/sandbox`'s does: it awaits the
123
+ * bytes and hands back a stream over them.
124
+ */
125
+ const readFile = (path, options) => withFs(path, "read", (fs) => options === undefined
126
+ ? fs.readFile(path)
127
+ : fs.readFile(path, options));
128
+ const fs = {
129
+ readFile: readFile,
130
+ stat: (path) => withFs(path, "read", async (fs) => ({
131
+ ...(await fs.stat(path)),
132
+ // The container has one, and the sandbox does not report it. Nothing
133
+ // downstream reads it: Computer's tools take `size`, `mtime`, `mode`
134
+ // and the three kind flags, and a number is all the type needs.
135
+ inode: 0
136
+ })),
137
+ readdir: (path, options) => withFs(path, "list", async (fs) => (await fs.readdir(path, options)).map((entry) => ({
174
138
  ...entry,
175
- path: dir === "/" ? `/${entry.name}` : `${dir}/${entry.name}`
139
+ // The directory this listing was read from, which is what a caller
140
+ // joins a name onto. The sandbox lists names and kinds, so the size
141
+ // and the time are the entry's own where it has them and zero where
142
+ // it does not: `ls` shows both to the model, and a second round trip
143
+ // per entry to fill them in would cost a directory's worth of RPCs.
144
+ parentPath: path,
145
+ size: entry.size ?? 0,
146
+ mtime: entry.mtime ?? 0,
147
+ isSymbolicLink: entry.isSymbolicLink ?? false
176
148
  }))),
177
149
  // Pruned rather than filtered: at a repository root either skipped
178
- // directory outnumbers everything else.
179
- glob: (pattern) => {
180
- const { dir, rest } = splitGlob(pattern, cwd);
181
- return withFs(dir, "find", async (fs) => (await fs.find(dir, rest, {
182
- limit: GLOB_LIMIT,
183
- exclude: WALK_SKIPS.map((segment) => `**/${segment}`)
184
- })).map((entry) => fileInfo({ path: entry.path, isDirectory: entry.type === "dir" })));
185
- },
186
- // Locked per file, so a write never lands inside the `edit` tool's read and
187
- // write of the same file — see `./file-lock.ts`.
188
- writeFile: (path, content) => withWritableFs(path, "write", (fs, name) => withFileLock(name, path, () => fs.writeFile(path, content))),
189
- mkdir: (path, opts) => withWritableFs(path, "write", (fs) => fs.mkdir(path, opts)),
150
+ // directory outnumbers everything else. No limit of this layer's own —
151
+ // `./sandbox.ts` caps a walk that arrives without one.
152
+ find: (directory, pattern, options) => withFs(directory, "find", (fs) => fs.find(directory, pattern, {
153
+ ...options,
154
+ exclude: [...walkSkips(directory, root), ...(options?.exclude ?? [])]
155
+ })),
156
+ grep: (pattern, path, options) => withFs(path, "search", (fs) => fs.grep(pattern, path, {
157
+ ...options,
158
+ exclude: [
159
+ ...walkSkips(path, root),
160
+ ...(options?.exclude ?? [])
161
+ ]
162
+ })),
163
+ /**
164
+ * Locked per file, so a write never lands inside the `edit` tool's read and
165
+ * write of the same file — see `./file-lock.ts`.
166
+ *
167
+ * A stream is drained here because the sandbox RPC takes a value. The
168
+ * `mode` a caller may send is dropped: the sandbox's write has nowhere to
169
+ * put one, so a mode this cannot set is not one it can claim to preserve.
170
+ */
171
+ writeFile: (path, content) => withWritableFs(path, "write", async (fs, name) => {
172
+ const body = content instanceof ReadableStream ? await drain(content) : content;
173
+ return withFileLock(name, path, () => fs.writeFile(path, body));
174
+ }),
175
+ mkdir: (path, options) => withWritableFs(path, "write", (fs) => fs.mkdir(path, options)),
190
176
  // Locked too: a delete landing between an edit's read and its write would
191
177
  // be undone by the write, and both would report success.
192
- rm: (path, opts) => withWritableFs(path, "delete", (fs, name) => withFileLock(name, path, () => fs.rm(path, opts)))
193
- // No `writeFileBytes`. Think writes through it on its own account —
194
- // evicted media, projected skills — and none of that belongs in a
195
- // repository's checkout. Without it Think skips both, with a warning.
178
+ rm: (path, options) => withWritableFs(path, "delete", (fs, name) => withFileLock(name, path, () => fs.rm(path, options)))
196
179
  };
180
+ /**
181
+ * `fs` alone. Computer's exec tool is offered only for a workspace carrying a
182
+ * `runtime`, so withholding one is how this keeps Think from building a
183
+ * second shell over the same container — `./tools-exec.ts` is the one the
184
+ * model gets, and it is this package's.
185
+ */
186
+ const workspace = { fs };
197
187
  Object.defineProperty(workspace, ROUTE, { value: route });
198
188
  return workspace;
199
189
  }
@@ -1,13 +1,13 @@
1
- import type { CommandResult, GrepMatch } from "./client.js";
1
+ import type { GrepMatch } from "./client.js";
2
2
  /**
3
3
  * Emit whole items until the byte budget is spent, and report exactly how many.
4
4
  *
5
- * {@link truncateOutput} is wrong for every list this module produces, and is
5
+ * Core's `truncateOutput` is wrong for every list this module produces, and is
6
6
  * deliberately not used by them. It keeps both ends and drops the middle, which is
7
- * right for a build log — first error, final summary — and destructive for a match
8
- * list or a directory listing, where the middle is a whole file's worth of hits or a
9
- * whole subtree that silently vanishes. So the budget is spent item by item, in
10
- * order, and the caller learns where it stopped.
7
+ * right for a build log and destructive for a match list or a directory listing,
8
+ * where the middle is a whole file's worth of hits or a whole subtree that silently
9
+ * vanishes. So the budget is spent item by item, in order, and the caller learns
10
+ * where it stopped.
11
11
  *
12
12
  * `shown` is the load-bearing return value: it is what makes the next page's offset
13
13
  * exact rather than a guess. A block is committed whole or not at all, because half
@@ -44,32 +44,3 @@ export declare function renderGrepMatches(matches: GrepMatch[], maxChars: number
44
44
  shown: number;
45
45
  capped: boolean;
46
46
  };
47
- /**
48
- * Render a completed command the way a terminal would, minus the noise.
49
- *
50
- * ## Every command ends with its verdict, including the successful ones
51
- *
52
- * Printing the exit code only when it is non-zero looks like saving noise and is
53
- * not: a model with no verdict appends `; echo "EXIT_CODE=$?"` itself, and
54
- * reaches for `${PIPESTATUS[0]}` to read a piped one — which dash does not
55
- * implement, so the whole line fails after the command it wrapped has run.
56
- *
57
- * ## `status` is not the exit code
58
- *
59
- * A `status` is `completed | failed | cancelled`. Dropping it
60
- * renders a command killed at the `timeoutMs` ceiling exactly like one that ran
61
- * to completion and failed — "your test suite was killed at ten minutes" and
62
- * "your test suite has a failing test", which want opposite responses. Anything
63
- * other than `completed` is stated.
64
- *
65
- * ## One budget, applied once
66
- *
67
- * Truncating per stream makes `maxOutputChars` mean "up to twice this". The bound
68
- * is on the rendered transcript, which is what reaches the context window.
69
- */
70
- export declare function renderResult(result: {
71
- exitCode: number;
72
- stdout: string;
73
- stderr: string;
74
- status?: CommandResult["status"];
75
- }, maxChars: number): string;
@@ -1,4 +1,3 @@
1
- import { truncateOutput } from "@dynamicagents/core";
2
1
  /**
3
2
  * Turning a result into the text a model reads.
4
3
  *
@@ -31,12 +30,12 @@ function capLine(text) {
31
30
  /**
32
31
  * Emit whole items until the byte budget is spent, and report exactly how many.
33
32
  *
34
- * {@link truncateOutput} is wrong for every list this module produces, and is
33
+ * Core's `truncateOutput` is wrong for every list this module produces, and is
35
34
  * deliberately not used by them. It keeps both ends and drops the middle, which is
36
- * right for a build log — first error, final summary — and destructive for a match
37
- * list or a directory listing, where the middle is a whole file's worth of hits or a
38
- * whole subtree that silently vanishes. So the budget is spent item by item, in
39
- * order, and the caller learns where it stopped.
35
+ * right for a build log and destructive for a match list or a directory listing,
36
+ * where the middle is a whole file's worth of hits or a whole subtree that silently
37
+ * vanishes. So the budget is spent item by item, in order, and the caller learns
38
+ * where it stopped.
40
39
  *
41
40
  * `shown` is the load-bearing return value: it is what makes the next page's offset
42
41
  * exact rather than a guess. A block is committed whole or not at all, because half
@@ -102,40 +101,3 @@ export function renderGrepMatches(matches, maxChars) {
102
101
  // Only a line that actually made it out is worth explaining.
103
102
  return { body, shown, capped: hasLongLine.slice(0, shown).includes(true) };
104
103
  }
105
- /**
106
- * Render a completed command the way a terminal would, minus the noise.
107
- *
108
- * ## Every command ends with its verdict, including the successful ones
109
- *
110
- * Printing the exit code only when it is non-zero looks like saving noise and is
111
- * not: a model with no verdict appends `; echo "EXIT_CODE=$?"` itself, and
112
- * reaches for `${PIPESTATUS[0]}` to read a piped one — which dash does not
113
- * implement, so the whole line fails after the command it wrapped has run.
114
- *
115
- * ## `status` is not the exit code
116
- *
117
- * A `status` is `completed | failed | cancelled`. Dropping it
118
- * renders a command killed at the `timeoutMs` ceiling exactly like one that ran
119
- * to completion and failed — "your test suite was killed at ten minutes" and
120
- * "your test suite has a failing test", which want opposite responses. Anything
121
- * other than `completed` is stated.
122
- *
123
- * ## One budget, applied once
124
- *
125
- * Truncating per stream makes `maxOutputChars` mean "up to twice this". The bound
126
- * is on the rendered transcript, which is what reaches the context window.
127
- */
128
- export function renderResult(result, maxChars) {
129
- const parts = [];
130
- if (result.stdout)
131
- parts.push(result.stdout);
132
- // Empty whenever the host configured a `shell`, which merges the streams at the
133
- // source so the transcript keeps its causal order. Kept for hosts that did not:
134
- // two labelled blocks beat silently dropping half the output.
135
- if (result.stderr)
136
- parts.push(`--- stderr ---\n${result.stderr}`);
137
- const body = truncateOutput(parts.join("\n"), maxChars);
138
- const state = result.status && result.status !== "completed" ? ` (${result.status})` : "";
139
- const verdict = `--- exit ${result.exitCode}${state} ---`;
140
- return body ? `${body}\n${verdict}` : `(no output)\n${verdict}`;
141
- }
@@ -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
  /**
@@ -27,11 +27,12 @@ export interface SandboxComputerHost extends Rpc.DurableObjectBranded {
27
27
  stderr: string;
28
28
  }>;
29
29
  readFile(path: string): Promise<Uint8Array | null>;
30
- writeFile(path: string, content: string): Promise<void>;
30
+ writeFile(path: string, content: string | Uint8Array): Promise<void>;
31
31
  stat(path: string): Promise<{
32
32
  type: string;
33
33
  size: number;
34
34
  modifiedAt: number;
35
+ mode: number;
35
36
  } | null>;
36
37
  readDir(path: string): Promise<{
37
38
  name: string;
@@ -50,6 +51,6 @@ export interface SandboxComputerConfig {
50
51
  sandboxName: () => string;
51
52
  cwd?: string;
52
53
  }
53
- export declare function computerWorkspace(config: SandboxComputerConfig, runtime?: () => unknown): WorkspaceLike;
54
+ export declare function computerWorkspace(config: SandboxComputerConfig, runtime?: () => unknown): ThinkWorkspace;
54
55
  /** The computer tools' client over one sandbox. */
55
56
  export declare function sandboxClient(host: SandboxComputerHost): ComputerClient;
@@ -1,4 +1,5 @@
1
1
  import { RUN_DIR } from "../sandbox/constants.js";
2
+ import { SANDBOX_ROOT } from "../sandbox/header.js";
2
3
  import { sandboxNameFromRuntime } from "../sandbox/name.js";
3
4
  import { routedWorkspace } from "./proxy.js";
4
5
  /** After `timeout`'s TERM, how long before its KILL. */
@@ -39,7 +40,10 @@ export function computerWorkspace(config, runtime) {
39
40
  name: () => sandboxNameFromRuntime(runtime?.()) ?? config.sandboxName(),
40
41
  open: async (name) => sandboxClient(host(name)),
41
42
  advisories: (name) => host(name).advisories()
42
- }, config.cwd ?? "/workspace");
43
+ },
44
+ // The served root, not `config.cwd`: a host may point the tools at a
45
+ // checkout, and Think's own directories stay where Think puts them.
46
+ SANDBOX_ROOT);
43
47
  }
44
48
  function missing(path) {
45
49
  return Object.assign(new Error(`ENOENT: no such file or directory, ${path}`), { code: "ENOENT" });
@@ -61,8 +65,10 @@ export function sandboxClient(host) {
61
65
  if (!stat)
62
66
  throw missing(path);
63
67
  return {
68
+ name: path.slice(path.lastIndexOf("/") + 1),
64
69
  size: stat.size,
65
70
  mtime: stat.modifiedAt,
71
+ mode: stat.mode,
66
72
  isFile: stat.type === "file",
67
73
  isDirectory: stat.type === "directory",
68
74
  isSymbolicLink: stat.type === "symlink"
@@ -75,7 +81,8 @@ export function sandboxClient(host) {
75
81
  .map((entry) => ({
76
82
  name: entry.name,
77
83
  isFile: entry.type === "file",
78
- isDirectory: entry.type === "directory"
84
+ isDirectory: entry.type === "directory",
85
+ isSymbolicLink: entry.type === "symlink"
79
86
  }));
80
87
  },
81
88
  find: (directory, pattern, options = {}) => find(host, directory, pattern, options),
@@ -3,7 +3,8 @@
3
3
  * two output streams merged into one transcript, in the order they were
4
4
  * written, or hand it back untouched when no shell is configured.
5
5
  *
6
- * `bash`'s consumer is **a model reading output**. A project's check is a chain
6
+ * The shell tool's consumer is **a model reading output**. A project's check is a
7
+ * chain
7
8
  * — `wrangler types && prettier && eslint && tsc` — and *which tool spoke last*
8
9
  * is how you know which one failed. A stdout block and a separate stderr block
9
10
  * destroy that ordering, and a model that wants it back re-runs the whole gate
@@ -25,12 +26,12 @@
25
26
  *
26
27
  * The trade is worth stating. `pipefail` surfaces SIGPIPE too, so
27
28
  * `ls big-dir | head -1` reports 141 rather than 0 — noisy, but *visible*. The
28
- * tool description tells the model not to pipe into `head`/`tail` at all, since
29
- * output is already truncated with both ends kept. It needs a shell that
30
- * implements it — `bash`, `zsh`, `ksh`, not `sh`/dash.
29
+ * tool's description is Computer's and says nothing about either, so the
30
+ * plugin's own context block in {@link file://./index.ts} is where the model is
31
+ * told not to pipe into `head`/`tail`. It needs a shell that implements it —
32
+ * `bash`, `zsh`, `ksh`, not `sh`/dash.
31
33
  *
32
- * With no shell configured the two streams arrive separate, and
33
- * {@link file://./render.ts renderResult} renders them as a labelled
34
- * `--- stderr ---` block.
34
+ * With no shell configured the two streams arrive separate, and the tool reports
35
+ * them as the `stdout` and `stderr` of its result.
35
36
  */
36
37
  export declare function withShellTranscript(command: string, shell: string | undefined): string;
@@ -4,7 +4,8 @@ import { shellQuote } from "../sandbox/agent-exec.js";
4
4
  * two output streams merged into one transcript, in the order they were
5
5
  * written, or hand it back untouched when no shell is configured.
6
6
  *
7
- * `bash`'s consumer is **a model reading output**. A project's check is a chain
7
+ * The shell tool's consumer is **a model reading output**. A project's check is a
8
+ * chain
8
9
  * — `wrangler types && prettier && eslint && tsc` — and *which tool spoke last*
9
10
  * is how you know which one failed. A stdout block and a separate stderr block
10
11
  * destroy that ordering, and a model that wants it back re-runs the whole gate
@@ -26,13 +27,13 @@ import { shellQuote } from "../sandbox/agent-exec.js";
26
27
  *
27
28
  * The trade is worth stating. `pipefail` surfaces SIGPIPE too, so
28
29
  * `ls big-dir | head -1` reports 141 rather than 0 — noisy, but *visible*. The
29
- * tool description tells the model not to pipe into `head`/`tail` at all, since
30
- * output is already truncated with both ends kept. It needs a shell that
31
- * implements it — `bash`, `zsh`, `ksh`, not `sh`/dash.
30
+ * tool's description is Computer's and says nothing about either, so the
31
+ * plugin's own context block in {@link file://./index.ts} is where the model is
32
+ * told not to pipe into `head`/`tail`. It needs a shell that implements it —
33
+ * `bash`, `zsh`, `ksh`, not `sh`/dash.
32
34
  *
33
- * With no shell configured the two streams arrive separate, and
34
- * {@link file://./render.ts renderResult} renders them as a labelled
35
- * `--- stderr ---` block.
35
+ * With no shell configured the two streams arrive separate, and the tool reports
36
+ * them as the `stdout` and `stderr` of its result.
36
37
  */
37
38
  export function withShellTranscript(command, shell) {
38
39
  return shell