omnirush 0.8.3 → 0.8.5

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.
@@ -36,10 +36,11 @@
36
36
  // signed-in omnirush identities.
37
37
 
38
38
  import { createHash } from "node:crypto";
39
- import { readdirSync, readFileSync, rmSync } from "node:fs";
40
- import { readdir, readFile, stat } from "node:fs/promises";
39
+ import { createReadStream, readdirSync, readFileSync, rmSync } from "node:fs";
40
+ import { readdir, stat } from "node:fs/promises";
41
41
  import os from "node:os";
42
42
  import path from "node:path";
43
+ import { createInterface } from "node:readline";
43
44
 
44
45
  import { gatewayUrlForOrigin, omniDir, resolveOrigin } from "./auth";
45
46
  import { WorkspaceCollector } from "./capture/workspace-collector";
@@ -50,13 +51,13 @@ import {
50
51
  engineMessagesFromEntries,
51
52
  MAX_TURN_MESSAGE_BYTES,
52
53
  messageParts,
53
- parseSessionFile,
54
54
  turnHistory,
55
55
  turnModelFromMessages,
56
56
  type EngineMessage,
57
57
  type PiEntry,
58
58
  } from "./pi-engine";
59
59
  import { sharedRefresher } from "./refresh";
60
+ import { AGENTS_HOOK, type AgentsHook } from "./agents-lib";
60
61
 
61
62
  /** info.agent of the root session's messages (a sub-agent's is its role). */
62
63
  export const ROOT_AGENT = "omnirush-cli";
@@ -158,6 +159,32 @@ async function findSessionFile(sessionId: string, dirs: readonly string[]): Prom
158
159
  return null;
159
160
  }
160
161
 
162
+ /** Parse a session JSONL file without buffering the raw transcript or its line array. */
163
+ export async function parseSessionFileStream(file: string): Promise<{ header: PiEntry | null; entries: PiEntry[] }> {
164
+ let header: PiEntry | null = null;
165
+ const entries: PiEntry[] = [];
166
+ const input = createReadStream(file, { encoding: "utf8" });
167
+ const lines = createInterface({ input, crlfDelay: Infinity });
168
+ try {
169
+ for await (const line of lines) {
170
+ if (!line.trim()) continue;
171
+ let value: unknown;
172
+ try {
173
+ value = JSON.parse(line);
174
+ } catch {
175
+ continue;
176
+ }
177
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
178
+ if ((value as { type?: unknown }).type === "session") header ??= value as PiEntry;
179
+ else entries.push(value as PiEntry);
180
+ }
181
+ } finally {
182
+ lines.close();
183
+ input.destroy();
184
+ }
185
+ return { header, entries };
186
+ }
187
+
161
188
  type Collector = Pick<
162
189
  WorkspaceCollector,
163
190
  "recordTrace" | "recordChildSession" | "childCheckpoints" | "childSessionIds"
