@zocomputer/agent-sdk 0.5.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +673 -0
  3. package/dist/attachments.js +52 -0
  4. package/dist/gateway-fetch.js +67 -0
  5. package/dist/index.js +4169 -0
  6. package/dist/initiator-auth.js +49 -0
  7. package/dist/platform/agent-sandbox/index.js +691 -0
  8. package/dist/platform/cloud-tools/image.js +498 -0
  9. package/dist/platform/cloud-tools/index.js +507 -0
  10. package/dist/platform/cloud-tools/web-search.js +87 -0
  11. package/dist/platform/runtime-ai/gateway.js +87 -0
  12. package/dist/platform/runtime-ai/index.js +91 -0
  13. package/dist/platform/runtime-ai/register.js +88 -0
  14. package/dist/platform/runtime-ai/session-fetch.js +54 -0
  15. package/dist/platform/runtime-auth/index.js +141 -0
  16. package/dist/state-files.js +287 -0
  17. package/dist/state-sandbox.js +383 -0
  18. package/dist/state.js +29 -0
  19. package/dist/steer-inbox.js +84 -0
  20. package/dist/steer.js +83 -0
  21. package/package.json +143 -0
  22. package/platform/agent-sandbox/api-client.ts +196 -0
  23. package/platform/agent-sandbox/index.ts +27 -0
  24. package/platform/agent-sandbox/pure.ts +83 -0
  25. package/platform/agent-sandbox/sftp.ts +141 -0
  26. package/platform/agent-sandbox/ssh-connection.ts +165 -0
  27. package/platform/agent-sandbox/ssh-exec.ts +98 -0
  28. package/platform/agent-sandbox/ssh-session.ts +487 -0
  29. package/platform/agent-sandbox/zo-backend.ts +88 -0
  30. package/platform/agent-sandbox/zo-sandbox.ts +39 -0
  31. package/platform/cloud-tools/image-path.ts +44 -0
  32. package/platform/cloud-tools/image.ts +225 -0
  33. package/platform/cloud-tools/index.ts +22 -0
  34. package/platform/cloud-tools/state-files.ts +368 -0
  35. package/platform/cloud-tools/tool-meta.ts +32 -0
  36. package/platform/cloud-tools/web-search.ts +15 -0
  37. package/platform/runtime-ai/gateway.ts +76 -0
  38. package/platform/runtime-ai/index.ts +20 -0
  39. package/platform/runtime-ai/register.ts +26 -0
  40. package/platform/runtime-ai/session-fetch.ts +124 -0
  41. package/platform/runtime-auth/index.ts +331 -0
  42. package/src/async-tasks.ts +273 -0
  43. package/src/attachments.ts +109 -0
  44. package/src/backgroundable.ts +88 -0
  45. package/src/bounded-output.ts +159 -0
  46. package/src/dir-conventions.ts +238 -0
  47. package/src/extract/cache.ts +40 -0
  48. package/src/extract/docx.ts +18 -0
  49. package/src/extract/pdf.ts +54 -0
  50. package/src/extract/sheet.ts +56 -0
  51. package/src/file-kind.ts +258 -0
  52. package/src/file-view.ts +80 -0
  53. package/src/gateway-fetch.ts +115 -0
  54. package/src/glob-match.ts +13 -0
  55. package/src/hooks.ts +213 -0
  56. package/src/index.ts +419 -0
  57. package/src/initiator-auth.ts +81 -0
  58. package/src/instructions.ts +224 -0
  59. package/src/list-files.ts +41 -0
  60. package/src/mock-model.ts +572 -0
  61. package/src/park-delivery.ts +247 -0
  62. package/src/path-locks.ts +52 -0
  63. package/src/read-file-content.ts +142 -0
  64. package/src/read-text.ts +40 -0
  65. package/src/redeliver.ts +155 -0
  66. package/src/run.ts +159 -0
  67. package/src/sandbox-io.ts +414 -0
  68. package/src/state-files.ts +460 -0
  69. package/src/state-sandbox.ts +710 -0
  70. package/src/state.ts +96 -0
  71. package/src/steer-inbox.ts +105 -0
  72. package/src/steer-tool.ts +83 -0
  73. package/src/steer.ts +146 -0
  74. package/src/task.ts +320 -0
  75. package/src/tools/bash.ts +143 -0
  76. package/src/tools/edit.ts +58 -0
  77. package/src/tools/glob.ts +56 -0
  78. package/src/tools/grep.ts +188 -0
  79. package/src/tools/read.ts +319 -0
  80. package/src/tools/tasks.ts +241 -0
  81. package/src/tools/webfetch.ts +368 -0
  82. package/src/tools/write.ts +42 -0
  83. package/src/walk.ts +112 -0
  84. package/src/watch-output.ts +104 -0
  85. package/src/web-fetch.ts +324 -0
  86. package/src/web-page.ts +179 -0
  87. package/src/workspace-io.ts +225 -0
  88. package/src/workspace.ts +41 -0
