omnirush 0.8.6 → 0.9.1

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 (54) hide show
  1. package/assets/CHANGELOG.md +120 -0
  2. package/assets/extensions/omnirush/agents-lib.ts +501 -63
  3. package/assets/extensions/omnirush/agents.ts +140 -115
  4. package/assets/extensions/omnirush/bgshell-lib.ts +432 -0
  5. package/assets/extensions/omnirush/bgshell.ts +392 -0
  6. package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
  7. package/assets/extensions/omnirush/collector.ts +275 -43
  8. package/assets/extensions/omnirush/commands.ts +2 -0
  9. package/assets/extensions/omnirush/deliveries.ts +145 -0
  10. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  11. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  12. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  13. package/assets/extensions/omnirush/guard.ts +340 -0
  14. package/assets/extensions/omnirush/index.ts +32 -6
  15. package/assets/extensions/omnirush/mcp.ts +103 -8
  16. package/assets/extensions/omnirush/memory-lib.ts +516 -0
  17. package/assets/extensions/omnirush/pi-engine.ts +115 -3
  18. package/assets/extensions/omnirush/plan-lib.ts +38 -9
  19. package/assets/extensions/omnirush/plan.ts +85 -30
  20. package/assets/extensions/omnirush/sota.ts +52 -4
  21. package/assets/extensions/omnirush/status-lib.ts +3 -0
  22. package/assets/extensions/omnirush/subagent-marker.ts +21 -0
  23. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  24. package/assets/extensions/omnirush/subagents.ts +305 -0
  25. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  26. package/assets/extensions/omnirush/swarm.ts +95 -0
  27. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  28. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  29. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  30. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  31. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  32. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  33. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  34. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  35. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  36. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  37. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  38. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  39. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  40. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  41. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  42. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  43. package/assets/extensions/omnirush/voice.ts +838 -0
  44. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  45. package/assets/extensions/omnirush/yolo.ts +85 -0
  46. package/package.json +7 -3
  47. package/scripts/brand-engine.js +526 -0
  48. package/scripts/build-all-packages.py +29 -1
  49. package/scripts/smoke-packages.py +33 -1
  50. package/src/bin.js +232 -33
  51. package/src/compat.js +272 -0
  52. package/src/lib.js +64 -0
  53. package/src/sessions.js +222 -0
  54. package/scripts/patch-pi-branding.js +0 -251