@@ -195,7 +222,7 @@ export async function captureChildSessions(input: {
195
222
  const stats = await stat(file).catch(() => null);
196
223
  const signature = stats ? `${stats.size}:${stats.mtimeMs}` : "";
197
224
  if (signature && input.seenFiles.get(child.id) === signature && input.known.has(child.id)) continue;
198
- const parsed = parseSessionFile(await readFile(file, "utf8").catch(() => ""));
225
+ const parsed = await parseSessionFileStream(file).catch(() => ({ header: null, entries: [] }));
199
226
  entries = branchOfEntries(parsed.entries);
200
227
  cwd = typeof parsed.header?.cwd === "string" ? parsed.header.cwd : "";
201
228
  if (signature) input.seenFiles.set(child.id, signature);
@@ -377,6 +404,32 @@ export default function (pi: any) {
377
404
  return session;
378
405
  };
379
406
 
407
+ /**
408
+ * The sub-agent tree's messages since their checkpoints. Background
409
+ * sub-agents keep running past the turn that started them: each later
410
+ * settle (and the session's end) records what they did since.
411
+ */
412
+ const captureChildren = async (state: NonNullable<typeof session>, room: (bytes: number) => void): Promise<void> => {
413
+ const { id } = state;
414
+ try {
415
+ await captureChildSessions({
416
+ collector,
417
+ rootSessionId: id,
418
+ parentSessionId: id,
419
+ parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
420
+ depth: 1,
421
+ sessionDirs: sessionDirs(state.ctx),
422
+ checkpoints: await collector.childCheckpoints(id),
423
+ known: new Set(await collector.childSessionIds(id)),
424
+ visited: new Set([id]),
425
+ seenFiles: state.seenFiles,
426
+ beforeRecord: room,
427
+ });
428
+ } catch (error) {
429
+ collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
430
+ }
431
+ };
432
+
380
433
  /**
381
434
  * Everything a settled run records, in the desktop observer's order:
382
435
  * messages, sub-agents, the idle event, the snapshot, the trace and the
@@ -418,23 +471,7 @@ export default function (pi: any) {
418
471
  }
419
472
  pending += bytes;
420
473
  };
421
- try {
422
- await captureChildSessions({
423
- collector,
424
- rootSessionId: id,
425
- parentSessionId: id,
426
- parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
427
- depth: 1,
428
- sessionDirs: sessionDirs(state.ctx),
429
- checkpoints: await collector.childCheckpoints(id),
430
- known: new Set(await collector.childSessionIds(id)),
431
- visited: new Set([id]),
432
- seenFiles: state.seenFiles,
433
- beforeRecord: room,
434
- });
435
- } catch (error) {
436
- collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
437
- }
474
+ await captureChildren(state, room);
438
475
  room(newest.bytes);
439
476
  collector.recordTrace(id, "session.idle", { status });
440
477
  // The turn snapshot runs first so the artifacts it discovers are part of
@@ -459,11 +496,34 @@ export default function (pi: any) {
459
496
  * settled too, so they reach the trace.
460
497
  */
461
498
  const settleRemaining = async (): Promise<void> => {
499
+ // Background sub-agents still running are stopped first (their session
500
+ // files are complete once they exited) and marked interrupted.
501
+ const hook = (globalThis as any)[AGENTS_HOOK] as AgentsHook | undefined;
502
+ const followed = session;
503
+ const interrupted = followed?.started && hook ? await hook.interruptAll(followed.id).catch(() => []) : [];
462
504
  await settleRunning("interrupted");
463
505
  const state = session;
464
506
  if (!state?.started) return;
507
+ for (const child of interrupted) {
508
+ collector.recordTrace(state.id, "session.child_interrupted", { child_session_id: child.sessionId, agent: child.role });
509
+ }
465
510
  const lastId = messagesOf(state).at(-1)?.info.id;
466
- if (typeof lastId !== "string" || lastId === state.lastMessageId) return;
511
+ if (typeof lastId !== "string" || lastId === state.lastMessageId) {
512
+ // No new message of its own: what its sub-agents did since the last settle.
513
+ settling = settling.then(async () => {
514
+ let pending = 0;
515
+ await captureChildren(state, (bytes) => {
516
+ if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
517
+ collector.flushTrace(state.id);
518
+ pending = 0;
519
+ }
520
+ pending += bytes;
521
+ });
522
+ collector.flushTrace(state.id);
523
+ }).catch(() => undefined);
524
+ await settling;
525
+ return;
526
+ }
467
527
  settling = settling.then(() => settle(state, "idle")).catch(() => undefined);
468
528
  await settling;
469
529
  };
@@ -565,6 +625,13 @@ export default function (pi: any) {
565
625
  archive.turnFollowed(state.id);
566
626
  });
567
627
 
628
+ // A run that no prompt started (a background sub-agent's results
629
+ // delivered as a message start one): it settles like any other.
630
+ pi.on("agent_start", (_event: any, ctx: any) => {
631
+ const state = follow(ctx, false);
632
+ if (state?.started) state.running = true;
633
+ });
634
+
568
635
  pi.on("tool_execution_start", (event: any) => {
569
636
  if (session && typeof event?.toolCallId === "string") session.toolStarts.set(event.toolCallId, Date.now());
570
637
  });