@@ -0,0 +1,273 @@
1
+ // Background task registry for the stdlib's async tools.
2
+ //
3
+ // eve runs each turn as a durable, step-checkpointed workflow, and a step can't
4
+ // advance until every tool call in it resolves — so there's no native "return
5
+ // early and keep running". Instead a tool kicks off work, stashes the promise
6
+ // here, and returns a task id immediately; later tools poll or await it.
7
+ //
8
+ // Task metadata/results are persisted to `storePath` so finished work survives
9
+ // an agent restart. In-flight JS promises cannot be reattached after a process
10
+ // exits, so tasks that were still running at boot are marked `lost`.
11
+ //
12
+ // Two hazards shape the registry's sharing model:
13
+ //
14
+ // - **Module-graph duplication.** eve's dev runtime rebuilds authored artifacts
15
+ // mid-session (e.g. the agent itself touches a watched file), and a rebuild
16
+ // gives statically-exported tools a fresh module graph while session-scoped
17
+ // dynamics (the tasks toolset, built on `session.started`) keep their old
18
+ // closure. Two module instances then hold two `Map`s: `bash` spawns `task_1`
19
+ // into one, `await_task` looks it up in the other — "No such task". So
20
+ // registries are deduped per `storePath` on `globalThis` (via `Symbol.for`,
21
+ // which is shared across module copies): every copy in the process converges
22
+ // on one instance.
23
+ // - **Out-of-instance readers.** As a second line, lookups that miss in memory
24
+ // fall back to the persisted store, and `awaitTask` polls it while the task
25
+ // is running elsewhere — so even a reader in another process reports honest
26
+ // state instead of "No such task".
27
+
28
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
29
+ import { dirname } from "node:path";
30
+
31
+ interface BaseTask {
32
+ readonly id: string;
33
+ /** Short human label, e.g. the command or query. */
34
+ readonly label: string;
35
+ /** Which backgroundable op produced this task. */
36
+ readonly tool: string;
37
+ readonly startedAt: number;
38
+ readonly progress?: unknown;
39
+ }
40
+
41
+ // Discriminated union so callers can't read `result` off a still-running task.
42
+ export type Task =
43
+ | (BaseTask & { readonly status: "running" })
44
+ | (BaseTask & { readonly status: "done"; readonly finishedAt: number; readonly result: unknown })
45
+ | (BaseTask & { readonly status: "error"; readonly finishedAt: number; readonly error: string })
46
+ | (BaseTask & { readonly status: "lost"; readonly finishedAt: number; readonly error: string });
47
+
48
+ export interface TaskRegistry {
49
+ /** Register `work` as a background task and return its id immediately. */
50
+ spawnTask(tool: string, label: string, work: Promise<unknown>): string;
51
+ updateTaskProgress(id: string, progress: unknown): void;
52
+ listTasks(): Task[];
53
+ getTask(id: string): Task | undefined;
54
+ /**
55
+ * Block until the task settles or `waitMs` elapses, then return its current
56
+ * state (still "running" if the wait timed out). Undefined for an unknown id.
57
+ */
58
+ awaitTask(id: string, waitMs: number): Promise<Task | undefined>;
59
+ }
60
+
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return typeof value === "object" && value !== null && !Array.isArray(value);
63
+ }
64
+
65
+ function isTask(value: unknown): value is Task {
66
+ if (!isRecord(value)) return false;
67
+ if (
68
+ typeof value.id !== "string" ||
69
+ typeof value.tool !== "string" ||
70
+ typeof value.label !== "string" ||
71
+ typeof value.startedAt !== "number" ||
72
+ typeof value.status !== "string"
73
+ ) {
74
+ return false;
75
+ }
76
+ switch (value.status) {
77
+ case "running":
78
+ return true;
79
+ case "done":
80
+ return typeof value.finishedAt === "number" && "result" in value;
81
+ case "error":
82
+ case "lost":
83
+ return typeof value.finishedAt === "number" && typeof value.error === "string";
84
+ default:
85
+ return false;
86
+ }
87
+ }
88
+
89
+ // Keep a registry from growing without bound over a long session; drop the
90
+ // oldest already-settled tasks first (never a running one).
91
+ const MAX_TASKS = 100;
92
+
93
+ // One registry per store path per process, no matter how many module copies a
94
+ // runtime rebuild creates. `Symbol.for` is process-global, so every copy of
95
+ // this module resolves the same key.
96
+ const REGISTRY_CACHE_KEY = Symbol.for("zocomputer.agent-sdk.task-registries");
97
+
98
+ function registryCache(): Map<string, TaskRegistry> {
99
+ const holder = globalThis as { [REGISTRY_CACHE_KEY]?: Map<string, TaskRegistry> };
100
+ holder[REGISTRY_CACHE_KEY] ??= new Map();
101
+ return holder[REGISTRY_CACHE_KEY];
102
+ }
103
+
104
+ /**
105
+ * Test-only: drop the per-process registry dedupe so a test can simulate an
106
+ * agent restart (a fresh registry over an existing store).
107
+ */
108
+ export function __resetTaskRegistryCacheForTests(): void {
109
+ registryCache().clear();
110
+ }
111
+
112
+ /** How often awaitTask re-reads the store for a task another instance owns. */
113
+ const STORE_POLL_MS = 500;
114
+
115
+ export function createTaskRegistry(opts: { storePath: string }): TaskRegistry {
116
+ const cache = registryCache();
117
+ const cached = cache.get(opts.storePath);
118
+ if (cached) return cached;
119
+ const registry = buildTaskRegistry(opts);
120
+ cache.set(opts.storePath, registry);
121
+ return registry;
122
+ }
123
+
124
+ function buildTaskRegistry(opts: { storePath: string }): TaskRegistry {
125
+ const { storePath } = opts;
126
+ const tasks = new Map<string, Task>();
127
+ const pending = new Map<string, Promise<unknown>>();
128
+ let counter = 0;
129
+
130
+ function listTasks(): Task[] {
131
+ return [...tasks.values()].sort((a, b) => a.startedAt - b.startedAt);
132
+ }
133
+
134
+ function persist(): void {
135
+ mkdirSync(dirname(storePath), { recursive: true });
136
+ writeFileSync(storePath, JSON.stringify({ tasks: listTasks() }, null, 2), "utf8");
137
+ }
138
+
139
+ function readStoreTasks(): Task[] {
140
+ if (!existsSync(storePath)) return [];
141
+ try {
142
+ const parsed: unknown = JSON.parse(readFileSync(storePath, "utf8"));
143
+ if (!isRecord(parsed) || !Array.isArray(parsed.tasks)) return [];
144
+ return parsed.tasks.filter(isTask);
145
+ } catch {
146
+ // Corrupt local state should not stop the agent from booting.
147
+ return [];
148
+ }
149
+ }
150
+
151
+ function loadPersisted(): void {
152
+ for (const saved of readStoreTasks()) {
153
+ const task =
154
+ saved.status === "running"
155
+ ? {
156
+ ...saved,
157
+ status: "lost" as const,
158
+ finishedAt: Date.now(),
159
+ error: "The agent restarted before this background task finished.",
160
+ }
161
+ : saved;
162
+ tasks.set(task.id, task);
163
+ const match = task.id.match(/^task_(\d+)$/);
164
+ const n = match ? Number(match[1]) : 0;
165
+ if (Number.isFinite(n)) counter = Math.max(counter, n);
166
+ }
167
+ }
168
+
169
+ loadPersisted();
170
+
171
+ // Read-through for tasks this instance doesn't hold (an owner in another
172
+ // process). Settled state in the store is authoritative; a "running" entry
173
+ // is honest live state — its promise just isn't awaitable from here.
174
+ function storeTask(id: string): Task | undefined {
175
+ return readStoreTasks().find((task) => task.id === id);
176
+ }
177
+
178
+ function prune(): void {
179
+ if (tasks.size <= MAX_TASKS) return;
180
+ const settled = [...tasks.values()]
181
+ .filter((t) => t.status !== "running")
182
+ .sort((a, b) => a.startedAt - b.startedAt);
183
+ for (const t of settled) {
184
+ if (tasks.size <= MAX_TASKS) break;
185
+ tasks.delete(t.id);
186
+ pending.delete(t.id);
187
+ }
188
+ persist();
189
+ }
190
+
191
+ // Attaches its own then/catch, so a rejecting op updates the task instead of
192
+ // surfacing as an unhandledRejection.
193
+ function spawnTask(tool: string, label: string, work: Promise<unknown>): string {
194
+ const id = `task_${++counter}`;
195
+ const startedAt = Date.now();
196
+ tasks.set(id, { id, tool, label, startedAt, status: "running" });
197
+ pending.set(id, work);
198
+ void work.then(
199
+ (result) => {
200
+ tasks.set(id, {
201
+ id,
202
+ tool,
203
+ label,
204
+ startedAt,
205
+ status: "done",
206
+ finishedAt: Date.now(),
207
+ result,
208
+ });
209
+ pending.delete(id);
210
+ persist();
211
+ },
212
+ (err: unknown) => {
213
+ const error = err instanceof Error ? err.message : String(err);
214
+ tasks.set(id, {
215
+ id,
216
+ tool,
217
+ label,
218
+ startedAt,
219
+ status: "error",
220
+ finishedAt: Date.now(),
221
+ error,
222
+ });
223
+ pending.delete(id);
224
+ persist();
225
+ },
226
+ );
227
+ prune();
228
+ persist();
229
+ return id;
230
+ }
231
+
232
+ return {
233
+ spawnTask,
234
+ updateTaskProgress(id, progress) {
235
+ const task = tasks.get(id);
236
+ if (!task || task.status !== "running") return;
237
+ tasks.set(id, { ...task, progress });
238
+ },
239
+ listTasks,
240
+ getTask(id) {
241
+ return tasks.get(id) ?? storeTask(id);
242
+ },
243
+ async awaitTask(id, waitMs) {
244
+ const current = tasks.get(id);
245
+ if (current) {
246
+ if (current.status !== "running") return current;
247
+ const work = pending.get(id);
248
+ if (work) {
249
+ await Promise.race([
250
+ work.then(
251
+ () => undefined,
252
+ () => undefined,
253
+ ),
254
+ new Promise<void>((resolve) => setTimeout(resolve, waitMs)),
255
+ ]);
256
+ }
257
+ return tasks.get(id);
258
+ }
259
+ // Not ours — poll the persisted store until the owning instance settles
260
+ // it or the wait elapses (still returns honest "running" state then).
261
+ const deadline = Date.now() + waitMs;
262
+ for (;;) {
263
+ const saved = storeTask(id);
264
+ if (!saved || saved.status !== "running") return saved;
265
+ const remaining = deadline - Date.now();
266
+ if (remaining <= 0) return saved;
267
+ await new Promise<void>((resolve) =>
268
+ setTimeout(resolve, Math.min(STORE_POLL_MS, remaining)),
269
+ );
270
+ }
271
+ },
272
+ };
273
+ }
@@ -0,0 +1,109 @@
1
+ // The chat-attachment contract for smuggling media past eve's text/json-only
2
+ // tool results. `read`/`webfetch` put a ChatAttachment on their raw execute()
3
+ // return under CHAT_ATTACHMENT_FIELD — a field the model never sees (stripped
4
+ // by the tool's toModelOutput) — and a connected client (or the park-delivery
5
+ // hook) reads it off the tool-result event (action.result / the reducer's
6
+ // dynamic-tool.output) and re-injects the media as a real user message part on
7
+ // the next turn.
8
+ //
9
+ // This module is deliberately dependency-free (no node:*, no extraction deps),
10
+ // so UI clients can import it via the `@zocomputer/agent-sdk/attachments`
11
+ // subpath without pulling the package's PDF/DOCX/spreadsheet graph into a
12
+ // browser bundle.
13
+
14
+ /** The result field carrying the model-hidden attachment. */
15
+ export const CHAT_ATTACHMENT_FIELD = "chatAttachment" as const;
16
+
17
+ /**
18
+ * Default cap for inlining image bytes on a `read`/`webfetch` result: 3 MiB,
19
+ * matching eve's attachment-staging hydration cap (`shouldInlineSandboxRefAsBytes`,
20
+ * eve ≤0.19: images ≤3 MiB inline at model-call time; bigger ones hydrate as a
21
+ * text stub). Staying under it keeps read's "queued" promise truthful on every
22
+ * runtime — a bigger image gets the honest metadata-only note instead of
23
+ * silently degrading after delivery.
24
+ */
25
+ export const DEFAULT_MAX_INLINE_IMAGE_BYTES = 3 * 1024 * 1024;
26
+
27
+ /**
28
+ * Default cap for inlining video/audio bytes: 10 MB, matching read's stat
29
+ * guard (bigger files never reach the attach decision). Bounds durable-stream
30
+ * bloat — the data URL rides the stream once per read/fetch.
31
+ */
32
+ export const DEFAULT_MAX_INLINE_MEDIA_BYTES = 10 * 1024 * 1024;
33
+
34
+ /** Media kinds the attachment contract carries. Image delivery works with
35
+ * every vision model; video/audio are provider-gated (the read/webfetch
36
+ * factories only attach them when the consumer opts in). */
37
+ export type ChatAttachmentKind = "image" | "video" | "audio";
38
+
39
+ export type ChatAttachment =
40
+ | {
41
+ readonly kind: "image";
42
+ /** A `data:` URL (base64) — drop straight into an AI SDK file part's `data`. */
43
+ readonly dataUrl: string;
44
+ /** e.g. `image/png`, `image/jpeg`. */
45
+ readonly mediaType: string;
46
+ readonly filename: string;
47
+ readonly width: number | null;
48
+ readonly height: number | null;
49
+ }
50
+ | {
51
+ readonly kind: "video";
52
+ readonly dataUrl: string;
53
+ /** e.g. `video/mp4`, `video/webm`. */
54
+ readonly mediaType: string;
55
+ readonly filename: string;
56
+ }
57
+ | {
58
+ readonly kind: "audio";
59
+ readonly dataUrl: string;
60
+ /** e.g. `audio/mpeg`, `audio/wav`. */
61
+ readonly mediaType: string;
62
+ readonly filename: string;
63
+ };
64
+
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === "object" && value !== null && !Array.isArray(value);
67
+ }
68
+
69
+ /**
70
+ * Read the model-hidden media attachment off a tool result, if present.
71
+ * Matches by payload shape, not tool name, so it's agnostic to what a
72
+ * consumer named its read tool. Returns null for any result without a valid
73
+ * attachment.
74
+ */
75
+ export function readChatAttachment(toolOutput: unknown): ChatAttachment | null {
76
+ if (!isRecord(toolOutput)) return null;
77
+ const raw = toolOutput[CHAT_ATTACHMENT_FIELD];
78
+ if (!isRecord(raw)) return null;
79
+ if (typeof raw.dataUrl !== "string" || raw.dataUrl.length === 0) return null;
80
+ if (typeof raw.mediaType !== "string" || raw.mediaType.length === 0) return null;
81
+ const base = {
82
+ dataUrl: raw.dataUrl,
83
+ mediaType: raw.mediaType,
84
+ };
85
+ switch (raw.kind) {
86
+ case "image":
87
+ return {
88
+ kind: "image",
89
+ ...base,
90
+ filename: typeof raw.filename === "string" ? raw.filename : "image",
91
+ width: typeof raw.width === "number" ? raw.width : null,
92
+ height: typeof raw.height === "number" ? raw.height : null,
93
+ };
94
+ case "video":
95
+ return {
96
+ kind: "video",
97
+ ...base,
98
+ filename: typeof raw.filename === "string" ? raw.filename : "video",
99
+ };
100
+ case "audio":
101
+ return {
102
+ kind: "audio",
103
+ ...base,
104
+ filename: typeof raw.filename === "string" ? raw.filename : "audio",
105
+ };
106
+ default:
107
+ return null;
108
+ }
109
+ }
@@ -0,0 +1,88 @@
1
+ import { z } from "zod";
2
+ import type { CommandRunner } from "./run";
3
+
4
+ // The set of operations run_async can launch in the background. This is the
5
+ // generic mechanism: run_async dispatches by name into this registry, so
6
+ // backgrounding "any tool" is a matter of registering it here — no bespoke
7
+ // per-tool async twin. Register only the ops where waiting actually hurts (a
8
+ // shell command); instant file ops stay synchronous, and mutating ops
9
+ // (edit/write) stay out to avoid the agent racing its own edits.
10
+
11
+ /** Optional live handles run_async threads into an op (e.g. a watcher tap). */
12
+ export interface OpStartExtras {
13
+ /** Raw output tap; an op that produces no stream just ignores it. */
14
+ onOutput?: (chunk: string) => void;
15
+ }
16
+
17
+ // A registered op erases its input type behind a uniform surface: `start`
18
+ // parses the raw tool input with the op's own schema (throwing a clear error on
19
+ // bad input) and returns a label plus the in-flight promise. The parse lives
20
+ // inside the closure, so the registry stays a plain array with no `any`.
21
+ export interface BackgroundableOp {
22
+ readonly name: string;
23
+ readonly description: string;
24
+ /** JSON Schema of the op's input, surfaced so the model knows what to pass. */
25
+ readonly inputJsonSchema: unknown;
26
+ start(
27
+ rawInput: unknown,
28
+ extras?: OpStartExtras,
29
+ ): { label: string; work: Promise<unknown>; progress?: () => unknown };
30
+ }
31
+
32
+ export function defineOp<I>(cfg: {
33
+ name: string;
34
+ description: string;
35
+ inputSchema: z.ZodType<I>;
36
+ label: (input: I) => string;
37
+ run: (
38
+ input: I,
39
+ extras?: OpStartExtras,
40
+ ) => Promise<unknown> | { work: Promise<unknown>; progress?: () => unknown };
41
+ }): BackgroundableOp {
42
+ return {
43
+ name: cfg.name,
44
+ description: cfg.description,
45
+ inputJsonSchema: z.toJSONSchema(cfg.inputSchema),
46
+ start(rawInput, extras) {
47
+ const parsed = cfg.inputSchema.safeParse(rawInput);
48
+ if (!parsed.success) {
49
+ throw new Error(`Invalid input for "${cfg.name}": ${parsed.error.message}`);
50
+ }
51
+ const started = cfg.run(parsed.data, extras);
52
+ if (started instanceof Promise) return { label: cfg.label(parsed.data), work: started };
53
+ return { label: cfg.label(parsed.data), ...started };
54
+ },
55
+ };
56
+ }
57
+
58
+ function truncate(s: string, max = 80): string {
59
+ const oneLine = s.replace(/\s+/g, " ").trim();
60
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
61
+ }
62
+
63
+ // The one op every agent wants backgroundable: a shell command through the
64
+ // stdlib's runner. Agents with their own long-running ops append to the array
65
+ // this feeds (see createStdlib's `backgroundables`).
66
+ export function createBashOp(runner: CommandRunner): BackgroundableOp {
67
+ return defineOp({
68
+ name: "bash",
69
+ description:
70
+ "Run a shell command in the background (git, bun, tests, builds, installs, dev servers). Same as the bash tool, but non-blocking.",
71
+ inputSchema: z.object({
72
+ command: z.string().min(1),
73
+ cwd: z.string().optional(),
74
+ // Background work is meant to be long; default the kill timeout higher
75
+ // than the synchronous bash tool's 120s.
76
+ timeout_ms: z.number().int().positive().optional(),
77
+ }),
78
+ label: ({ command }) => truncate(command),
79
+ run: ({ command, cwd, timeout_ms }, extras) => {
80
+ const running = runner.startCommand(command, {
81
+ cwd,
82
+ timeoutMs: timeout_ms ?? 600_000,
83
+ onOutput: extras?.onOutput,
84
+ });
85
+ return { work: running.result, progress: running.progress };
86
+ },
87
+ });
88
+ }
@@ -0,0 +1,159 @@
1
+ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ // Bounded stream capture for shell output. The old capture kept a 100k-char
5
+ // head and stopped accumulating — the tail (where test runners print the
6
+ // failure summary) was gone forever. This keeps head AND tail within a fixed
7
+ // in-context budget and, on first overflow, spills the *complete* output to a
8
+ // file the model can grep/read instead of re-running the command (opencode's
9
+ // truncate-with-spill design). See plans/ben/rib-speed-opencode-lessons.md.
10
+
11
+ export const HEAD_CHARS = 25_000;
12
+ export const TAIL_CHARS = 25_000;
13
+
14
+ // Directory name for spilled outputs under the agent's state dir. Exported so
15
+ // retention sweeps (e.g. rib's) can find and prune old spills.
16
+ export const TOOL_OUTPUT_DIRNAME = "tool-outputs";
17
+
18
+ export interface CaptureSnapshot {
19
+ /** Bounded text; when truncated, head + a marker naming the spill file + tail. */
20
+ readonly text: string;
21
+ readonly totalChars: number;
22
+ readonly truncated: boolean;
23
+ /** Absolute path of the complete output, when spilled successfully. */
24
+ readonly spillPath: string | null;
25
+ }
26
+
27
+ export interface BoundedCapture {
28
+ append(chunk: string): void;
29
+ snapshot(): CaptureSnapshot;
30
+ /** The most recent text we hold: the whole output until overflow, the rolling tail after. */
31
+ latest(): string;
32
+ totalChars(): number;
33
+ }
34
+
35
+ // The head/tail cuts index UTF-16 code units, so a naive slice can land inside
36
+ // a surrogate pair and leave a lone surrogate in the transcript (which then
37
+ // breaks JSON encoding to the model API). Nudge every cut off a pair boundary:
38
+ // a head cut never ends on a high surrogate, a tail cut never starts on a low.
39
+ const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff;
40
+ const isLowSurrogate = (code: number): boolean => code >= 0xdc00 && code <= 0xdfff;
41
+
42
+ const endsOnHighSurrogate = (text: string): boolean =>
43
+ text.length > 0 && isHighSurrogate(text.charCodeAt(text.length - 1));
44
+
45
+ /** Last `cap` chars of `text`, moved one right if that would open on a low surrogate. */
46
+ function takeTail(text: string, cap: number): string {
47
+ if (text.length <= cap) return text;
48
+ let start = text.length - cap;
49
+ if (isLowSurrogate(text.charCodeAt(start))) start += 1;
50
+ return text.slice(start);
51
+ }
52
+
53
+ export function createBoundedCapture(
54
+ opts: {
55
+ headChars?: number;
56
+ tailChars?: number;
57
+ /** Absolute file path for the complete output; created lazily on first overflow. Omit to disable spilling. */
58
+ spillPath?: string;
59
+ /** How the marker names the spill file (e.g. repo-relative path). Defaults to `spillPath`. */
60
+ spillLabel?: string;
61
+ } = {},
62
+ ): BoundedCapture {
63
+ const headCap = opts.headChars ?? HEAD_CHARS;
64
+ const tailCap = opts.tailChars ?? TAIL_CHARS;
65
+ let head = "";
66
+ let tail = "";
67
+ let total = 0;
68
+ let overflowed = false;
69
+ // Three-state spill: not started, live, or failed (a failed spill degrades
70
+ // to bounded-without-file rather than erroring the command).
71
+ let spill: "none" | "live" | "failed" = opts.spillPath ? "none" : "failed";
72
+
73
+ // A surrogate pair split across appends would hit the UTF-8 file write as a
74
+ // lone surrogate and encode as U+FFFD; hold the high half until its partner
75
+ // arrives so the spill file stays byte-faithful.
76
+ let spillCarry = "";
77
+
78
+ const writeSpill = (chunk: string, first: boolean): void => {
79
+ if (spill === "failed" || opts.spillPath === undefined) return;
80
+ let text = spillCarry + chunk;
81
+ if (endsOnHighSurrogate(text)) {
82
+ spillCarry = text.slice(-1);
83
+ text = text.slice(0, -1);
84
+ } else {
85
+ spillCarry = "";
86
+ }
87
+ try {
88
+ if (first) {
89
+ mkdirSync(dirname(opts.spillPath), { recursive: true });
90
+ writeFileSync(opts.spillPath, text);
91
+ spill = "live";
92
+ } else {
93
+ appendFileSync(opts.spillPath, text);
94
+ }
95
+ } catch {
96
+ spill = "failed";
97
+ }
98
+ };
99
+
100
+ return {
101
+ append(chunk) {
102
+ total += chunk.length;
103
+ if (!overflowed) {
104
+ const room = headCap - head.length;
105
+ if (chunk.length <= room) {
106
+ head += chunk;
107
+ return;
108
+ }
109
+ overflowed = true;
110
+ // Don't cut through a surrogate pair inside this chunk…
111
+ let cut = room;
112
+ if (cut > 0 && isHighSurrogate(chunk.charCodeAt(cut - 1))) cut -= 1;
113
+ head += chunk.slice(0, cut);
114
+ // …and don't freeze a head that ends on one either (a pair can also be
115
+ // split across appends: the previous chunk filled the head exactly and
116
+ // ended on the high half). Move the orphan into the tail stream.
117
+ let remainder = chunk.slice(cut);
118
+ if (endsOnHighSurrogate(head)) {
119
+ remainder = head.slice(-1) + remainder;
120
+ head = head.slice(0, -1);
121
+ }
122
+ // The spill file starts as everything seen so far, so it's always complete.
123
+ writeSpill(head + remainder, true);
124
+ tail = takeTail(remainder, tailCap);
125
+ return;
126
+ }
127
+ writeSpill(chunk, false);
128
+ tail = takeTail(tail + chunk, tailCap);
129
+ },
130
+ snapshot() {
131
+ if (!overflowed) {
132
+ return { text: head, totalChars: total, truncated: false, spillPath: null };
133
+ }
134
+ if (head.length + tail.length === total) {
135
+ // Head overflowed but the tail still holds the rest — contiguous,
136
+ // complete. Checked by length (not caps): a surrogate nudge can move a
137
+ // char from head into a tail that then trims, and that loss must
138
+ // surface as truncation.
139
+ return { text: head + tail, totalChars: total, truncated: false, spillPath: null };
140
+ }
141
+ const where =
142
+ spill === "live" ? `; full output: ${opts.spillLabel ?? opts.spillPath}` : "";
143
+ // Actual lengths, not the caps — surrogate nudges can run a char short.
144
+ const marker = `\n… [output truncated: showing first ${head.length} and last ${tail.length} of ${total} chars${where}]\n`;
145
+ return {
146
+ text: `${head}${marker}${tail}`,
147
+ totalChars: total,
148
+ truncated: true,
149
+ spillPath: spill === "live" && opts.spillPath !== undefined ? opts.spillPath : null,
150
+ };
151
+ },
152
+ latest() {
153
+ return overflowed ? tail : head;
154
+ },
155
+ totalChars() {
156
+ return total;
157
+ },
158
+ };
159
+ }