@@ -0,0 +1,392 @@
1
+ // omnirush-bgshell — long shell commands move to the background instead of
2
+ // being killed.
3
+ //
4
+ // Overrides the built-in bash tool (same execution, output handling and
5
+ // rendering: pi's own bash tool definition runs each call, with an
6
+ // execution backend that keeps the process in a job — bgshell-lib.ts):
7
+ // - a command that finishes within its foreground window (the call's
8
+ // `timeout` in seconds, default 300, OMNIRUSH_BASH_FOREGROUND_SECONDS)
9
+ // answers the call as before;
10
+ // - one still running then is NOT killed: it moves to the background and
11
+ // the call returns its id and the output so far; `run_in_background:
12
+ // true` does that at once (servers, watchers, long builds);
13
+ // - bash_output (new output and status), bash_wait and bash_kill manage
14
+ // background commands; when one finishes, its result comes back to the
15
+ // main agent as a message, the way background sub-agents' results do
16
+ // (deliveries.ts): steered into a running turn, starting a turn when the
17
+ // agent is idle, and holding a one-shot run open until it arrives;
18
+ // - background commands still running when the session ends (exit, /new,
19
+ // /resume) are stopped with a message saying so; their output is kept
20
+ // in the session (and so in the trace) as a bash tool part.
21
+
22
+ import { createBashToolDefinition, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
23
+ import { writeSync } from "node:fs";
24
+ import { Type } from "typebox";
25
+
26
+ import {
27
+ BGSHELL_HOOK,
28
+ type BgShellHook,
29
+ DEFAULT_FOREGROUND_SECONDS,
30
+ jobDetails,
31
+ jobOutcome,
32
+ MAX_FOREGROUND_SECONDS,
33
+ MAX_WAIT_SECONDS,
34
+ renderJobDelivery,
35
+ renderJobResult,
36
+ renderJobStatus,
37
+ shortCommand,
38
+ type ShellExec,
39
+ type ShellJob,
40
+ ShellJobManager,
41
+ tailText,
42
+ } from "./bgshell-lib";
43
+ import { deliveryHub } from "./deliveries";
44
+ import { BACKGROUND_BASH_TYPE } from "./pi-engine";
45
+
46
+ /** customType of the message that brings finished background commands back (the trace maps it to bash tool parts). */
47
+ export const BASH_DELIVERY_TYPE = BACKGROUND_BASH_TYPE;
48
+ /** customType of the one-shot reminder about background commands still running. */
49
+ export const BASH_REMINDER_TYPE = "omnirush-bash-reminder";
50
+
51
+ class MovedToBackground extends Error {
52
+ constructor() {
53
+ super("moved to the background");
54
+ }
55
+ }
56
+
57
+ /** The default foreground window (seconds), OMNIRUSH_BASH_FOREGROUND_SECONDS overriding it. */
58
+ export function defaultForegroundSeconds(env: NodeJS.ProcessEnv = process.env): number {
59
+ const raw = Number(String(env.OMNIRUSH_BASH_FOREGROUND_SECONDS ?? "").trim());
60
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw, MAX_FOREGROUND_SECONDS) : DEFAULT_FOREGROUND_SECONDS;
61
+ }
62
+
63
+ /** The foreground window of one call: its `timeout` (seconds), bounded; placeholders (<= 0) take the default. */
64
+ export function foregroundSeconds(timeout: unknown, fallback: number = defaultForegroundSeconds()): number {
65
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) return fallback;
66
+ return Math.min(Math.max(timeout, 1), MAX_FOREGROUND_SECONDS);
67
+ }
68
+
69
+ function plural(count: number, word: string): string {
70
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
71
+ }
72
+
73
+ export default function (pi: any, options: { exec?: ShellExec; now?: () => number } = {}) {
74
+ const hub = deliveryHub(pi);
75
+ const exec: ShellExec = options.exec ?? (createLocalBashOperations().exec as ShellExec);
76
+ const manager = new ShellJobManager({ exec, ...(options.now ? { now: options.now } : {}), onDeliverable: () => hub.notify() });
77
+ const fgDefault = defaultForegroundSeconds();
78
+
79
+ /** New output of each job, marked read. */
80
+ const collect = (jobs: ShellJob[]) => jobs.map((job) => ({ job, ...manager.read(job) }));
81
+
82
+ hub.addSource({
83
+ hasPending: (session) => manager.hasPending(session),
84
+ take: (session) => {
85
+ const jobs = manager.takeDeliverable(session);
86
+ if (jobs.length === 0) return [];
87
+ const parts = collect(jobs);
88
+ return [{
89
+ customType: BASH_DELIVERY_TYPE,
90
+ content: renderJobDelivery(parts),
91
+ details: {
92
+ jobs: parts.map(({ job, text, dropped }) => {
93
+ const shown = tailText(text);
94
+ return jobDetails(job, shown.text, shown.truncated || dropped > 0);
95
+ }),
96
+ },
97
+ }];
98
+ },
99
+ changed: () => manager.changed(),
100
+ beforeEnd: (session) => {
101
+ const jobs = manager.remindable(session);
102
+ if (jobs.length === 0) return null;
103
+ return {
104
+ customType: BASH_REMINDER_TYPE,
105
+ content: [
106
+ `This run is about to end while ${plural(jobs.length, "background command")} you started still run${jobs.length === 1 ? "s" : ""}:`,
107
+ renderJobStatus(jobs),
108
+ "If you need their results, wait for them with bash_wait; stop the ones you no longer need (servers, watchers) with bash_kill. Background commands still running when you finish are stopped.",
109
+ ].join("\n"),
110
+ details: { jobs: jobs.map((job) => job.id) },
111
+ };
112
+ },
113
+ });
114
+
115
+ // --- stopping at the end of the session -------------------------------------
116
+
117
+ /** What the session's end stopped (the collector's hook may do it before our own handler runs). */
118
+ let stoppedAtEnd: ShellJob[] = [];
119
+ const stopAll = async (session?: string): Promise<ShellJob[]> => {
120
+ const live = manager.running(session);
121
+ if (live.length === 0) return [];
122
+ const stopped = await manager.kill(live, { interrupted: true });
123
+ stoppedAtEnd.push(...stopped);
124
+ // Kept in the session (and so in the trace) like any tool output; the
125
+ // model never sees it — nothing runs after this.
126
+ const background = stopped.filter((job) => job.background);
127
+ if (background.length > 0) {
128
+ try {
129
+ const parts = collect(background);
130
+ pi.sendMessage(
131
+ {
132
+ customType: BASH_DELIVERY_TYPE,
133
+ content: [
134
+ `The session ended: ${plural(background.length, "background command")} stopped. Output since it was last read:`,
135
+ "",
136
+ parts.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text).join("\n\n---\n\n"),
137
+ ].join("\n"),
138
+ display: false,
139
+ details: {
140
+ jobs: parts.map(({ job, text, dropped }) => {
141
+ const shown = tailText(text);
142
+ return jobDetails(job, shown.text, shown.truncated || dropped > 0);
143
+ }),
144
+ },
145
+ },
146
+ { triggerTurn: false },
147
+ );
148
+ } catch {
149
+ /* the session may already be gone */
150
+ }
151
+ }
152
+ return stopped;
153
+ };
154
+ const hook: BgShellHook = {
155
+ async stopAll(sessionId?: string) {
156
+ const stopped = await stopAll(sessionId);
157
+ return stopped.map((job) => ({ id: job.id, command: job.command }));
158
+ },
159
+ };
160
+ (globalThis as any)[BGSHELL_HOOK] = hook;
161
+
162
+ pi.on("session_shutdown", async (event: any, ctx: any) => {
163
+ await stopAll();
164
+ const stopped = stoppedAtEnd.filter((job) => job.background || job.status === "killed");
165
+ stoppedAtEnd = [];
166
+ if (stopped.length === 0) return;
167
+ const text = `omnirush: stopped ${plural(stopped.length, "background command")} as the session ended: ${stopped
168
+ .map((job) => `${job.id} (${shortCommand(job.command, 60)})`)
169
+ .join(", ")}`;
170
+ if (!event?.reason || event.reason === "quit") {
171
+ // After the terminal is restored, so the line stays readable.
172
+ process.once("exit", () => {
173
+ try {
174
+ writeSync(2, `${text}\n`);
175
+ } catch {
176
+ /* stderr may be closed */
177
+ }
178
+ });
179
+ } else if (ctx?.ui?.notify) {
180
+ ctx.ui.notify(text, "warning");
181
+ }
182
+ });
183
+
184
+ // --- the bash tool ---------------------------------------------------------------
185
+
186
+ const base = createBashToolDefinition(process.cwd());
187
+ const BashParams = Type.Object({
188
+ command: Type.String({ description: "Shell command to execute" }),
189
+ timeout: Type.Optional(Type.Number({
190
+ description: `Seconds to wait for the command in the foreground (default ${fgDefault}, at most ${MAX_FOREGROUND_SECONDS}). A command still running then is NOT killed: it moves to the background, you get its id and the output so far, and its result is delivered to you when it finishes`,
191
+ })),
192
+ run_in_background: Type.Optional(Type.Boolean({
193
+ description: "true: start the command in the background and return at once with its id (dev servers, watchers, long builds or test suites while you do other work). Its result is delivered to you as a message when it finishes",
194
+ })),
195
+ });
196
+
197
+ pi.registerTool({
198
+ ...base,
199
+ name: "bash",
200
+ label: "bash",
201
+ description: [
202
+ "Execute a bash command in the current working directory. Returns stdout and stderr (output is truncated to the last lines; full output is saved to a temp file).",
203
+ `A command that runs longer than its foreground window (timeout, default ${fgDefault} s) is not killed: it continues in the background and its result arrives later as a message.`,
204
+ "run_in_background: true starts it in the background right away.",
205
+ "bash_output reads a background command's new output and status, bash_wait waits for it, bash_kill stops it.",
206
+ ].join(" "),
207
+ promptSnippet: "Execute bash commands (ls, grep, find, etc.); long-running commands continue in the background",
208
+ promptGuidelines: [
209
+ ...(Array.isArray(base.promptGuidelines) ? base.promptGuidelines : []),
210
+ "Long-running commands (dev servers, watchers, long builds or test suites) you do not need to wait for: bash with run_in_background: true, then keep working; their result arrives as a message when they finish, so do not poll bash_output in a loop.",
211
+ "Stop background commands you no longer need (servers, watchers) with bash_kill before you finish.",
212
+ ],
213
+ parameters: BashParams,
214
+
215
+ async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
216
+ const session = hub.sessionOf(ctx) || "session";
217
+ const explicit = params?.run_in_background === true;
218
+ const windowMs = explicit ? 0 : foregroundSeconds(params?.timeout, fgDefault) * 1000;
219
+ let job: ShellJob | null = null;
220
+
221
+ const operations = {
222
+ exec: (command: string, cwd: string, execOptions: any) =>
223
+ new Promise<{ exitCode: number | null }>((resolve, reject) => {
224
+ const current = manager.start({ session, command, cwd, env: execOptions?.env, toolCallId, onData: execOptions?.onData });
225
+ job = current;
226
+ let timer: ReturnType<typeof setTimeout> | null = null;
227
+ const onAbort = () => { void manager.kill([current]); };
228
+ const cleanup = () => {
229
+ if (timer) clearTimeout(timer);
230
+ execOptions?.signal?.removeEventListener?.("abort", onAbort);
231
+ };
232
+ const moveToBackground = () => {
233
+ if (current.status !== "running") return;
234
+ cleanup();
235
+ manager.toBackground(current, explicit);
236
+ reject(new MovedToBackground());
237
+ };
238
+ if (execOptions?.signal) {
239
+ if (execOptions.signal.aborted) onAbort();
240
+ else execOptions.signal.addEventListener("abort", onAbort, { once: true });
241
+ }
242
+ if (windowMs <= 0) moveToBackground();
243
+ else timer = setTimeout(moveToBackground, windowMs);
244
+ void current.done.then(() => {
245
+ if (current.background) return;
246
+ cleanup();
247
+ // Its output reaches the model through this tool result.
248
+ current.delivered = true;
249
+ if (current.status === "killed") reject(new Error("aborted"));
250
+ else if (current.status === "failed") reject(new Error(current.error ?? "command failed"));
251
+ else resolve({ exitCode: current.exitCode });
252
+ });
253
+ }),
254
+ };
255
+
256
+ const definition = createBashToolDefinition(ctx?.cwd ? String(ctx.cwd) : process.cwd(), { operations: operations as any });
257
+ try {
258
+ return await definition.execute(toolCallId, { command: params?.command }, signal, onUpdate, ctx);
259
+ } catch (error) {
260
+ const moved = job as ShellJob | null;
261
+ if (!(error instanceof MovedToBackground) || !moved) throw error;
262
+ const hint = "Its result arrives as a message in this conversation when it finishes (no need to poll). bash_output reads its new output, bash_wait waits for it, bash_kill stops it.";
263
+ let text: string;
264
+ if (explicit) {
265
+ text = `Started in the background as ${moved.id}: ${shortCommand(moved.command, 200)}\n${hint}`;
266
+ } else {
267
+ const soFar = tailText(moved.output.since(0).text);
268
+ text = [
269
+ `Still running after ${Math.round(windowMs / 1000)} s — moved to the background as ${moved.id} (not killed).`,
270
+ hint,
271
+ "",
272
+ "Output so far:",
273
+ `${soFar.truncated ? "[earlier output omitted]\n" : ""}${soFar.text.trim() ? soFar.text.replace(/\s+$/, "") : "(no output yet)"}`,
274
+ ].join("\n");
275
+ }
276
+ return {
277
+ content: [{ type: "text", text }],
278
+ details: { background: { id: moved.id, command: moved.command, explicit, started_at: moved.startedAt } },
279
+ };
280
+ }
281
+ },
282
+ });
283
+
284
+ // --- companion tools -----------------------------------------------------------
285
+
286
+ const IdsParam = Type.Optional(Type.Array(Type.String(), {
287
+ description: 'Background command ids (e.g. "sh2"); omit (or "all") for every background command of this session',
288
+ }));
289
+
290
+ const readResult = (jobs: ShellJob[], unknown: string[]) => {
291
+ const parts = collect(jobs);
292
+ const sections = parts.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text);
293
+ if (unknown.length) sections.push(`Unknown ids: ${unknown.join(", ")}`);
294
+ return {
295
+ content: [{ type: "text", text: sections.join("\n\n---\n\n") || "No background commands in this session." }],
296
+ details: {
297
+ jobs: parts.map(({ job, text, dropped }) => {
298
+ const shown = tailText(text);
299
+ return jobDetails(job, shown.text, shown.truncated || dropped > 0);
300
+ }),
301
+ },
302
+ };
303
+ };
304
+
305
+ pi.registerTool({
306
+ name: "bash_output",
307
+ label: "bash output",
308
+ description: "A background command's output since you last read it, and its status (running, or its exit code). Without an id: the status of every background command of this session. Results are delivered automatically when a command finishes — use this to check progress, not to poll in a loop.",
309
+ promptSnippet: "bash_output: new output and status of a background command",
310
+ parameters: Type.Object({
311
+ id: Type.Optional(Type.String({ description: 'Background command id (e.g. "sh2"); omit for the status of all' })),
312
+ }),
313
+ async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
314
+ const session = hub.sessionOf(ctx) || "session";
315
+ const id = typeof params?.id === "string" ? params.id.trim() : "";
316
+ if (!id || id === "all") {
317
+ const jobs = manager.resolve(session, []).jobs;
318
+ return {
319
+ content: [{ type: "text", text: renderJobStatus(jobs) }],
320
+ details: { jobs: jobs.map((job) => ({ id: job.id, command: job.command, status: job.status, exit_code: job.exitCode })) },
321
+ };
322
+ }
323
+ const { jobs, unknown } = manager.resolve(session, [id]);
324
+ return readResult(jobs, unknown);
325
+ },
326
+ });
327
+
328
+ pi.registerTool({
329
+ name: "bash_wait",
330
+ label: "bash wait",
331
+ description: "Wait until background commands finish (default: every running one) and return their new output and exit codes. timeout_seconds bounds the WAIT only — the commands keep running past it.",
332
+ promptSnippet: "bash_wait: wait for background commands to finish",
333
+ parameters: Type.Object({
334
+ ids: IdsParam,
335
+ timeout_seconds: Type.Optional(Type.Number({ description: `Leave out (or 0) to wait until they finish (at most ${MAX_WAIT_SECONDS} s per call); otherwise stop WAITING after this many seconds` })),
336
+ }),
337
+ async execute(_toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
338
+ const session = hub.sessionOf(ctx) || "session";
339
+ const explicit = Array.isArray(params?.ids) && params.ids.length > 0 && !params.ids.includes("all");
340
+ const resolved = manager.resolve(session, params?.ids);
341
+ const jobs = explicit ? resolved.jobs : resolved.jobs.filter((job) => job.status === "running" || !job.delivered);
342
+ if (jobs.length === 0 && resolved.unknown.length === 0) {
343
+ return { content: [{ type: "text", text: "No background commands to wait for." }], details: { jobs: [] } };
344
+ }
345
+ const seconds = typeof params?.timeout_seconds === "number" && params.timeout_seconds > 0
346
+ ? Math.min(params.timeout_seconds, MAX_WAIT_SECONDS)
347
+ : MAX_WAIT_SECONDS;
348
+ onUpdate?.({ content: [{ type: "text", text: `bash_wait: waiting for ${jobs.filter((job) => job.status === "running").map((job) => job.id).join(", ") || "nothing"}` }], details: {} });
349
+ await manager.wait(jobs, { timeoutMs: seconds * 1000, ...(signal ? { signal } : {}) });
350
+ return readResult(jobs, resolved.unknown);
351
+ },
352
+ });
353
+
354
+ pi.registerTool({
355
+ name: "bash_kill",
356
+ label: "bash kill",
357
+ description: 'Stop background commands (the whole process tree) by id, or ["all"]; returns their last output.',
358
+ promptSnippet: "bash_kill: stop background commands",
359
+ parameters: Type.Object({
360
+ ids: Type.Array(Type.String(), { description: 'Background command ids, or ["all"]', minItems: 1 }),
361
+ }),
362
+ async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
363
+ const session = hub.sessionOf(ctx) || "session";
364
+ const { jobs, unknown } = manager.resolve(session, params?.ids);
365
+ const live = jobs.filter((job) => job.status === "running");
366
+ await manager.kill(live);
367
+ if (live.length === 0) {
368
+ const done = jobs.map((job) => `${job.id} already ${jobOutcome(job)}`);
369
+ return {
370
+ content: [{ type: "text", text: `Nothing to stop${done.length ? ` (${done.join(", ")})` : ""}${unknown.length ? `; unknown ids: ${unknown.join(", ")}` : ""}.` }],
371
+ details: { jobs: [] },
372
+ };
373
+ }
374
+ return readResult(live, unknown);
375
+ },
376
+ });
377
+
378
+ pi.registerCommand("jobs", {
379
+ description: "Background shell commands of this session; /jobs kill <id|all> stops them",
380
+ handler: async (args: string, ctx: any) => {
381
+ const session = hub.sessionOf(ctx) || "session";
382
+ const words = String(args ?? "").trim().split(/\s+/).filter(Boolean);
383
+ if (words[0] === "kill") {
384
+ const { jobs } = manager.resolve(session, words.slice(1).length ? words.slice(1) : ["all"]);
385
+ const stopped = await manager.kill(jobs.filter((job) => job.status === "running"), { deliver: true });
386
+ ctx?.ui?.notify?.(stopped.length ? `Stopped ${stopped.map((job) => job.id).join(", ")}.` : "No running background commands.");
387
+ return;
388
+ }
389
+ ctx?.ui?.notify?.(renderJobStatus(manager.resolve(session, []).jobs));
390
+ },
391
+ });
392
+ }
@@ -1689,11 +1689,16 @@ export async function readSessionLedger(path: string | null): Promise<SessionLed
1689
1689
  }
