@dynamicagents/plugins 0.11.0 → 0.12.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 (95) hide show
  1. package/README.md +12 -13
  2. package/dist/arc-agi/analysis.d.ts +16 -30
  3. package/dist/arc-agi/analysis.js +20 -47
  4. package/dist/arc-agi/client.d.ts +3 -2
  5. package/dist/arc-agi/game-tools.d.ts +5 -6
  6. package/dist/arc-agi/index.d.ts +6 -8
  7. package/dist/arc-agi/index.js +3 -3
  8. package/dist/arc-agi/main-agent.d.ts +5 -8
  9. package/dist/arc-agi/main-agent.js +5 -8
  10. package/dist/arc-agi/recipe.d.ts +17 -20
  11. package/dist/arc-agi/recipe.js +17 -20
  12. package/dist/arc-agi/soul.d.ts +16 -27
  13. package/dist/arc-agi/soul.js +16 -27
  14. package/dist/arc-agi/store.d.ts +4 -5
  15. package/dist/arc-agi/store.js +4 -5
  16. package/dist/arc-agi/tools.d.ts +1 -1
  17. package/dist/arc-agi/tools.js +26 -19
  18. package/dist/arc-agi/types.d.ts +6 -7
  19. package/dist/claude-code/config.d.ts +88 -11
  20. package/dist/claude-code/config.js +11 -2
  21. package/dist/claude-code/credentials.d.ts +21 -9
  22. package/dist/claude-code/credentials.js +43 -9
  23. package/dist/claude-code/egress.d.ts +7 -13
  24. package/dist/claude-code/egress.js +9 -10
  25. package/dist/claude-code/events.d.ts +47 -0
  26. package/dist/claude-code/events.js +96 -10
  27. package/dist/claude-code/index.d.ts +31 -13
  28. package/dist/claude-code/index.js +62 -15
  29. package/dist/claude-code/run.d.ts +135 -41
  30. package/dist/claude-code/run.js +366 -61
  31. package/dist/computer/advisory.d.ts +31 -18
  32. package/dist/computer/advisory.js +30 -5
  33. package/dist/computer/context.d.ts +23 -0
  34. package/dist/computer/context.js +225 -0
  35. package/dist/computer/file-lock.d.ts +5 -0
  36. package/dist/computer/file-lock.js +39 -0
  37. package/dist/computer/gate.d.ts +8 -8
  38. package/dist/computer/gate.js +7 -7
  39. package/dist/computer/host/ca-trust.d.ts +88 -0
  40. package/dist/computer/host/ca-trust.js +303 -0
  41. package/dist/computer/host/container-deps.d.ts +54 -0
  42. package/dist/computer/host/container-deps.js +224 -0
  43. package/dist/computer/host/container-fault.d.ts +38 -0
  44. package/dist/computer/host/container-fault.js +58 -0
  45. package/dist/computer/host/git-host.d.ts +62 -0
  46. package/dist/computer/host/git-host.js +156 -0
  47. package/dist/computer/host/git-identity.d.ts +57 -0
  48. package/dist/computer/host/git-identity.js +147 -0
  49. package/dist/computer/host/install-job.d.ts +121 -0
  50. package/dist/computer/host/install-job.js +766 -0
  51. package/dist/computer/host/sync.d.ts +111 -0
  52. package/dist/computer/host/sync.js +124 -0
  53. package/dist/computer/host/wake.d.ts +19 -0
  54. package/dist/computer/host/wake.js +10 -0
  55. package/dist/computer/host/workspace.d.ts +424 -0
  56. package/dist/computer/host/workspace.js +1258 -0
  57. package/dist/computer/index.d.ts +70 -50
  58. package/dist/computer/index.js +79 -767
  59. package/dist/computer/install.d.ts +8 -12
  60. package/dist/computer/install.js +6 -9
  61. package/dist/computer/paths.d.ts +21 -16
  62. package/dist/computer/paths.js +40 -38
  63. package/dist/computer/read.d.ts +18 -22
  64. package/dist/computer/read.js +24 -27
  65. package/dist/computer/render.d.ts +28 -0
  66. package/dist/computer/render.js +34 -1
  67. package/dist/computer/shell.d.ts +45 -0
  68. package/dist/computer/shell.js +80 -0
  69. package/dist/computer/tools-exec.d.ts +3 -0
  70. package/dist/computer/tools-exec.js +269 -0
  71. package/dist/computer/tools-file.d.ts +4 -0
  72. package/dist/computer/tools-file.js +124 -0
  73. package/dist/computer/tools-find.d.ts +3 -0
  74. package/dist/computer/tools-find.js +216 -0
  75. package/dist/recall/index.d.ts +9 -3
  76. package/dist/recall/index.js +22 -12
  77. package/dist/repo/checkout.d.ts +34 -4
  78. package/dist/repo/checkout.js +36 -1
  79. package/dist/repo/context.d.ts +109 -0
  80. package/dist/repo/context.js +530 -0
  81. package/dist/repo/index.d.ts +6 -29
  82. package/dist/repo/index.js +17 -1027
  83. package/dist/repo/tools-clone.d.ts +4 -0
  84. package/dist/repo/tools-clone.js +192 -0
  85. package/dist/repo/tools-forge.d.ts +4 -0
  86. package/dist/repo/tools-forge.js +179 -0
  87. package/dist/repo/tools-review.d.ts +3 -0
  88. package/dist/repo/tools-review.js +373 -0
  89. package/dist/repo/tools-worktree.d.ts +4 -0
  90. package/dist/repo/tools-worktree.js +221 -0
  91. package/dist/triage/index.d.ts +6 -1
  92. package/dist/triage/index.js +32 -23
  93. package/dist/workspace/index.d.ts +3 -3
  94. package/dist/workspace/index.js +3 -3
  95. package/package.json +21 -15
