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,
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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(
|
|
393
|
-
|
|
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
|
+
"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
|
+
}
|