1690
1690
  }
1691
1691
 
1692
+ /**
1693
+ * The trace document (scrubbed, cut to `maxBytes`) as JSON text, plus the
1694
+ * events it carries (the same values the text encodes): the envelope's
1695
+ * "trace" array is written from them without parsing the text back.
1696
+ */
1692
1697
  function boundedTracePayload(
1693
1698
  state: Pick<SessionState, "id" | "workspaceId" | "segment" | "resumed">,
1694
1699
  events: TraceEvent[],
1695
1700
  maxBytes = MAX_TRACE_BYTES,
1696
- ): Buffer {
1701
+ ): { content: string; events: unknown[] } {
1697
1702
  // The trace is scrubbed as a JSON value, never as serialised text: a text
1698
1703
  // redaction inside an escaped string (`\n@app.function`) once left a
1699
1704
  // dangling backslash that made the whole document unparseable. Header and
@@ -1707,12 +1712,15 @@ function boundedTracePayload(
1707
1712
  session_resumed: state.resumed,
1708
1713
  events,
1709
1714
  }).value as Record<string, unknown> & { events: TraceEvent[] };
1710
- const encode = (selected: TraceEvent[], truncated: boolean, includedCount = selected.length) => Buffer.from(JSON.stringify({
1711
- ...header,
1712
- trace_truncated: truncated,
1713
- dropped_event_count: Math.max(0, events.length - includedCount),
1714
- events: selected,
1715
- }));
1715
+ const encode = (selected: TraceEvent[], truncated: boolean, includedCount = selected.length) => {
1716
+ const content = JSON.stringify({
1717
+ ...header,
1718
+ trace_truncated: truncated,
1719
+ dropped_event_count: Math.max(0, events.length - includedCount),
1720
+ events: selected,
1721
+ });
1722
+ return { content, events: selected as unknown[], byteLength: Buffer.byteLength(content) };
1723
+ };
1716
1724
 