@@ -52,9 +52,8 @@ export interface InstallProbe {
52
52
  * `npm ci`.
53
53
  *
54
54
  * `JobState` intersects `TExtra` into its `running`/`done`/`failed` variants
55
- * instead of nesting it under `meta`, so this alias is byte-identical to the
56
- * hand-written union it replaced. Every reader still spells `status.command` at
57
- * the top level and no call site moved.
55
+ * rather than nesting it under `meta`, so a reader spells `status.command` at
56
+ * the top level.
58
57
  */
59
58
  export type InstallState = JobState<{
60
59
  command: string;
@@ -137,8 +136,7 @@ export declare function resolveInstallCommand(fs: InstallProbe, dir: string, pla
137
136
  *
138
137
  * Content, not mtime: a `git fetch && reset --hard` onto a new commit rewrites
139
138
  * these files whether or not their dependencies changed, and re-installing on
140
- * every commit would throw away the thing that makes a warm container worth
141
- * having.
139
+ * every commit would throw away a tree that is already correct.
142
140
  *
143
141
  * ## Both files, not just the lockfile
144
142
  *
@@ -171,8 +169,8 @@ export declare function resolveInstallCommand(fs: InstallProbe, dir: string, pla
171
169
  * {@link INSTALL_CONFIG_FILES} is hashed too, for the same reason `package.json`
172
170
  * is: a lockfile says which versions, and `.npmrc` says how they are laid out.
173
171
  * Flipping pnpm's `node-linker` to `hoisted` produces a different
174
- * `node_modules` from a byte-identical lockfile, and a warm container would
175
- * otherwise skip the reinstall that change requires.
172
+ * `node_modules` from a byte-identical lockfile, and a matching fingerprint
173
+ * would otherwise skip the reinstall that change requires.
176
174
  *
177
175
  * The residual is worth stating because it cannot be closed here: an override
178
176
  * that *builds* — `npm ci && npm run build`, the documented example — is not
@@ -184,10 +182,8 @@ export declare function resolveInstallCommand(fs: InstallProbe, dir: string, pla
184
182
  * ## This is only half of the skip condition
185
183
  *
186
184
  * A matching fingerprint means "the same install would produce the same tree".
187
- * It does **not** mean the tree is there. `node_modules` lives in the container
188
- * and dies with it, while this fingerprint is stored in the Durable Object and
189
- * does not — so on a cold container the two disagree, and a caller that skips on
190
- * the fingerprint alone skips the install that the empty tree needs most. The
191
- * caller must also confirm `node_modules` is actually present.
185
+ * It does **not** mean the tree is there: it is on the container's disk, and
186
+ * goes with the container. The caller also needs a record that this container
187
+ * finished it — see `./host/install-job.ts`.
192
188
  */
193
189
  export declare function installFingerprint(fs: InstallProbe, dir: string, resolution: InstallResolution): Promise<string | null>;
@@ -211,8 +211,7 @@ async function firstPresent(fs, dir, names) {
211
211
  *
212
212
  * Content, not mtime: a `git fetch && reset --hard` onto a new commit rewrites
213
213
  * these files whether or not their dependencies changed, and re-installing on
214
- * every commit would throw away the thing that makes a warm container worth
215
- * having.
214
+ * every commit would throw away a tree that is already correct.
216
215
  *
217
216
  * ## Both files, not just the lockfile
218
217
  *
@@ -245,8 +244,8 @@ async function firstPresent(fs, dir, names) {
245
244
  * {@link INSTALL_CONFIG_FILES} is hashed too, for the same reason `package.json`
246
245
  * is: a lockfile says which versions, and `.npmrc` says how they are laid out.
247
246
  * Flipping pnpm's `node-linker` to `hoisted` produces a different
248
- * `node_modules` from a byte-identical lockfile, and a warm container would
249
- * otherwise skip the reinstall that change requires.
247
+ * `node_modules` from a byte-identical lockfile, and a matching fingerprint
248
+ * would otherwise skip the reinstall that change requires.
250
249
  *
251
250
  * The residual is worth stating because it cannot be closed here: an override
252
251
  * that *builds* — `npm ci && npm run build`, the documented example — is not
@@ -258,11 +257,9 @@ async function firstPresent(fs, dir, names) {
258
257
  * ## This is only half of the skip condition
259
258
  *
260
259
  * A matching fingerprint means "the same install would produce the same tree".
261
- * It does **not** mean the tree is there. `node_modules` lives in the container
262
- * and dies with it, while this fingerprint is stored in the Durable Object and
263
- * does not — so on a cold container the two disagree, and a caller that skips on
264
- * the fingerprint alone skips the install that the empty tree needs most. The
265
- * caller must also confirm `node_modules` is actually present.
260
+ * It does **not** mean the tree is there: it is on the container's disk, and
261
+ * goes with the container. The caller also needs a record that this container
262
+ * finished it — see `./host/install-job.ts`.
266
263
  */
267
264
  export async function installFingerprint(fs, dir, resolution) {
268
265
  if (resolution.kind !== "run")
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * Which paths these tools act on, and the sentence a refused one gets.
3
3
  *
4
- * Two lists that look alike and mean opposite things. `node_modules` is *absent*
5
- * — physics, decided by `computerd` — so its note explains a file that is really
6
- * there and names the tool that can reach it. `.git` is present, readable, and
7
- * refused anyway — policy, decided here — so its note names where repository work
8
- * belongs. Two reasons want two sentences, which is why they are not one list.
4
+ * Both refused directories are also skipped by every walk. `.git` is refused as
5
+ * policy: a model that edits `.git/HEAD` corrupts a checkout in a way that
6
+ * surfaces much later. `node_modules` is refused as a fact: it lives on the
7
+ * container's disk (see `./host/container-deps.ts`), so these tools, which read
8
+ * the workspace, would find it empty.
9
9
  *
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
- export declare function isContainerOnly(path: string): boolean;
14
13
  /**
15
14
  * Is this path inside git's internal state?
16
15
  *
@@ -18,27 +17,33 @@ export declare function isContainerOnly(path: string): boolean;
18
17
  * checkout in a way that surfaces much later as an inexplicable git failure.
19
18
  *
20
19
  * Exact segment rather than substring, so `.gitignore`, `.gitattributes` and
21
- * `.github/` are untouched — the same trap `node_modules_old` sets one function up.
20
+ * `.github/` are untouched.
22
21
  */
23
22
  export declare function isGitInternal(path: string): boolean;
23
+ /**
24
+ * What every walk steps over. `find` prunes these in the store; `grep` takes no
25
+ * exclusion, so `sb_grep` filters its results instead.
26
+ */
27
+ export declare const WALK_SKIPS: readonly [".git", "node_modules"];
28
+ /** Whether a walk carrying `skips` steps over `path`. */
29
+ export declare function isSkipped(skips: readonly string[], path: string): boolean;
30
+ /** The skipped directories, for a sentence: "`.git` and `node_modules`". */
31
+ export declare function skipNames(skips: readonly string[]): string;
24
32
  /**
25
33
  * The path check every file tool makes, before it opens anything.
26
34
  *
27
- * Returns the sentence to hand back, or `undefined` to proceed. Two string
28
- * comparisons — no `stat`, no round trip — which is why all six tools can afford
29
- * to call it before the workspace is opened.
35
+ * Returns the sentence to hand back, or `undefined` to proceed. One string
36
+ * comparison — no `stat`, no round trip — which is why every file tool can
37
+ * afford to call it before the workspace is opened.
30
38
  *
31
39
  * ## A redirect, not a boundary
32
40
  *
33
- * `node_modules` is a fact about the substrate and the note names the tool that
34
- * can see it; `.git` is a rule about where repository work belongs and the note
35
- * names the tools it belongs to. Both catch the case that actually happens — a
36
- * model reaching into `.git/HEAD` to fix a merge, or reading a dependency's
37
- * source and finding nothing there.
41
+ * The note names the tools repository work belongs to, because the case that
42
+ * actually happens is a model reaching into `.git/HEAD` to fix a merge.
38
43
  *
39
44
  * It is not containment, and this is the only place worth saying so. `sb_exec`
40
45
  * is in the same tool family, granted per family rather than per tool, so every
41
- * agent holding these six also holds a shell that reads and writes `.git`
46
+ * agent holding these file tools also holds a shell that reads and writes `.git`
42
47
  * directly.
43
48
  *
44
49
  * Symlinks are not resolved, for the same reason: a tracked
@@ -1,35 +1,18 @@
1
1
  /**
2
2
  * Which paths these tools act on, and the sentence a refused one gets.
3
3
  *
4
- * Two lists that look alike and mean opposite things. `node_modules` is *absent*
5
- * — physics, decided by `computerd` — so its note explains a file that is really
6
- * there and names the tool that can reach it. `.git` is present, readable, and
7
- * refused anyway — policy, decided here — so its note names where repository work
8
- * belongs. Two reasons want two sentences, which is why they are not one list.
4
+ * Both refused directories are also skipped by every walk. `.git` is refused as
5
+ * policy: a model that edits `.git/HEAD` corrupts a checkout in a way that
6
+ * surfaces much later. `node_modules` is refused as a fact: it lives on the
7
+ * container's disk (see `./host/container-deps.ts`), so these tools, which read
8
+ * the workspace, would find it empty.
9
9
  *
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
- /**
14
- * Whether a path sits under a directory the workspace never receives.
15
- *
16
- * Only `node_modules` today, and it is not configurable here because it is not
17
- * our choice: `computerd` applies its own `DEFAULT_IGNORE` container-side, and
18
- * this list exists to *predict* that so a read can explain itself rather than
19
- * reporting a missing file. A dependency the model can plainly see in a `ls` but
20
- * cannot read is the kind of thing that sends it hunting for the wrong bug.
21
- */
22
- const CONTAINER_ONLY_SEGMENTS = ["node_modules"];
23
- export function isContainerOnly(path) {
24
- return path
25
- .split("/")
26
- .some((segment) => CONTAINER_ONLY_SEGMENTS.includes(segment));
27
- }
28
- /** The sentence a container-only path gets instead of "not found". */
29
- function containerOnlyNote(path, verb) {
30
- return (`${path} is inside node_modules, which lives only in the container and is ` +
31
- `not part of the durable workspace — ${verb} cannot see it. Use \`sb_exec\` ` +
32
- `(for example \`cat ${path}\`) to read it through the shell instead.`);
13
+ /** Whether any segment of `path` is exactly `segment`. */
14
+ function hasSegment(path, segment) {
15
+ return path.split("/").includes(segment);
33
16
  }
34
17
  /**
35
18
  * Is this path inside git's internal state?
@@ -38,10 +21,32 @@ function containerOnlyNote(path, verb) {
38
21
  * checkout in a way that surfaces much later as an inexplicable git failure.
39
22
  *
40
23
  * Exact segment rather than substring, so `.gitignore`, `.gitattributes` and
41
- * `.github/` are untouched — the same trap `node_modules_old` sets one function up.
24
+ * `.github/` are untouched.
42
25
  */
43
26
  export function isGitInternal(path) {
44
- return path.split("/").includes(".git");
27
+ return hasSegment(path, ".git");
28
+ }
29
+ /**
30
+ * What every walk steps over. `find` prunes these in the store; `grep` takes no
31
+ * exclusion, so `sb_grep` filters its results instead.
32
+ */
33
+ export const WALK_SKIPS = [".git", "node_modules"];
34
+ /** Whether a walk carrying `skips` steps over `path`. */
35
+ export function isSkipped(skips, path) {
36
+ return skips.some((segment) => hasSegment(path, segment));
37
+ }
38
+ /** The skipped directories, for a sentence: "`.git` and `node_modules`". */
39
+ export function skipNames(skips) {
40
+ const quoted = skips.map((segment) => `\`${segment}\``);
41
+ return quoted.length > 1
42
+ ? `${quoted.slice(0, -1).join(", ")} and ${quoted[quoted.length - 1]}`
43
+ : (quoted[0] ?? "");
44
+ }
45
+ /** The sentence a `node_modules` path gets. */
46
+ function dependencyTreeNote(path, verb) {
47
+ return (`${path} is inside node_modules, which lives on the container's disk rather ` +
48
+ `than in the workspace ${verb} works on. Use sb_exec there — ` +
49
+ `\`cat\`, \`ls\`, \`rg\`.`);
45
50
  }
46
51
  /**
47
52
  * The sentence a `.git` path gets.
@@ -67,21 +72,18 @@ function gitInternalNote(path, verb) {
67
72
  /**
68
73
  * The path check every file tool makes, before it opens anything.
69
74
  *
70
- * Returns the sentence to hand back, or `undefined` to proceed. Two string
71
- * comparisons — no `stat`, no round trip — which is why all six tools can afford
72
- * to call it before the workspace is opened.
75
+ * Returns the sentence to hand back, or `undefined` to proceed. One string
76
+ * comparison — no `stat`, no round trip — which is why every file tool can
77
+ * afford to call it before the workspace is opened.
73
78
  *
74
79
  * ## A redirect, not a boundary
75
80
  *
76
- * `node_modules` is a fact about the substrate and the note names the tool that
77
- * can see it; `.git` is a rule about where repository work belongs and the note
78
- * names the tools it belongs to. Both catch the case that actually happens — a
79
- * model reaching into `.git/HEAD` to fix a merge, or reading a dependency's
80
- * source and finding nothing there.
81
+ * The note names the tools repository work belongs to, because the case that
82
+ * actually happens is a model reaching into `.git/HEAD` to fix a merge.
81
83
  *
82
84
  * It is not containment, and this is the only place worth saying so. `sb_exec`
83
85
  * is in the same tool family, granted per family rather than per tool, so every
84
- * agent holding these six also holds a shell that reads and writes `.git`
86
+ * agent holding these file tools also holds a shell that reads and writes `.git`
85
87
  * directly.
86
88
  *
87
89
  * Symlinks are not resolved, for the same reason: a tracked
@@ -96,9 +98,9 @@ function gitInternalNote(path, verb) {
96
98
  * segment.
97
99
  */
98
100
  export function guardPath(path, verb) {
99
- if (isContainerOnly(path))
100
- return containerOnlyNote(path, verb);
101
101
  if (isGitInternal(path))
102
102
  return gitInternalNote(path, verb);
103
+ if (hasSegment(path, "node_modules"))
104
+ return dependencyTreeNote(path, verb);
103
105
  return undefined;
104
106
  }
@@ -27,18 +27,14 @@ import type { WorkspaceClient } from "@cloudflare/computer";
27
27
  *
28
28
  * `maxChars` is a character ceiling everywhere else — see
29
29
  * {@link ComputerConfig.maxOutputChars} — and here it is spent against
30
- * `byteOffset`/`byteLength`, which are the only units the transport has. That is
31
- * deliberate and it is safe in the direction that matters: a UTF-8 byte is never
32
- * more than one UTF-16 code unit, so *N* bytes decode to at most *N* characters.
33
- * Reading the character budget as bytes therefore always honours the ceiling,
34
- * and errs low on a file that is mostly non-ASCII — a CJK source file is cut at
35
- * about a third of the characters it could have shown. The alternative is a
36
- * second round trip to measure what the first one returned, on every read, to
37
- * recover a bound the marker already announces.
38
- *
39
- * The other reason it cannot be characters is the one this function exists for:
40
- * bounding the *transport*. A byte range is what stops a 4 MB lockfile being
41
- * materialised in a 128 MB isolate before anything is thrown away.
30
+ * `byteOffset`/`byteLength`, which are the only units the transport has, and the
31
+ * only ones that bound what crosses it. Safe in the direction that matters: a
32
+ * UTF-8 byte is never more than one UTF-16 code unit, so *N* bytes decode to at
33
+ * most *N* characters. Reading the character budget as bytes therefore always
34
+ * honours the ceiling, and errs low on a file that is mostly non-ASCII — a CJK
35
+ * source file is cut at about a third of the characters it could have shown. The
36
+ * alternative is a second round trip on every read, to recover a bound the
37
+ * marker already announces.
42
38
  *
43
39
  * ## Why `stat` first
44
40
  *
@@ -73,20 +69,20 @@ export declare function readWindow(fs: WorkspaceClient["fs"], path: string, offs
73
69
  * The subtle part is why `rawIndex` exists at all. `offset` counts items at the
74
70
  * source; the caller renders a filtered, budget-trimmed subset of them. So the
75
71
  * obvious `nextOffset = offset + shown` is wrong — it silently skips or repeats
76
- * whenever anything was dropped, which is precisely when `.git` is involved. Keeping
77
- * each survivor's source index means the next page starts exactly after the last one
78
- * the model actually saw.
72
+ * whenever anything was dropped, which is precisely when a skipped directory is
73
+ * involved. Keeping each survivor's source index means the next page starts exactly
74
+ * after the last one the model actually saw.
79
75
  *
80
- * Retries because `.git` can outnumber a whole page on its own: at a repo root it is
81
- * walked *first* (`.` sorts before alphanumerics) and holds thousands of objects, so
82
- * one fetch can come back entirely excluded. `maxRounds` is the caller's, and the
83
- * asymmetry is deliberate — a `find` retry re-walks dirents, which is cheap SQLite
84
- * reads, while a `grep` retry re-reads and re-scans every file it already looked at.
76
+ * Retries because a skipped directory can outnumber a whole page on its own — a
77
+ * dependency tree runs to tens of thousands of files — so one fetch can come back
78
+ * entirely excluded. `maxRounds` is the caller's: a retry re-reads and re-scans
79
+ * every file the last one looked at. For a walk the store can prune instead, see
80
+ * `sb_ls`, which passes exclusions to `find` and needs none of this.
85
81
  *
86
82
  * Deliberately no slicing to `want`: the caller needs the extra item to know a next
87
83
  * page exists, and its index to say where.
88
84
  */
89
- export declare function collectVisible<T>(fetch: (offset: number, limit: number) => Promise<T[]>, pathOf: (item: T) => string, want: number, offset: number, maxRounds: number): Promise<{
85
+ export declare function collectVisible<T>(fetch: (offset: number, limit: number) => Promise<T[]>, pathOf: (item: T) => string, skip: (path: string) => boolean, want: number, offset: number, maxRounds: number): Promise<{
90
86
  items: T[];
91
87
  /** Source offset of `items[i]`. */
92
88
  rawIndex: number[];
@@ -109,7 +105,7 @@ interface VisiblePage {
109
105
  *
110
106
  * `rawIndex[shown]` rather than `offset + shown`: the first item the model did *not*
111
107
  * see, at its source coordinate. Anything else drifts by however many entries the
112
- * `.git` filter removed, and drifts silently — the next page would repeat or skip
108
+ * skip filter removed, and drifts silently — the next page would repeat or skip
113
109
  * with nothing to indicate it had.
114
110
  *
115
111
  * Narrowing is still offered, and still first-class, because it is cheaper than
@@ -1,4 +1,3 @@
1
- import { isGitInternal } from "./paths.js";
2
1
  /**
3
2
  * Reading the workspace without pulling more of it across the boundary than the
4
3
  * model will be shown.
@@ -27,18 +26,14 @@ import { isGitInternal } from "./paths.js";
27
26
  *
28
27
  * `maxChars` is a character ceiling everywhere else — see
29
28
  * {@link ComputerConfig.maxOutputChars} — and here it is spent against
30
- * `byteOffset`/`byteLength`, which are the only units the transport has. That is
31
- * deliberate and it is safe in the direction that matters: a UTF-8 byte is never
32
- * more than one UTF-16 code unit, so *N* bytes decode to at most *N* characters.
33
- * Reading the character budget as bytes therefore always honours the ceiling,
34
- * and errs low on a file that is mostly non-ASCII — a CJK source file is cut at
35
- * about a third of the characters it could have shown. The alternative is a
36
- * second round trip to measure what the first one returned, on every read, to
37
- * recover a bound the marker already announces.
38
- *
39
- * The other reason it cannot be characters is the one this function exists for:
40
- * bounding the *transport*. A byte range is what stops a 4 MB lockfile being
41
- * materialised in a 128 MB isolate before anything is thrown away.
29
+ * `byteOffset`/`byteLength`, which are the only units the transport has, and the
30
+ * only ones that bound what crosses it. Safe in the direction that matters: a
31
+ * UTF-8 byte is never more than one UTF-16 code unit, so *N* bytes decode to at
32
+ * most *N* characters. Reading the character budget as bytes therefore always
33
+ * honours the ceiling, and errs low on a file that is mostly non-ASCII — a CJK
34
+ * source file is cut at about a third of the characters it could have shown. The
35
+ * alternative is a second round trip on every read, to recover a bound the
36
+ * marker already announces.
42
37
  *
43
38
  * ## Why `stat` first
44
39
  *
@@ -112,20 +107,20 @@ export async function readWindow(fs, path, offset, length, maxChars) {
112
107
  * The subtle part is why `rawIndex` exists at all. `offset` counts items at the
113
108
  * source; the caller renders a filtered, budget-trimmed subset of them. So the
114
109
  * obvious `nextOffset = offset + shown` is wrong — it silently skips or repeats
115
- * whenever anything was dropped, which is precisely when `.git` is involved. Keeping
116
- * each survivor's source index means the next page starts exactly after the last one
117
- * the model actually saw.
110
+ * whenever anything was dropped, which is precisely when a skipped directory is
111
+ * involved. Keeping each survivor's source index means the next page starts exactly
112
+ * after the last one the model actually saw.
118
113
  *
119
- * Retries because `.git` can outnumber a whole page on its own: at a repo root it is
120
- * walked *first* (`.` sorts before alphanumerics) and holds thousands of objects, so
121
- * one fetch can come back entirely excluded. `maxRounds` is the caller's, and the
122
- * asymmetry is deliberate — a `find` retry re-walks dirents, which is cheap SQLite
123
- * reads, while a `grep` retry re-reads and re-scans every file it already looked at.
114
+ * Retries because a skipped directory can outnumber a whole page on its own — a
115
+ * dependency tree runs to tens of thousands of files — so one fetch can come back
116
+ * entirely excluded. `maxRounds` is the caller's: a retry re-reads and re-scans
117
+ * every file the last one looked at. For a walk the store can prune instead, see
118
+ * `sb_ls`, which passes exclusions to `find` and needs none of this.
124
119
  *
125
120
  * Deliberately no slicing to `want`: the caller needs the extra item to know a next
126
121
  * page exists, and its index to say where.
127
122
  */
128
- export async function collectVisible(fetch, pathOf, want, offset, maxRounds) {
123
+ export async function collectVisible(fetch, pathOf, skip, want, offset, maxRounds) {
129
124
  const items = [];
130
125
  const rawIndex = [];
131
126
  let cursor = offset;
@@ -136,7 +131,7 @@ export async function collectVisible(fetch, pathOf, want, offset, maxRounds) {
136
131
  rounds += 1;
137
132
  const batch = await fetch(cursor, want + 1);
138
133
  batch.forEach((item, i) => {
139
- if (isGitInternal(pathOf(item)))
134
+ if (skip(pathOf(item)))
140
135
  return;
141
136
  items.push(item);
142
137
  rawIndex.push(cursor + i);
@@ -160,7 +155,7 @@ export async function collectVisible(fetch, pathOf, want, offset, maxRounds) {
160
155
  *
161
156
  * `rawIndex[shown]` rather than `offset + shown`: the first item the model did *not*
162
157
  * see, at its source coordinate. Anything else drifts by however many entries the
163
- * `.git` filter removed, and drifts silently — the next page would repeat or skip
158
+ * skip filter removed, and drifts silently — the next page would repeat or skip
164
159
  * with nothing to indicate it had.
165
160
  *
166
161
  * Narrowing is still offered, and still first-class, because it is cheaper than
@@ -196,8 +191,10 @@ export function listingNote(page, shown, noun, narrower) {
196
191
  * somewhere else — is the same either way.
197
192
  */
198
193
  export async function pathExists(fs, path) {
199
- const probe = fs.exists;
200
- if (typeof probe === "function")
201
- return probe.call(fs, path);
194
+ // Called as a method, never through `.call`: on an RPC stub `.call` is read as
195
+ // a remote method, and the stub it would pass as `this` cannot be serialised.
196
+ const remote = fs;
197
+ if (typeof remote.exists === "function")
198
+ return remote.exists(path);
202
199
  return fs.stat(path).then(() => true, () => false);
203
200
  }
@@ -100,7 +100,35 @@ export declare function renderResult(result: {
100
100
  stdout: string;
101
101
  stderr: string;
102
102
  status?: WorkspaceRuntimeStatus;
103
+ sync?: {
104
+ status: "complete" | "pending";
105
+ };
103
106
  }, maxChars: number): string;
107
+ /**
108
+ * The command ran; its changes have not landed in the workspace yet.
109
+ *
110
+ * Every command is bracketed by a sync, and the pull afterwards is what moves
111
+ * what it wrote from the container into the Durable Object. That pull can fail
112
+ * while the command itself succeeds — a transport that dropped, a container
113
+ * replaced underneath — and the runtime reports it in `sync` rather than in the
114
+ * exit code, because the command genuinely did run.
115
+ *
116
+ * Worth a sentence to the model because the *next* thing it does is usually read
117
+ * what it just wrote, and the file tools read the workspace rather than the
118
+ * container. Without this the file looks unchanged and the obvious conclusion is
119
+ * that the command did not work, which is the one conclusion that is wrong.
120
+ *
121
+ * Recovery is deliberately not the model's, and is not automatic either: the
122
+ * runtime schedules no retry of its own, so an outstanding pull lands when the
123
+ * host drives one or when a later command's own bracket carries it. What the
124
+ * model is told therefore stops short of "it will be there in a moment" — it is
125
+ * told the read may be stale, that re-running the command is the wrong fix
126
+ * because it would repeat the side effects, and to say so in its result if the
127
+ * workspace still disagrees with what it wrote.
128
+ */
129
+ export declare function syncPendingNote(sync: {
130
+ status: "complete" | "pending";
131
+ } | undefined): string | undefined;
104
132
  /**
105
133
  * Why a command has nothing to show for itself.
106
134
  *
@@ -177,7 +177,40 @@ export function renderResult(result, maxChars) {
177
177
  const body = truncateOutput(parts.join("\n"), maxChars);
178
178
  const state = result.status && result.status !== "completed" ? ` (${result.status})` : "";
179
179
  const verdict = `--- exit ${result.exitCode}${state} ---`;
180
- return body ? `${body}\n${verdict}` : `(no output)\n${verdict}`;
180
+ const pending = syncPendingNote(result.sync);
181
+ const transcript = body ? `${body}\n${verdict}` : `(no output)\n${verdict}`;
182
+ return pending ? `${transcript}\n${pending}` : transcript;
183
+ }
184
+ /**
185
+ * The command ran; its changes have not landed in the workspace yet.
186
+ *
187
+ * Every command is bracketed by a sync, and the pull afterwards is what moves
188
+ * what it wrote from the container into the Durable Object. That pull can fail
189
+ * while the command itself succeeds — a transport that dropped, a container
190
+ * replaced underneath — and the runtime reports it in `sync` rather than in the
191
+ * exit code, because the command genuinely did run.
192
+ *
193
+ * Worth a sentence to the model because the *next* thing it does is usually read
194
+ * what it just wrote, and the file tools read the workspace rather than the
195
+ * container. Without this the file looks unchanged and the obvious conclusion is
196
+ * that the command did not work, which is the one conclusion that is wrong.
197
+ *
198
+ * Recovery is deliberately not the model's, and is not automatic either: the
199
+ * runtime schedules no retry of its own, so an outstanding pull lands when the
200
+ * host drives one or when a later command's own bracket carries it. What the
201
+ * model is told therefore stops short of "it will be there in a moment" — it is
202
+ * told the read may be stale, that re-running the command is the wrong fix
203
+ * because it would repeat the side effects, and to say so in its result if the
204
+ * workspace still disagrees with what it wrote.
205
+ */
206
+ export function syncPendingNote(sync) {
207
+ if (sync?.status !== "pending")
208
+ return undefined;
209
+ return ("(The command finished, but what it wrote has not reached the workspace " +
210
+ "yet, so the file tools may still show the previous contents. Do not re-run " +
211
+ "the command to force it — that repeats whatever it already did. Read again " +
212
+ "in a moment, and if the workspace still disagrees with what the command " +
213
+ "wrote, say so in your result rather than working around it.)");
181
214
  }
182
215
  /**
183
216
  * Why a command has nothing to show for itself.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Run a command under {@link file://./index.ts ComputerConfig.shell} with its
3
+ * two output streams left as they are, or hand it back untouched when no shell
4
+ * is configured.
5
+ *
6
+ * This is the variant for a caller that **reads the result in code**: `stdout` is
7
+ * a data channel it compares or parses, and `stderr` is a separate diagnostic.
8
+ * {@link file://./index.ts computerExec} is that caller, on behalf of
9
+ * `@dynamicagents/plugins/repo`, which asks git questions like
10
+ * `symbolic-ref --short refs/remotes/origin/HEAD` and `rev-list --count` and
11
+ * needs the answer alone.
12
+ *
13
+ * Two functions rather than one with a flag, because the difference is a change
14
+ * to the *output contract* and a boolean hides it at the call site. Merging the
15
+ * streams here would give every `/repo` git command an empty `stderr` and a
16
+ * `stdout` that is a transcript rather than an answer — which fails silently,
17
+ * since the commands `/repo` parses are quiet ones and a single `warning:` is
18
+ * enough to skip the empty-branch guard.
19
+ */
20
+ export declare function withShell(command: string, shell: string | undefined): string;
21
+ /**
22
+ * Run a command under {@link file://./index.ts ComputerConfig.shell} with its
23
+ * two output streams merged into one transcript, in the order they were
24
+ * written.
25
+ *
26
+ * This is the variant for a caller whose consumer is **a model reading output**.
27
+ * `sb_exec` is that caller. A project's check is a chain — `wrangler types &&
28
+ * prettier && eslint && tsc` — and *which tool spoke last* is how you know which
29
+ * one failed. A stdout block and a separate stderr block destroy that ordering,
30
+ * and a model that wants it back re-runs the whole gate as
31
+ * `npm run check > /tmp/out 2>&1; cat /tmp/out`. This is that workaround, done
32
+ * once, for free.
33
+ *
34
+ * The redirect binds to the wrapper process, so it applies to everything the
35
+ * command writes however deeply nested — and there is no inner brace group or
36
+ * subshell to mis-parse a command that already contains `&&`, quotes or redirects
37
+ * of its own.
38
+ *
39
+ * With no shell configured there is no wrapper process to redirect, so the
40
+ * command goes to the runtime untouched and the two streams arrive separate.
41
+ * That is not a gap: {@link file://./render.ts renderResult} renders them as a
42
+ * labelled `--- stderr ---` block for exactly this case. The transcript is the
43
+ * better answer, not the only supported one.
44
+ */
45
+ export declare function withShellTranscript(command: string, shell: string | undefined): string;
@@ -0,0 +1,80 @@
1
+ import { shellQuote } from "@cloudflare/computer";
2
+ /** How a command reaches the shell, and what its two output streams do there. */
3
+ /**
4
+ * The shell wrapper both variants share, minus the choice they differ on.
5
+ *
6
+ * One quoted argument, not string concatenation: the command is model-authored
7
+ * and routinely contains quotes of its own (`git commit -m "…"`), so anything
8
+ * less than `shellQuote` re-parses the model's quoting and mangles it.
9
+ *
10
+ * ## Why `-o pipefail`
11
+ *
12
+ * Without it a pipeline's exit status is its **last** stage's, so
13
+ * `npm run check | tail -100` reports `exit 0` for a gate that failed outright —
14
+ * and models pipe into `tail` constantly. A tool that reports success for a
15
+ * failed build is worse than one that reports nothing. It also costs a re-run
16
+ * every time: a model that cannot trust a piped exit code runs the whole thing
17
+ * again unpiped to get one.
18
+ *
19
+ * The trade is worth stating. `pipefail` surfaces SIGPIPE too, so
20
+ * `ls big-dir | head -1` reports 141 rather than 0 — noisy, but *visible*, where
21
+ * the alternative is a failing gate that looks clean. The tool description tells
22
+ * the model not to pipe into `head`/`tail` at all, since output is already
23
+ * truncated with both ends kept.
24
+ *
25
+ * Requires a shell that implements it — `bash`, `zsh`, `ksh`, not `sh`/dash. A
26
+ * host setting {@link file://./index.ts ComputerConfig.shell} is choosing that
27
+ * shell explicitly.
28
+ */
29
+ function wrapped(command, shell) {
30
+ return `${shell} -o pipefail -c ${shellQuote(command)}`;
31
+ }
32
+ /**
33
+ * Run a command under {@link file://./index.ts ComputerConfig.shell} with its
34
+ * two output streams left as they are, or hand it back untouched when no shell
35
+ * is configured.
36
+ *
37
+ * This is the variant for a caller that **reads the result in code**: `stdout` is
38
+ * a data channel it compares or parses, and `stderr` is a separate diagnostic.
39
+ * {@link file://./index.ts computerExec} is that caller, on behalf of
40
+ * `@dynamicagents/plugins/repo`, which asks git questions like
41
+ * `symbolic-ref --short refs/remotes/origin/HEAD` and `rev-list --count` and
42
+ * needs the answer alone.
43
+ *
44
+ * Two functions rather than one with a flag, because the difference is a change
45
+ * to the *output contract* and a boolean hides it at the call site. Merging the
46
+ * streams here would give every `/repo` git command an empty `stderr` and a
47
+ * `stdout` that is a transcript rather than an answer — which fails silently,
48
+ * since the commands `/repo` parses are quiet ones and a single `warning:` is
49
+ * enough to skip the empty-branch guard.
50
+ */
51
+ export function withShell(command, shell) {
52
+ return shell ? wrapped(command, shell) : command;
53
+ }
54
+ /**
55
+ * Run a command under {@link file://./index.ts ComputerConfig.shell} with its
56
+ * two output streams merged into one transcript, in the order they were
57
+ * written.
58
+ *
59
+ * This is the variant for a caller whose consumer is **a model reading output**.
60
+ * `sb_exec` is that caller. A project's check is a chain — `wrangler types &&
61
+ * prettier && eslint && tsc` — and *which tool spoke last* is how you know which
62
+ * one failed. A stdout block and a separate stderr block destroy that ordering,
63
+ * and a model that wants it back re-runs the whole gate as
64
+ * `npm run check > /tmp/out 2>&1; cat /tmp/out`. This is that workaround, done
65
+ * once, for free.
66
+ *
67
+ * The redirect binds to the wrapper process, so it applies to everything the
68
+ * command writes however deeply nested — and there is no inner brace group or
69
+ * subshell to mis-parse a command that already contains `&&`, quotes or redirects
70
+ * of its own.
71
+ *
72
+ * With no shell configured there is no wrapper process to redirect, so the
73
+ * command goes to the runtime untouched and the two streams arrive separate.
74
+ * That is not a gap: {@link file://./render.ts renderResult} renders them as a
75
+ * labelled `--- stderr ---` block for exactly this case. The transcript is the
76
+ * better answer, not the only supported one.
77
+ */
78
+ export function withShellTranscript(command, shell) {
79
+ return shell ? `${wrapped(command, shell)} 2>&1` : command;
80
+ }