omnirush 0.10.0 → 0.10.2
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.
- package/assets/extensions/omnirush/agents-lib.ts +18 -6
- package/assets/extensions/omnirush/agents.ts +7 -4
- package/assets/extensions/omnirush/auth.js +3 -2
- package/assets/extensions/omnirush/bgshell.ts +6 -6
- package/assets/extensions/omnirush/capture/UPSTREAM +7 -4
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +2 -2
- package/assets/extensions/omnirush/capture/server-fetch.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +3 -3
- package/assets/extensions/omnirush/capture/session-archive/files.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +2 -2
- package/assets/extensions/omnirush/capture/session-archive/index.ts +8 -8
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +4 -4
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +2 -2
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +3 -3
- package/assets/extensions/omnirush/capture/{collect-upload-budget.ts → sync-upload-budget.ts} +5 -5
- package/assets/extensions/omnirush/capture/turn-diff.ts +6 -6
- package/assets/extensions/omnirush/capture/{workspace-collector.ts → workspace-sync.ts} +253 -204
- package/assets/extensions/omnirush/commands.ts +6 -6
- package/assets/extensions/omnirush/{pi-engine.ts → engine-messages.ts} +406 -52
- package/assets/extensions/omnirush/index.ts +7 -3
- package/assets/extensions/omnirush/plan.ts +2 -2
- package/assets/extensions/omnirush/refresh.ts +1 -1
- package/assets/extensions/omnirush/retry.js +1 -1
- package/assets/extensions/omnirush/{collector.ts → session-sync.ts} +54 -54
- package/assets/extensions/omnirush/status-lib.ts +9 -9
- package/assets/extensions/omnirush/stream-timing.ts +140 -0
- package/assets/extensions/omnirush/subagent-marker.ts +4 -4
- package/assets/extensions/omnirush/swarm-lib.ts +2 -2
- package/assets/extensions/omnirush/voice.ts +1 -1
- package/assets/{collect-once.ts → sync-once.ts} +20 -19
- package/package.json +2 -2
- package/src/bin.js +20 -8
- package/src/sessions.js +3 -3
- package/scripts/__pycache__/build-all-packages.cpython-38.pyc +0 -0
- package/scripts/__pycache__/smoke-packages.cpython-38.pyc +0 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// A sub-agent (a headless spawn_agents child: OMNIRUSH_PARENT_SESSION is
|
|
10
10
|
// set) does not load what only the interactive top agent uses — the slash
|
|
11
|
-
// commands (commands), the session capture (
|
|
11
|
+
// commands (commands), the session capture (session uploader: the parent records
|
|
12
12
|
// its children) and voice input (TUI only) — so each of the many child
|
|
13
13
|
// processes a fan-out starts stays smaller. They are imported on demand,
|
|
14
14
|
// in the same order as before, so the top agent registers exactly what it
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import recorder from "./recorder";
|
|
19
|
+
import recordStreamTiming from "./stream-timing";
|
|
19
20
|
import sota from "./sota";
|
|
20
21
|
import mcp from "./mcp";
|
|
21
22
|
import agents from "./agents";
|
|
@@ -37,9 +38,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
37
38
|
const subagent = isSubagentProcess();
|
|
38
39
|
if (!subagent) (await import("./commands")).default(pi as any);
|
|
39
40
|
recorder(pi as any);
|
|
41
|
+
// Every process times its own runs (the 2.x trace fields); before the
|
|
42
|
+
// session uploader, so a settled run's idle entry is written before it is read.
|
|
43
|
+
recordStreamTiming(pi as any);
|
|
40
44
|
sota(pi as any);
|
|
41
|
-
if (!subagent) (await import("./
|
|
42
|
-
// Children still mark their own session as a sub-agent's (the
|
|
45
|
+
if (!subagent) (await import("./session-sync")).default(pi as any);
|
|
46
|
+
// Children still mark their own session as a sub-agent's (the session uploader
|
|
43
47
|
// does that for the top agent's sessions; children do not load it).
|
|
44
48
|
else (await import("./subagent-marker")).default(pi as any);
|
|
45
49
|
mcp(pi as any);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The model maintains an ordered task list (items with pending /
|
|
4
4
|
// in-progress / done statuses) while it works. Every update:
|
|
5
|
-
// - persists to <cwd>/__agent__/plan.json (the workspace
|
|
5
|
+
// - persists to <cwd>/__agent__/plan.json (the workspace uploader
|
|
6
6
|
// ships it with the workspace snapshots),
|
|
7
7
|
// - appends an `omnirush.plan` custom entry to the session JSONL (the
|
|
8
8
|
// tool call itself, with the whole list, reaches the trace as a tool
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
const PLAN_TOOL_NAME = "plan";
|
|
42
42
|
const PLAN_ENTRY_TYPE = "omnirush.plan";
|
|
43
43
|
const PLAN_STATUS_KEY = "omnirush-plan";
|
|
44
|
-
// The plan file's folder in the workspace (the CLI's earlier
|
|
44
|
+
// The plan file's folder in the workspace (the CLI's earlier uploader
|
|
45
45
|
// kept its journal there too).
|
|
46
46
|
const AGENT_DIR = "__agent__";
|
|
47
47
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Process-wide single-flight refresher shared by the sota guard and the
|
|
2
|
-
//
|
|
2
|
+
// session uploader (both run inside the pi process). Lazily created from the
|
|
3
3
|
// environment; refreshes rotate both tokens back to the state dir (0600).
|
|
4
4
|
|
|
5
5
|
import { createRefresher, omniDir, resolveOrigin } from "./auth";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Omnirush retry layer — shared by every Omnirush API call path (model
|
|
2
|
-
// stream via the sota tap,
|
|
2
|
+
// stream via the sota tap, session uploads, /device and /device/me).
|
|
3
3
|
//
|
|
4
4
|
// Goal: users ride out a backend deploy or a rate-limit window without
|
|
5
5
|
// seeing a single raw error. Retryable = 429 / 5xx / network failures.
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// the same format, as the desktop app.
|
|
3
3
|
//
|
|
4
4
|
// The capture itself is the desktop's, ported unchanged but for a few
|
|
5
|
-
// marked seams (capture/: workspace-
|
|
5
|
+
// marked seams (capture/: workspace-sync.ts, turn-diff.ts and the
|
|
6
6
|
// project archive in session-archive/, from omnirush-gui apps/server/src).
|
|
7
7
|
// This file is the CLI's counterpart of the desktop's turn observer
|
|
8
|
-
// (
|
|
8
|
+
// (the desktop turn observer) and server hooks: it tells the session uploader and the
|
|
9
9
|
// project archive what the agent does, from pi's events.
|
|
10
10
|
//
|
|
11
11
|
// a prompt goes out engine.request trace event (prompt, model,
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// events), the "prompt" change snapshot, the
|
|
14
14
|
// project archive's session start (its base)
|
|
15
15
|
// a run settles the conversation since the last flush as
|
|
16
|
-
// engine messages (
|
|
16
|
+
// engine messages (engine-messages.ts): session.model,
|
|
17
17
|
// turn.messages parts when it is long, each
|
|
18
18
|
// sub-agent's new messages as session.child, the
|
|
19
19
|
// "turn_completed" snapshot with its turn.diff, and
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// it. A prompt an extension sends (pi's input event, source "extension")
|
|
42
42
|
// is synthetic too.
|
|
43
43
|
//
|
|
44
|
-
// Silent no-op without device credentials:
|
|
44
|
+
// Silent no-op without device credentials: session uploads only run for
|
|
45
45
|
// signed-in omnirush identities.
|
|
46
46
|
|
|
47
47
|
import { createHash } from "node:crypto";
|
|
@@ -53,7 +53,7 @@ import path from "node:path";
|
|
|
53
53
|
import { createInterface } from "node:readline";
|
|
54
54
|
|
|
55
55
|
import { gatewayUrlForOrigin, omniDir, resolveOrigin } from "./auth";
|
|
56
|
-
import {
|
|
56
|
+
import { WorkspaceSync } from "./capture/workspace-sync";
|
|
57
57
|
import { SessionArchiver } from "./capture/session-archive/index";
|
|
58
58
|
import { ProjectArchiveLifecycle, projectArchiveEnabled } from "./capture/session-archive/lifecycle";
|
|
59
59
|
import {
|
|
@@ -68,14 +68,14 @@ import {
|
|
|
68
68
|
userText,
|
|
69
69
|
type EngineMessage,
|
|
70
70
|
type PiEntry,
|
|
71
|
-
} from "./
|
|
71
|
+
} from "./engine-messages";
|
|
72
72
|
import { sharedRefresher } from "./refresh";
|
|
73
73
|
import { AGENTS_HOOK, SUBAGENT_SESSIONS_DIR, type AgentsHook } from "./agents-lib";
|
|
74
74
|
import { BGSHELL_HOOK, type BgShellHook } from "./bgshell-lib";
|
|
75
75
|
|
|
76
76
|
/** info.agent of the root session's messages (a sub-agent's is its role). */
|
|
77
77
|
export const ROOT_AGENT = "omnirush-cli";
|
|
78
|
-
/** Sub-agent layers captured below the root session (the desktop's
|
|
78
|
+
/** Sub-agent layers captured below the root session (the desktop's MAX_SYNC_CHILD_SESSION_DEPTH). */
|
|
79
79
|
const MAX_CHILD_DEPTH = 3;
|
|
80
80
|
/** Sub-agent sessions followed per session (the desktop's MAX_CHILD_SESSIONS). */
|
|
81
81
|
const MAX_CHILDREN = 200;
|
|
@@ -105,7 +105,7 @@ function piAgentDir(): string {
|
|
|
105
105
|
return path.join(stateDir ? path.resolve(stateDir) : path.join(os.homedir(), ".omnirush"), "agent");
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/** pi session ids are UUIDs; the
|
|
108
|
+
/** pi session ids are UUIDs; the session uploader refuses anything outside its id alphabet. */
|
|
109
109
|
function sanitizeSessionId(raw: string): string {
|
|
110
110
|
const id = String(raw || "");
|
|
111
111
|
return /^[A-Za-z0-9._:-]{8,128}$/.test(id) ? id : "";
|
|
@@ -128,7 +128,7 @@ function cliVersion(): string {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/** The previous
|
|
131
|
+
/** The previous session uploader's per-session change journals (`journal-<session>.ndjson`); the ported session uploader keeps none. */
|
|
132
132
|
function removeLegacyJournals(stateDir: string): void {
|
|
133
133
|
try {
|
|
134
134
|
for (const name of readdirSync(stateDir)) {
|
|
@@ -307,20 +307,20 @@ export async function parseSessionFileStream(file: string): Promise<{ header: Pi
|
|
|
307
307
|
return { header, entries };
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
type
|
|
311
|
-
|
|
310
|
+
type Sync = Pick<
|
|
311
|
+
WorkspaceSync,
|
|
312
312
|
"recordTrace" | "recordChildSession" | "childCheckpoints" | "childSessionIds"
|
|
313
313
|
>;
|
|
314
314
|
|
|
315
315
|
/**
|
|
316
316
|
* Records the sub-agent tree below a settled session: one session.child event
|
|
317
317
|
* per sub-agent carrying only its messages since its checkpoint, recursively
|
|
318
|
-
* for the sub-agents it started (
|
|
318
|
+
* for the sub-agents it started (the desktop turn observer
|
|
319
319
|
* `captureChildSessions`). `beforeRecord` hears the size of each event's
|
|
320
320
|
* messages before it is recorded.
|
|
321
321
|
*/
|
|
322
322
|
export async function captureChildSessions(input: {
|
|
323
|
-
|
|
323
|
+
sync: Sync;
|
|
324
324
|
rootSessionId: string;
|
|
325
325
|
parentSessionId: string;
|
|
326
326
|
parentEntries: readonly PiEntry[];
|
|
@@ -354,13 +354,13 @@ export async function captureChildSessions(input: {
|
|
|
354
354
|
const messages = engineMessagesFromEntries(entries, { sessionId: child.id, agent, cwd });
|
|
355
355
|
const history = turnHistory(messages, input.checkpoints[child.id], MAX_TURN_MESSAGE_BYTES);
|
|
356
356
|
if (history.omitted > 0) {
|
|
357
|
-
input.
|
|
357
|
+
input.sync.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
|
|
358
358
|
}
|
|
359
359
|
const fallbackKey = `${input.rootSessionId}:${child.id}`;
|
|
360
360
|
if (child.modelFallback && !recordedFallbacks.has(fallbackKey)) {
|
|
361
361
|
// As the desktop: the sub-agent ran on the main model, and why.
|
|
362
362
|
recordedFallbacks.add(fallbackKey);
|
|
363
|
-
input.
|
|
363
|
+
input.sync.recordTrace(input.rootSessionId, "subagent.model_fallback", {
|
|
364
364
|
child_session_id: child.id,
|
|
365
365
|
requested: child.modelFallback.requested,
|
|
366
366
|
used: child.modelFallback.used,
|
|
@@ -371,7 +371,7 @@ export async function captureChildSessions(input: {
|
|
|
371
371
|
}
|
|
372
372
|
if (history.delta.length > 0 || !input.known.has(child.id)) {
|
|
373
373
|
await input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
|
|
374
|
-
input.
|
|
374
|
+
input.sync.recordChildSession(input.rootSessionId, {
|
|
375
375
|
childSessionId: child.id,
|
|
376
376
|
parentSessionId: input.parentSessionId,
|
|
377
377
|
depth: input.depth,
|
|
@@ -391,7 +391,7 @@ export default function (pi: any) {
|
|
|
391
391
|
const parentSession = (process.env.OMNIRUSH_PARENT_SESSION || "").trim();
|
|
392
392
|
if (parentSession) {
|
|
393
393
|
// Its session says so (subagent-marker.ts; index.ts loads it for
|
|
394
|
-
// sub-agent processes, which do not load the
|
|
394
|
+
// sub-agent processes, which do not load the session uploader).
|
|
395
395
|
markSubagentSession(pi, parentSession);
|
|
396
396
|
return;
|
|
397
397
|
}
|
|
@@ -411,7 +411,7 @@ export default function (pi: any) {
|
|
|
411
411
|
if (level !== "warn" && !verbose) return;
|
|
412
412
|
try {
|
|
413
413
|
const detail = attributes && Object.keys(attributes).length > 0 ? ` ${JSON.stringify(attributes)}` : "";
|
|
414
|
-
process.stderr.write(`omnirush
|
|
414
|
+
process.stderr.write(`omnirush session upload: ${message}${detail}\n`);
|
|
415
415
|
} catch {
|
|
416
416
|
/* stderr may be closed late in shutdown */
|
|
417
417
|
}
|
|
@@ -431,7 +431,7 @@ export default function (pi: any) {
|
|
|
431
431
|
|
|
432
432
|
// Uploads carry the CURRENT on-disk token, not the one this process
|
|
433
433
|
// last saw: another terminal or a sub-agent may have rotated the pair.
|
|
434
|
-
// Only requests that already carry a bearer (
|
|
434
|
+
// Only requests that already carry a bearer (session upload, archive API) are
|
|
435
435
|
// touched — presigned S3 part PUTs pass through unchanged.
|
|
436
436
|
const withCurrentBearer = (input: string, init?: RequestInit): Promise<Response> => {
|
|
437
437
|
const headers = new Headers(init?.headers ?? {});
|
|
@@ -450,7 +450,7 @@ export default function (pi: any) {
|
|
|
450
450
|
return globalThis.fetch(input, init);
|
|
451
451
|
};
|
|
452
452
|
|
|
453
|
-
const
|
|
453
|
+
const sync = new WorkspaceSync({
|
|
454
454
|
gatewayUrl,
|
|
455
455
|
accessToken,
|
|
456
456
|
refreshAccessToken,
|
|
@@ -484,12 +484,12 @@ export default function (pi: any) {
|
|
|
484
484
|
});
|
|
485
485
|
const archive = new ProjectArchiveLifecycle({
|
|
486
486
|
archiver,
|
|
487
|
-
enabled: projectArchiveEnabled(process.env) &&
|
|
487
|
+
enabled: projectArchiveEnabled(process.env) && sync.enabled,
|
|
488
488
|
log,
|
|
489
489
|
baseIdleMs: ARCHIVE_BASE_IDLE_MS,
|
|
490
490
|
baseMaxDeferMs: ARCHIVE_BASE_MAX_DEFER_MS,
|
|
491
491
|
});
|
|
492
|
-
if (!
|
|
492
|
+
if (!sync.enabled) {
|
|
493
493
|
// Signed out: clear what a previous run left queued (no network).
|
|
494
494
|
archive.start();
|
|
495
495
|
return;
|
|
@@ -502,21 +502,21 @@ export default function (pi: any) {
|
|
|
502
502
|
id: string;
|
|
503
503
|
root: string;
|
|
504
504
|
ctx: any;
|
|
505
|
-
/** A prompt went out in this process: the
|
|
505
|
+
/** A prompt went out in this process: the session uploader follows the session. */
|
|
506
506
|
started: boolean;
|
|
507
507
|
/** A prompt went out and its run has not settled. */
|
|
508
508
|
running: boolean;
|
|
509
|
-
/** The id of the last engine message recorded (the
|
|
509
|
+
/** The id of the last engine message recorded (the session uploader's checkpoint). */
|
|
510
510
|
lastMessageId: string | undefined;
|
|
511
511
|
toolStarts: Map<string, number>;
|
|
512
512
|
seenFiles: Map<string, string>;
|
|
513
513
|
/** Prompts sent in this process with the model and effort they went with, until their user entry is found. */
|
|
514
514
|
pendingPrompts: Array<{ at: number; model: { providerID: string; modelID: string } | null; variant: string | null }>;
|
|
515
|
-
/** User entry id -> what its prompt was sent with (
|
|
515
|
+
/** User entry id -> what its prompt was sent with (engine-messages promptSettings). */
|
|
516
516
|
promptSettings: Map<string, { model: { providerID: string; modelID: string } | null; variant: string | null }>;
|
|
517
517
|
/** Prompts an extension sent (pi's input event, source "extension"), until their user entry is found. */
|
|
518
518
|
extensionInputs: Array<{ at: number; text: string }>;
|
|
519
|
-
/** User entries the user did not submit -> their parts' metadata (
|
|
519
|
+
/** User entries the user did not submit -> their parts' metadata (engine-messages injectedPrompts). */
|
|
520
520
|
injectedPrompts: Map<string, Record<string, unknown>>;
|
|
521
521
|
/** Whether the session is a sub-agent's (its first prompt the parent agent's task), once looked up. */
|
|
522
522
|
origin: Promise<void> | null;
|
|
@@ -581,7 +581,7 @@ export default function (pi: any) {
|
|
|
581
581
|
};
|
|
582
582
|
|
|
583
583
|
/**
|
|
584
|
-
* The session `ctx` is on. Its
|
|
584
|
+
* The session `ctx` is on. Its upload starts with its first prompt in
|
|
585
585
|
* this process, as the desktop starts it on a prompt dispatch: opening a
|
|
586
586
|
* session and leaving without a prompt uploads nothing.
|
|
587
587
|
*/
|
|
@@ -609,7 +609,7 @@ export default function (pi: any) {
|
|
|
609
609
|
session.ctx = ctx;
|
|
610
610
|
// Idempotent: a session already started in this process keeps its segment.
|
|
611
611
|
if (prompted) {
|
|
612
|
-
|
|
612
|
+
sync.startSession(id, workspaceIdFor(session.root), session.root);
|
|
613
613
|
session.started = true;
|
|
614
614
|
}
|
|
615
615
|
return session;
|
|
@@ -624,20 +624,20 @@ export default function (pi: any) {
|
|
|
624
624
|
const { id } = state;
|
|
625
625
|
try {
|
|
626
626
|
await captureChildSessions({
|
|
627
|
-
|
|
627
|
+
sync,
|
|
628
628
|
rootSessionId: id,
|
|
629
629
|
parentSessionId: id,
|
|
630
630
|
parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
|
|
631
631
|
depth: 1,
|
|
632
632
|
sessionDirs: sessionDirs(state.ctx),
|
|
633
|
-
checkpoints: await
|
|
634
|
-
known: new Set(await
|
|
633
|
+
checkpoints: await sync.childCheckpoints(id),
|
|
634
|
+
known: new Set(await sync.childSessionIds(id)),
|
|
635
635
|
visited: new Set([id]),
|
|
636
636
|
seenFiles: state.seenFiles,
|
|
637
637
|
beforeRecord: room,
|
|
638
638
|
});
|
|
639
639
|
} catch (error) {
|
|
640
|
-
|
|
640
|
+
sync.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
|
|
641
641
|
}
|
|
642
642
|
};
|
|
643
643
|
|
|
@@ -653,9 +653,9 @@ export default function (pi: any) {
|
|
|
653
653
|
let pending = 0;
|
|
654
654
|
return async (bytes: number): Promise<void> => {
|
|
655
655
|
if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
|
|
656
|
-
|
|
656
|
+
sync.flushTrace(id);
|
|
657
657
|
pending = 0;
|
|
658
|
-
await
|
|
658
|
+
await sync.drained(id);
|
|
659
659
|
}
|
|
660
660
|
pending += bytes;
|
|
661
661
|
};
|
|
@@ -673,37 +673,37 @@ export default function (pi: any) {
|
|
|
673
673
|
let parts: Array<{ messages: unknown[]; bytes: number }> = [{ messages: [], bytes: 0 }];
|
|
674
674
|
try {
|
|
675
675
|
messages = messagesOf(state);
|
|
676
|
-
const checkpoint = state.lastMessageId ?? (await
|
|
676
|
+
const checkpoint = state.lastMessageId ?? (await sync.sessionCheckpoint(id)).lastMessageId;
|
|
677
677
|
const history = turnHistory(messages, checkpoint);
|
|
678
678
|
const lastId = messages.at(-1)?.info.id;
|
|
679
679
|
if (typeof lastId === "string") {
|
|
680
680
|
state.lastMessageId = lastId;
|
|
681
|
-
void
|
|
681
|
+
void sync.setSessionCheckpoint(id, lastId);
|
|
682
682
|
}
|
|
683
|
-
if (history.omitted > 0)
|
|
683
|
+
if (history.omitted > 0) sync.recordTrace(id, "session.messages_omitted", { count: history.omitted });
|
|
684
684
|
const model = turnModelFromMessages(history.delta);
|
|
685
|
-
if (model)
|
|
685
|
+
if (model) sync.recordSessionModel(id, model);
|
|
686
686
|
// More messages than one flush holds: the older ones go ahead, a flush per part.
|
|
687
687
|
parts = messageParts(history.delta, history.sizes);
|
|
688
688
|
for (const [index, part] of parts.slice(0, -1).entries()) {
|
|
689
|
-
|
|
690
|
-
|
|
689
|
+
sync.recordTrace(id, "turn.messages", { messages: part.messages, part: index + 1, parts: parts.length });
|
|
690
|
+
sync.flushTrace(id);
|
|
691
691
|
// One part waits in memory at a time (a resumed session's backlog is up to 32 MiB).
|
|
692
|
-
await
|
|
692
|
+
await sync.drained(id);
|
|
693
693
|
}
|
|
694
694
|
} catch (error) {
|
|
695
|
-
|
|
695
|
+
sync.recordTrace(id, "session.messages_failed", { error: error instanceof Error ? error.message : "unknown" });
|
|
696
696
|
}
|
|
697
697
|
const newest = parts.at(-1)!;
|
|
698
698
|
// Sub-agent messages flush on their own before they would crowd the turn's out.
|
|
699
699
|
const room = childRoom(id);
|
|
700
700
|
await captureChildren(state, room);
|
|
701
701
|
await room(newest.bytes);
|
|
702
|
-
|
|
702
|
+
sync.recordTrace(id, "session.idle", { status });
|
|
703
703
|
// The turn snapshot runs first so the artifacts it discovers are part of
|
|
704
704
|
// the trace flushed right behind it.
|
|
705
|
-
|
|
706
|
-
|
|
705
|
+
sync.captureSnapshot(id, "turn_completed");
|
|
706
|
+
sync.flushTrace(id, { messages: newest.messages });
|
|
707
707
|
archive.turnCompleted(id, messages);
|
|
708
708
|
};
|
|
709
709
|
|
|
@@ -735,14 +735,14 @@ export default function (pi: any) {
|
|
|
735
735
|
const state = session;
|
|
736
736
|
if (!state?.started) return;
|
|
737
737
|
for (const child of interrupted) {
|
|
738
|
-
|
|
738
|
+
sync.recordTrace(state.id, "session.child_interrupted", { child_session_id: child.sessionId, agent: child.role });
|
|
739
739
|
}
|
|
740
740
|
const lastId = messagesOf(state).at(-1)?.info.id;
|
|
741
741
|
if (typeof lastId !== "string" || lastId === state.lastMessageId) {
|
|
742
742
|
// No new message of its own: what its sub-agents did since the last settle.
|
|
743
743
|
settling = settling.then(async () => {
|
|
744
744
|
await captureChildren(state, childRoom(state.id));
|
|
745
|
-
|
|
745
|
+
sync.flushTrace(state.id);
|
|
746
746
|
}).catch(() => undefined);
|
|
747
747
|
await settling;
|
|
748
748
|
return;
|
|
@@ -755,7 +755,7 @@ export default function (pi: any) {
|
|
|
755
755
|
let stopping: Promise<void> | null = null;
|
|
756
756
|
const shutdown = (budgetMs: number): Promise<void> => (stopping ??= stop(budgetMs));
|
|
757
757
|
const stop = async (budgetMs: number): Promise<void> => {
|
|
758
|
-
// A ref'd timer: the
|
|
758
|
+
// A ref'd timer: the session uploader's and the archive's own timers are unref'd,
|
|
759
759
|
// and pi awaits this handler without holding the process open, so without
|
|
760
760
|
// it a process with nothing else to do exits in the middle of an upload.
|
|
761
761
|
let budget: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -770,12 +770,12 @@ export default function (pi: any) {
|
|
|
770
770
|
if (state) {
|
|
771
771
|
// The settled turn's own snapshot (its turn.diff) and trace go out
|
|
772
772
|
// before the session ends: a finished session skips queued captures.
|
|
773
|
-
await
|
|
774
|
-
|
|
773
|
+
await sync.idle(state.id);
|
|
774
|
+
sync.finishSession(state.id);
|
|
775
775
|
}
|
|
776
776
|
// The base and turn deltas upload now; what is left waits in the queue.
|
|
777
777
|
await archive.settled();
|
|
778
|
-
await
|
|
778
|
+
await sync.stop();
|
|
779
779
|
})(),
|
|
780
780
|
deadline,
|
|
781
781
|
]);
|
|
@@ -837,7 +837,7 @@ export default function (pi: any) {
|
|
|
837
837
|
// The prompt as the desktop records its engine request: text, attached
|
|
838
838
|
// files (inline bytes replaced by a marker), model, variant, agent and
|
|
839
839
|
// the system prompt it ran with.
|
|
840
|
-
|
|
840
|
+
sync.recordTrace(state.id, "engine.request", {
|
|
841
841
|
method: "POST",
|
|
842
842
|
path: `/session/${state.id}/prompt`,
|
|
843
843
|
body: {
|
|
@@ -861,7 +861,7 @@ export default function (pi: any) {
|
|
|
861
861
|
images.forEach((image: any, index: number) => {
|
|
862
862
|
if (typeof image?.data !== "string") return;
|
|
863
863
|
const bytes = Buffer.from(image.data, "base64");
|
|
864
|
-
|
|
864
|
+
sync.recordAttachment(state.id, {
|
|
865
865
|
name: `image-${index + 1}`,
|
|
866
866
|
mime: typeof image.mimeType === "string" ? image.mimeType : "application/octet-stream",
|
|
867
867
|
bytes: bytes.length,
|
|
@@ -869,7 +869,7 @@ export default function (pi: any) {
|
|
|
869
869
|
text: null,
|
|
870
870
|
});
|
|
871
871
|
});
|
|
872
|
-
|
|
872
|
+
sync.captureSnapshot(state.id, "prompt");
|
|
873
873
|
archive.sessionStarted({
|
|
874
874
|
sessionId: state.id,
|
|
875
875
|
root: state.root,
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// no imports beyond node builtins, so node:test covers them directly (the
|
|
3
3
|
// command handler in commands.ts gathers the live inputs).
|
|
4
4
|
|
|
5
|
-
export interface
|
|
5
|
+
export interface SyncStatusInput {
|
|
6
6
|
/** pi session id (empty when unknown). */
|
|
7
7
|
sessionId: string;
|
|
8
|
-
/** Ledger record for the session, when the
|
|
8
|
+
/** Ledger record for the session, when the session uploader has one. */
|
|
9
9
|
record?: {
|
|
10
10
|
segment: number;
|
|
11
11
|
nextSequence: number;
|
|
@@ -14,19 +14,19 @@ export interface CollectorStatusInput {
|
|
|
14
14
|
} | null;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** The
|
|
18
|
-
export function
|
|
19
|
-
if (!sessionId) return "
|
|
17
|
+
/** The session uploader line: current session, parts uploaded, last upload time. */
|
|
18
|
+
export function formatSyncLine({ sessionId, record }: SyncStatusInput): string {
|
|
19
|
+
if (!sessionId) return "session uploads: no session yet";
|
|
20
20
|
const short = sessionId.slice(0, 8);
|
|
21
21
|
if (!record) {
|
|
22
|
-
return `
|
|
22
|
+
return `session uploads: session ${short} — no uploads yet`;
|
|
23
23
|
}
|
|
24
24
|
const parts = `session ${short} — ${record.nextSequence} part${record.nextSequence === 1 ? "" : "s"} uploaded`;
|
|
25
25
|
const bytes = typeof record.sentBytes === "number" && record.sentBytes > 0
|
|
26
26
|
? ` · ${(record.sentBytes / 1024).toFixed(0)} kB sent`
|
|
27
27
|
: "";
|
|
28
28
|
const seen = record.lastSeenAt ? ` · last upload ${record.lastSeenAt}` : "";
|
|
29
|
-
return `
|
|
29
|
+
return `session uploads: ${parts}${bytes}${seen} (segment ${record.segment})`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** Truncate an identity string for one-line display. */
|
|
@@ -47,7 +47,7 @@ export interface StatusInput {
|
|
|
47
47
|
/** Human grant summary, e.g. "997.2M of 1.0B tokens remaining". */
|
|
48
48
|
grant?: string | null;
|
|
49
49
|
grantDate?: string | null;
|
|
50
|
-
|
|
50
|
+
sync: SyncStatusInput;
|
|
51
51
|
/** Manager origin the CLI talks to. */
|
|
52
52
|
origin: string;
|
|
53
53
|
/** omnirush CLI version ("unknown" when not provided by the launcher). */
|
|
@@ -80,7 +80,7 @@ export function formatStatusLines(input: StatusInput): string[] {
|
|
|
80
80
|
lines.push("grant: no data yet (appears after the first request or via /usage)");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
lines.push(
|
|
83
|
+
lines.push(formatSyncLine(input.sync));
|
|
84
84
|
if (input.yolo) lines.push(input.yolo);
|
|
85
85
|
lines.push(`manager: ${input.origin}`);
|
|
86
86
|
lines.push(`version: ${input.version}`);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// When things happened inside a run, as the 2.x engine records them and pi
|
|
2
|
+
// does not: when each content block of a model step started and ended
|
|
3
|
+
// streaming (text, thinking, tool call), when the step's response ended,
|
|
4
|
+
// when each tool call started running, and when the run went idle and how
|
|
5
|
+
// it ended. pi keeps only one timestamp per message.
|
|
6
|
+
//
|
|
7
|
+
// Heard from pi's live events and written into the session itself, as
|
|
8
|
+
// `omnirush-timing` entries (custom entries: never part of the model's
|
|
9
|
+
// context), so every process records its own, a sub-agent's included, and
|
|
10
|
+
// a session read back later (a resumed one, a sub-agent's file) still has
|
|
11
|
+
// them. engine-messages.ts reads them into the trace's 2.x fields:
|
|
12
|
+
//
|
|
13
|
+
// one entry per model step, at the end of its turn
|
|
14
|
+
// {step: <the assistant message's timestamp>, streamed, blocks:
|
|
15
|
+
// [{index, created, completed}], tools: {<toolCallId>: ran}}
|
|
16
|
+
// one entry when the run settles
|
|
17
|
+
// {idle: {at, outcome: "succeeded" | "failed" | "interrupted"}}
|
|
18
|
+
//
|
|
19
|
+
// Loaded before the session uploader (index.ts): the idle entry is on the branch
|
|
20
|
+
// when the session uploader reads the settled run.
|
|
21
|
+
|
|
22
|
+
import { TIMING_ENTRY_TYPE } from "./engine-messages";
|
|
23
|
+
|
|
24
|
+
type Block = { index: number; created?: number; completed?: number };
|
|
25
|
+
type Step = { streamed?: number; blocks: Map<number, Block>; message?: any };
|
|
26
|
+
|
|
27
|
+
const STARTS = new Set(["text_start", "thinking_start", "toolcall_start"]);
|
|
28
|
+
const ENDS = new Set(["text_end", "thinking_end", "toolcall_end"]);
|
|
29
|
+
|
|
30
|
+
/** A run's outcome from its last model step (2.x idle marker). */
|
|
31
|
+
export function outcomeOf(stopReason: unknown): "succeeded" | "failed" | "interrupted" {
|
|
32
|
+
if (stopReason === "stop" || stopReason === "length") return "succeeded";
|
|
33
|
+
if (stopReason === "error") return "failed";
|
|
34
|
+
return "interrupted";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function recordStreamTiming(pi: any, now: () => number = Date.now): void {
|
|
38
|
+
const steps = new Map<number, Step>();
|
|
39
|
+
const ran = new Map<string, number>();
|
|
40
|
+
let running = false;
|
|
41
|
+
let lastStop: unknown = undefined;
|
|
42
|
+
|
|
43
|
+
const append = (data: Record<string, unknown>) => {
|
|
44
|
+
try {
|
|
45
|
+
pi.appendEntry(TIMING_ENTRY_TYPE, data);
|
|
46
|
+
} catch {
|
|
47
|
+
/* never in the run's way */
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const stepOf = (message: any): Step | null => {
|
|
52
|
+
const key = Number(message?.timestamp);
|
|
53
|
+
if (message?.role !== "assistant" || !Number.isFinite(key)) return null;
|
|
54
|
+
let step = steps.get(key);
|
|
55
|
+
if (!step) {
|
|
56
|
+
step = { blocks: new Map() };
|
|
57
|
+
steps.set(key, step);
|
|
58
|
+
}
|
|
59
|
+
return step;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The step of `message` (a finished assistant message) as its timing entry. */
|
|
63
|
+
const flushStep = (message: any) => {
|
|
64
|
+
const key = Number(message?.timestamp);
|
|
65
|
+
const step = steps.get(key);
|
|
66
|
+
if (!step || message?.role !== "assistant") return;
|
|
67
|
+
steps.delete(key);
|
|
68
|
+
const tools: Record<string, number> = {};
|
|
69
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
70
|
+
const id = block?.type === "toolCall" && typeof block.id === "string" ? block.id : "";
|
|
71
|
+
const at = id ? ran.get(id) : undefined;
|
|
72
|
+
if (at !== undefined) {
|
|
73
|
+
tools[id] = at;
|
|
74
|
+
ran.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const blocks = [...step.blocks.values()].sort((a, b) => a.index - b.index);
|
|
78
|
+
append({
|
|
79
|
+
step: key,
|
|
80
|
+
...(step.streamed !== undefined ? { streamed: step.streamed } : {}),
|
|
81
|
+
...(blocks.length > 0 ? { blocks } : {}),
|
|
82
|
+
...(Object.keys(tools).length > 0 ? { tools } : {}),
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const settle = (outcome: "succeeded" | "failed" | "interrupted") => {
|
|
87
|
+
if (!running) return;
|
|
88
|
+
running = false;
|
|
89
|
+
// Steps whose turn never ended (an aborted run) keep their timing too.
|
|
90
|
+
for (const step of [...steps.values()]) if (step.message) flushStep(step.message);
|
|
91
|
+
steps.clear();
|
|
92
|
+
append({ idle: { at: now(), outcome } });
|
|
93
|
+
lastStop = undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
pi.on("agent_start", () => {
|
|
97
|
+
running = true;
|
|
98
|
+
lastStop = undefined;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
pi.on("message_update", (event: any) => {
|
|
102
|
+
const kind = event?.assistantMessageEvent?.type;
|
|
103
|
+
const index = event?.assistantMessageEvent?.contentIndex;
|
|
104
|
+
if (typeof kind !== "string" || typeof index !== "number") return;
|
|
105
|
+
const step = stepOf(event?.message);
|
|
106
|
+
if (!step) return;
|
|
107
|
+
if (STARTS.has(kind)) {
|
|
108
|
+
if (!step.blocks.has(index)) step.blocks.set(index, { index, created: now() });
|
|
109
|
+
} else if (ENDS.has(kind)) {
|
|
110
|
+
const block = step.blocks.get(index) ?? { index };
|
|
111
|
+
block.completed = now();
|
|
112
|
+
step.blocks.set(index, block);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
pi.on("message_end", (event: any) => {
|
|
117
|
+
const step = stepOf(event?.message);
|
|
118
|
+
if (!step) return;
|
|
119
|
+
step.streamed = now();
|
|
120
|
+
step.message = event.message;
|
|
121
|
+
lastStop = event.message.stopReason;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
pi.on("tool_execution_start", (event: any) => {
|
|
125
|
+
if (typeof event?.toolCallId === "string") ran.set(event.toolCallId, now());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.on("turn_end", (event: any) => {
|
|
129
|
+
flushStep(event?.message);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
pi.on("agent_settled", () => {
|
|
133
|
+
settle(outcomeOf(lastStop));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Quit, reload or another session while a run is still going.
|
|
137
|
+
pi.on("session_shutdown", () => {
|
|
138
|
+
settle("interrupted");
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// A sub-agent process (spawn_agents sets OMNIRUSH_PARENT_SESSION) marks its
|
|
2
2
|
// own session with an `omnirush-subagent` entry naming its parent, before
|
|
3
3
|
// the task: should that session ever be continued as a main session, its
|
|
4
|
-
// first prompt is known to be the parent agent's (
|
|
5
|
-
// as synthetic). Kept out of
|
|
6
|
-
// not load the
|
|
4
|
+
// first prompt is known to be the parent agent's (session-sync.ts records it
|
|
5
|
+
// as synthetic). Kept out of session-sync.ts because sub-agent processes do
|
|
6
|
+
// not load the session uploader (index.ts keeps children light).
|
|
7
7
|
|
|
8
|
-
import { SUBAGENT_ENTRY_TYPE } from "./
|
|
8
|
+
import { SUBAGENT_ENTRY_TYPE } from "./engine-messages";
|
|
9
9
|
|
|
10
10
|
export default function markSubagentSession(pi: any, parentSession: string = (process.env.OMNIRUSH_PARENT_SESSION || "").trim()): void {
|
|
11
11
|
if (!parentSession) return;
|