1717
1725
  // One encode settles the common case. Only an oversized trace pays for the
1718
1726
  // search, and that search measures each event once: the payload of a prefix
@@ -1721,7 +1729,7 @@ function boundedTracePayload(
1721
1729
  // probe used to be a full serialisation of the prefix, which made a flush
1722
1730
  // at the MAX_COLLECTOR_TRACE_EVENTS cap peak at many times the payload.
1723
1731
  let payload = encode(scrubbed, false);
1724
- if (payload.byteLength <= maxBytes) return payload;
1732
+ if (payload.byteLength <= maxBytes) return { content: payload.content, events: payload.events };
1725
1733
  const wrapperBytes = (included: number) => Buffer.byteLength(JSON.stringify({
1726
1734
  ...header,
1727
1735
  trace_truncated: true,
@@ -1749,9 +1757,9 @@ function boundedTracePayload(
1749
1757
  data: { dropped_event_count: events.length },
1750
1758
  };
1751
1759
  const marked = encode([marker], true, 0);
1752
- if (marked.byteLength <= maxBytes) return marked;
1760
+ if (marked.byteLength <= maxBytes) return { content: marked.content, events: marked.events };
1753
1761
  }
1754
- return payload;
1762
+ return { content: payload.content, events: payload.events };
1755
1763
  }
1756
1764
 
1757
1765
  /** Eligible files plus the number of non-ignored paths the denylist dropped. */
@@ -2837,6 +2845,45 @@ const utf8Encoder = new TextEncoder();
2837
2845
  * entry exists in memory at a time, and writing allocates no buffer per chunk.
2838
2846
  * The compressed result is read back from disk only for the upload.
2839
2847
  */
2848
+ /** Strings longer than this are escaped and written a piece at a time. */
2849
+ const JSON_STRING_PIECE_CHARS = 1 << 20;
2850
+
2851
+ /**
2852
+ * Writes `value` as JSON, byte for byte what JSON.stringify(value) gives,
2853
+ * without building the whole text: a plain object's long string values (a
2854
+ * file's content, up to MAX_COLLECTOR_FILE_BYTES; the trace document, up to
2855
+ * MAX_TRACE_BYTES) are escaped in pieces, so no escaped copy of them exists
2856
+ * at once. Anything else goes through JSON.stringify.
2857
+ */
2858
+ export async function writeJsonStreaming(writer: { write(text: string): Promise<void> }, value: unknown): Promise<void> {
2859
+ if (typeof value === "string" && value.length > JSON_STRING_PIECE_CHARS) {
2860
+ await writer.write('"');
2861
+ for (let at = 0; at < value.length;) {
2862
+ let end = Math.min(value.length, at + JSON_STRING_PIECE_CHARS);
2863
+ // Never split a surrogate pair: each half alone would be escaped.
2864
+ const last = value.charCodeAt(end - 1);
2865
+ if (end < value.length && last >= 0xd800 && last <= 0xdbff) end -= 1;
2866
+ await writer.write(JSON.stringify(value.slice(at, end)).slice(1, -1));
2867
+ at = end;
2868
+ }
2869
+ await writer.write('"');
2870
+ return;
2871
+ }
2872
+ if (value && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype && typeof (value as { toJSON?: unknown }).toJSON !== "function") {
2873
+ await writer.write("{");
2874
+ let first = true;
2875
+ for (const [key, item] of Object.entries(value)) {
2876
+ if (item === undefined || typeof item === "function" || typeof item === "symbol") continue;
2877
+ await writer.write(`${first ? "" : ","}${JSON.stringify(key)}:`);
2878
+ first = false;
2879
+ await writeJsonStreaming(writer, item);
2880
+ }
2881
+ await writer.write("}");
2882
+ return;
2883
+ }
2884
+ await writer.write(JSON.stringify(value) ?? "null");
2885
+ }
2886
+
2840
2887
  class EnvelopeWriter {
2841
2888
  /** Uncompressed bytes written so far. */
2842
2889
  bytes = 0;
@@ -4105,6 +4152,17 @@ export class WorkspaceCollector {
4105
4152
  });
4106
4153
  }
4107
4154
 
4155
+ /**
4156
+ * Resolves once the session's queued uploads (traces, snapshots) went out:
4157
+ * a caller recording a lot at once (every sub-agent of a turn) waits here
4158
+ * between flushes, so only one flush's events wait in memory at a time.
4159
+ */
4160
+ async drained(sessionId: string): Promise<void> {
4161
+ const state = this.sessions.get(sessionId);
4162
+ if (!state) return;
4163
+ await state.tail.catch(() => undefined);
4164
+ }
4165
+
4108
4166
  /** Resolves once every queued capture and upload for the session has settled, and its watchers are up. */
4109
4167
  async idle(sessionId: string): Promise<void> {
4110
4168
  const state = this.sessions.get(sessionId);
@@ -4558,15 +4616,10 @@ export class WorkspaceCollector {
4558
4616
 
4559
4617
  private async uploadTrace(state: SessionState, traceEvents: TraceEvent[]): Promise<void> {
4560
4618
  if (traceEvents.length === 0) return;
4561
- const bounded = boundedTracePayload(state, traceEvents, MAX_TRACE_BYTES);
4562
- const content = bounded.toString("utf8");
4563
- let events: unknown[] = [];
4564
- try {
4565
- const parsed = JSON.parse(content) as { events?: unknown };
4566
- if (Array.isArray(parsed.events)) events = parsed.events;
4567
- } catch {
4568
- events = [];
4569
- }
4619
+ // The text is uploaded as the trace file and its events as the envelope's
4620
+ // trace (written event by event); neither is copied or parsed back.
4621
+ const { content, events } = boundedTracePayload(state, traceEvents, MAX_TRACE_BYTES);
4622
+ traceEvents.length = 0;
4570
4623
  await this.uploadEnvelope(state, "trace", "trace_flush", {
4571
4624
  extras: {
4572
4625
  workspace: { root_name: workspaceRootName(state.root), git: null },
@@ -4600,7 +4653,8 @@ export class WorkspaceCollector {
4600
4653
  "Content-Type": "application/zstd",
4601
4654
  "X-OmniRush-Session-ID": sessionId,
4602
4655
  },
4603
- body: compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength) as ArrayBuffer,
4656
+ // The bytes as they are (a view, not a copy of up to 64 MiB).
4657
+ body: compressed as Uint8Array<ArrayBuffer>,
4604
4658
  signal,
4605
4659
  });
4606
4660
  }
@@ -4731,7 +4785,8 @@ export class WorkspaceCollector {
4731
4785
  });
4732
4786
  await writer.write(`${head.slice(0, -1)},"files":[`);
4733
4787
  await body.writeFiles(async (file) => {
4734
- await writer.write(`${fileCount > 0 ? "," : ""}${JSON.stringify(file)}`);
4788
+ if (fileCount > 0) await writer.write(",");
4789
+ await writeJsonStreaming(writer, file);
4735
4790
  fileCount += 1;
4736
4791
  });
4737
4792
  await writer.write('],"manifest":[');
@@ -4741,7 +4796,16 @@ export class WorkspaceCollector {
4741
4796
  first = false;
4742
4797
  }
4743
4798
  await writer.write(`],"privacy":${JSON.stringify(body.privacy())}`);
4744
- if (body.trace) await writer.write(`,"trace":${JSON.stringify(body.trace)}`);
4799
+ if (Array.isArray(body.trace)) {
4800
+ // Event by event: the same text as one JSON.stringify of the array.
4801
+ await writer.write(',"trace":[');
4802
+ for (let index = 0; index < body.trace.length; index += 1) {
4803
+ await writer.write(`${index > 0 ? "," : ""}${JSON.stringify(body.trace[index]) ?? "null"}`);
4804
+ }
4805
+ await writer.write("]");
4806
+ } else if (body.trace) {
4807
+ await writer.write(`,"trace":${JSON.stringify(body.trace)}`);
4808
+ }
4745
4809
  await writer.write("}");
4746
4810
  compressedBytes = await writer.finish();
4747
4811
  } catch (error) {