@@ -138,3 +138,132 @@ export function sanitizeGatewayErrorBody({
138
138
  const message = friendlyGatewayMessage(status, ref, attempts, waitedMs);
139
139
  return { bodyText: message, sanitized: true };
140
140
  }
141
+
142
+ // --- stream stall watchdog -------------------------------------------------
143
+ //
144
+ // The agent core runs on Bun, and there its undici transport never
145
+ // settles a pending body read when the connection dies mid-stream: the
146
+ // HTTP idle timeout (or the gateway/edge dropping the socket) closes the
147
+ // socket, but `reader.read()` stays pending forever. The turn then sits
148
+ // on "Working…" until the user presses Esc — no error, no auto-retry
149
+ // (field report: session 01a0dd1a, a 13-minute hang after a tool call).
150
+ // On Node the same close surfaces as a `terminated` error that pi retries.
151
+ // The watchdog gives Bun that behavior: it re-exposes the body through a
152
+ // stream of our own and errors it when the upstream read has been
153
+ // pending for `idleMs`, so pi's retry takes over.
154
+
155
+ /** setTimeout's ceiling; pi passes it (2^31-1) for a disabled timeout. */
156
+ const MAX_TIMER_MS = 2_147_483_647;
157
+ export const DEFAULT_STREAM_IDLE_MS = 300_000;
158
+
159
+ /**
160
+ * The idle limit for a model stream: OMNIRUSH_STREAM_IDLE_MS wins, then
161
+ * the request timeout pi hands the provider (its httpIdleTimeoutMs /
162
+ * retry.provider.timeoutMs), then 5 minutes. 0 (or a value at the timer
163
+ * ceiling, pi's "disabled") turns the watchdog off.
164
+ */
165
+ export function streamIdleMs(envValue: string | undefined, requestTimeoutMs: unknown): number {
166
+ const pick = (value: unknown): number | undefined => {
167
+ if (value === undefined || value === null || value === "") return undefined;
168
+ const n = Number(value);
169
+ if (!Number.isFinite(n) || n < 0) return undefined;
170
+ return n >= MAX_TIMER_MS ? 0 : Math.floor(n);
171
+ };
172
+ return pick(envValue) ?? pick(requestTimeoutMs) ?? DEFAULT_STREAM_IDLE_MS;
173
+ }
174
+
175
+ /** The error a stalled stream fails with ("timed out" keeps it retryable for pi). */
176
+ export function streamStallError(idleMs: number): Error {
177
+ const seconds = Math.max(1, Math.round(idleMs / 1000));
178
+ const error = new Error(
179
+ `Omnirush model stream timed out: no data from the gateway for ${seconds}s — retrying`,
180
+ );
181
+ error.name = "OmnirushStreamStall";
182
+ return error;
183
+ }
184
+
185
+ /**
186
+ * Re-expose `source` as a new ReadableStream that errors with
187
+ * `streamStallError` when one upstream read stays pending for `idleMs`
188
+ * (0 = never), and with the abort reason when `signal` aborts. Every
189
+ * chunk is handed to `onChunk` before it is enqueued. The upstream
190
+ * reader is cancelled (best effort, never awaited — it may be the hung
191
+ * one) whenever the wrapper errors or is cancelled.
192
+ */
193
+ export function withStallWatchdog(
194
+ source: ReadableStream<any>,
195
+ {
196
+ idleMs,
197
+ signal,
198
+ onChunk,
199
+ }: { idleMs: number; signal?: AbortSignal | null; onChunk?: (chunk: any) => void },
200
+ ): ReadableStream<any> {
201
+ const reader = source.getReader();
202
+ let controller: ReadableStreamDefaultController<any>;
203
+ let finished = false;
204
+ let timer: ReturnType<typeof setTimeout> | undefined;
205
+
206
+ const clearTimer = () => {
207
+ if (timer !== undefined) clearTimeout(timer);
208
+ timer = undefined;
209
+ };
210
+ const finish = () => {
211
+ finished = true;
212
+ clearTimer();
213
+ signal?.removeEventListener?.("abort", onAbort);
214
+ };
215
+ const fail = (error: unknown) => {
216
+ if (finished) return;
217
+ finish();
218
+ try {
219
+ controller.error(error);
220
+ } catch {
221
+ /* already closed */
222
+ }
223
+ try {
224
+ reader.cancel(error).catch(() => undefined);
225
+ } catch {
226
+ /* already released */
227
+ }
228
+ };
229
+ function onAbort() {
230
+ const reason = (signal as any)?.reason;
231
+ fail(reason ?? Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
232
+ }
233
+
234
+ return new ReadableStream<any>({
235
+ start(c) {
236
+ controller = c;
237
+ if (signal?.aborted) onAbort();
238
+ else signal?.addEventListener?.("abort", onAbort, { once: true });
239
+ },
240
+ async pull(c) {
241
+ if (finished) return;
242
+ if (idleMs > 0) timer = setTimeout(() => fail(streamStallError(idleMs)), Math.min(idleMs, MAX_TIMER_MS - 1));
243
+ let result: ReadableStreamReadResult<any>;
244
+ try {
245
+ result = await reader.read();
246
+ } catch (error) {
247
+ fail(error);
248
+ return;
249
+ }
250
+ clearTimer();
251
+ if (finished) return;
252
+ if (result.done) {
253
+ finish();
254
+ c.close();
255
+ return;
256
+ }
257
+ try {
258
+ onChunk?.(result.value);
259
+ } catch {
260
+ /* observers never break the stream */
261
+ }
262
+ c.enqueue(result.value);
263
+ },
264
+ cancel(reason) {
265
+ finish();
266
+ return reader.cancel(reason).catch(() => undefined);
267
+ },
268
+ });
269
+ }
@@ -14,6 +14,9 @@
14
14
  // - on 429 / 5xx / network failures, retries with exponential backoff
15
15
  // + full jitter (shared retry layer, "attempt 2/5" progress on
16
16
  // stderr) so users ride out backend deploys.
17
+ // - fails a model stream that stops delivering data (stall watchdog)
18
+ // with a retryable error, so a dropped connection never leaves the
19
+ // turn hanging on Bun (pi retries it like on Node).
17
20
  // - SANITIZES every non-OK response body: raw upstream provider text
18
21
  // ("help.openai.com… request ID…") never reaches the user — the
19
22
  // agent layer gets an omnirush message with a local correlation id
@@ -34,6 +37,8 @@ import {
34
37
  isSotaViolation,
35
38
  sanitizeGatewayErrorBody,
36
39
  servedModelFromSseData,
40
+ streamIdleMs,
41
+ withStallWatchdog,
37
42
  } from "./sota-lib";
38
43
  import {
39
44
  formatUnavailableSummary,
@@ -209,12 +214,15 @@ async function readErrorBody(response: any): Promise<string> {
209
214
  * Retries only cover the request up to (and including) the response
210
215
  * headers — once an OK response body starts streaming it is forwarded
211
216
  * untouched; a mid-stream failure surfaces as the provider's own error.
217
+ * A body that stops delivering data for `idleMs` fails with a retryable
218
+ * "timed out" error (stall watchdog, see sota-lib) instead of hanging.
212
219
  * Client aborts (Esc) are never retried.
213
220
  */
214
221
  function tapFetch(
215
222
  baseFetch: any,
216
223
  expectedModelId: string,
217
224
  onViolation: (expected: string, served: string) => void,
225
+ idleMs: number,
218
226
  ): any {
219
227
  return async (input: any, init: any) => {
220
228
  const requestUrl = typeof input === "string" ? input : input?.url;
@@ -353,13 +361,11 @@ function tapFetch(
353
361
  };
354
362
  const scanner = createSseDataScanner(onData);
355
363
  const decoder = new TextDecoder();
356
- const passthrough = new TransformStream({
357
- transform(chunk: any, controller: any) {
358
- controller.enqueue(chunk);
359
- scanner(decoder.decode(chunk, { stream: true }));
360
- },
364
+ const body = withStallWatchdog(response.body, {
365
+ idleMs,
366
+ signal: init?.signal,
367
+ onChunk: (chunk: any) => scanner(decoder.decode(chunk, { stream: true })),
361
368
  });
362
- const body = response.body.pipeThrough(passthrough);
363
369
  return new Response(body, {
364
370
  status: response.status,
365
371
  statusText: response.statusText,
@@ -389,8 +395,11 @@ export default function (pi: any) {
389
395
  const baseFetch = options?.fetch ?? globalThis.fetch;
390
396
  const tapped = {
391
397
  ...(options ?? {}),
392
- fetch: tapFetch(baseFetch, model.id, (expected, served) =>
393
- reportViolation(strictSota(), expected, served),
398
+ fetch: tapFetch(
399
+ baseFetch,
400
+ model.id,
401
+ (expected, served) => reportViolation(strictSota(), expected, served),
402
+ streamIdleMs(process.env.OMNIRUSH_STREAM_IDLE_MS, options?.timeoutMs),
394
403
  ),
395
404
  };
396
405
  return simple
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omnirush",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Omnirush \u2014 free daily tokens for the most powerful coding model on earth.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -41,4 +41,4 @@
41
41
  "minimatch": "^10.2.6",
42
42
  "zod": "^4.6.5"
43
43
  }
44
- }
44
+ }