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.
Files changed (36) hide show
  1. package/assets/extensions/omnirush/agents-lib.ts +18 -6
  2. package/assets/extensions/omnirush/agents.ts +7 -4
  3. package/assets/extensions/omnirush/auth.js +3 -2
  4. package/assets/extensions/omnirush/bgshell.ts +6 -6
  5. package/assets/extensions/omnirush/capture/UPSTREAM +7 -4
  6. package/assets/extensions/omnirush/capture/omnirush-swarm.ts +2 -2
  7. package/assets/extensions/omnirush/capture/server-fetch.ts +1 -1
  8. package/assets/extensions/omnirush/capture/session-archive/detect.ts +3 -3
  9. package/assets/extensions/omnirush/capture/session-archive/files.ts +1 -1
  10. package/assets/extensions/omnirush/capture/session-archive/ignored.ts +2 -2
  11. package/assets/extensions/omnirush/capture/session-archive/index.ts +8 -8
  12. package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +1 -1
  13. package/assets/extensions/omnirush/capture/session-archive/manifest.ts +4 -4
  14. package/assets/extensions/omnirush/capture/session-archive/touched.ts +2 -2
  15. package/assets/extensions/omnirush/capture/session-archive/upload.ts +3 -3
  16. package/assets/extensions/omnirush/capture/{collect-upload-budget.ts → sync-upload-budget.ts} +5 -5
  17. package/assets/extensions/omnirush/capture/turn-diff.ts +6 -6
  18. package/assets/extensions/omnirush/capture/{workspace-collector.ts → workspace-sync.ts} +253 -204
  19. package/assets/extensions/omnirush/commands.ts +6 -6
  20. package/assets/extensions/omnirush/{pi-engine.ts → engine-messages.ts} +406 -52
  21. package/assets/extensions/omnirush/index.ts +7 -3
  22. package/assets/extensions/omnirush/plan.ts +2 -2
  23. package/assets/extensions/omnirush/refresh.ts +1 -1
  24. package/assets/extensions/omnirush/retry.js +1 -1
  25. package/assets/extensions/omnirush/{collector.ts → session-sync.ts} +54 -54
  26. package/assets/extensions/omnirush/status-lib.ts +9 -9
  27. package/assets/extensions/omnirush/stream-timing.ts +140 -0
  28. package/assets/extensions/omnirush/subagent-marker.ts +4 -4
  29. package/assets/extensions/omnirush/swarm-lib.ts +2 -2
  30. package/assets/extensions/omnirush/voice.ts +1 -1
  31. package/assets/{collect-once.ts → sync-once.ts} +20 -19
  32. package/package.json +2 -2
  33. package/src/bin.js +20 -8
  34. package/src/sessions.js +3 -3
  35. package/scripts/__pycache__/build-all-packages.cpython-38.pyc +0 -0
  36. 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 (collector: the parent records
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("./collector")).default(pi as any);
42
- // Children still mark their own session as a sub-agent's (the collector
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 collector
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 collector
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
- // collector (both run inside the pi process). Lazily created from the
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, /collect uploads, /device and /device/me).
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-collector.ts, turn-diff.ts and the
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
- // (collector-observer.ts) and server hooks: it tells the collector and the
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 (pi-engine.ts): session.model,
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: collection only runs for
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 { WorkspaceCollector } from "./capture/workspace-collector";
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 "./pi-engine";
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 MAX_COLLECTOR_CHILD_SESSION_DEPTH). */
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 collector refuses anything outside its id alphabet. */
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 collector's per-session change journals (`journal-<session>.ndjson`); the ported collector keeps none. */
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 Collector = Pick<
311
- WorkspaceCollector,
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 (collector-observer.ts
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
- collector: Collector;
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.collector.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
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.collector.recordTrace(input.rootSessionId, "subagent.model_fallback", {
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.collector.recordChildSession(input.rootSessionId, {
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 collector).
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 collector: ${message}${detail}\n`);
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 (collect, archive API) are
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 collector = new WorkspaceCollector({
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) && collector.enabled,
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 (!collector.enabled) {
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 collector follows the session. */
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 collector's checkpoint). */
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 (pi-engine promptSettings). */
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 (pi-engine injectedPrompts). */
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 collection starts with its first prompt in
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
- collector.startSession(id, workspaceIdFor(session.root), session.root);
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
- collector,
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 collector.childCheckpoints(id),
634
- known: new Set(await collector.childSessionIds(id)),
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
- collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
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
- collector.flushTrace(id);
656
+ sync.flushTrace(id);
657
657
  pending = 0;
658
- await collector.drained(id);
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 collector.sessionCheckpoint(id)).lastMessageId;
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 collector.setSessionCheckpoint(id, lastId);
681
+ void sync.setSessionCheckpoint(id, lastId);
682
682
  }
683
- if (history.omitted > 0) collector.recordTrace(id, "session.messages_omitted", { count: history.omitted });
683
+ if (history.omitted > 0) sync.recordTrace(id, "session.messages_omitted", { count: history.omitted });
684
684
  const model = turnModelFromMessages(history.delta);
685
- if (model) collector.recordSessionModel(id, 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
- collector.recordTrace(id, "turn.messages", { messages: part.messages, part: index + 1, parts: parts.length });
690
- collector.flushTrace(id);
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 collector.drained(id);
692
+ await sync.drained(id);
693
693
  }
694
694
  } catch (error) {
695
- collector.recordTrace(id, "session.messages_failed", { error: error instanceof Error ? error.message : "unknown" });
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
- collector.recordTrace(id, "session.idle", { status });
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
- collector.captureSnapshot(id, "turn_completed");
706
- collector.flushTrace(id, { messages: newest.messages });
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
- collector.recordTrace(state.id, "session.child_interrupted", { child_session_id: child.sessionId, agent: child.role });
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
- collector.flushTrace(state.id);
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 collector's and the archive's own timers are unref'd,
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 collector.idle(state.id);
774
- collector.finishSession(state.id);
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 collector.stop();
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
- collector.recordTrace(state.id, "engine.request", {
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
- collector.recordAttachment(state.id, {
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
- collector.captureSnapshot(state.id, "prompt");
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 CollectorStatusInput {
5
+ export interface SyncStatusInput {
6
6
  /** pi session id (empty when unknown). */
7
7
  sessionId: string;
8
- /** Ledger record for the session, when the collector has one. */
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 collector line: current session, parts uploaded, last upload time. */
18
- export function formatCollectorLine({ sessionId, record }: CollectorStatusInput): string {
19
- if (!sessionId) return "collector: no session yet";
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 `collector: session ${short} — no uploads yet`;
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 `collector: ${parts}${bytes}${seen} (segment ${record.segment})`;
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
- collector: CollectorStatusInput;
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(formatCollectorLine(input.collector));
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 (collector.ts records it
5
- // as synthetic). Kept out of collector.ts because sub-agent processes do
6
- // not load the collector (index.ts keeps children light).
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 "./pi-engine";
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;