omnirush 0.10.1 → 0.10.3

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 +66 -11
  2. package/assets/extensions/omnirush/agents.ts +85 -18
  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} +7 -7
  21. package/assets/extensions/omnirush/index.ts +4 -4
  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} +134 -58
  26. package/assets/extensions/omnirush/status-lib.ts +9 -9
  27. package/assets/extensions/omnirush/stream-timing.ts +4 -4
  28. package/assets/extensions/omnirush/subagent-marker.ts +4 -4
  29. package/assets/extensions/omnirush/subagents-lib.ts +113 -13
  30. package/assets/extensions/omnirush/subagents.ts +13 -2
  31. package/assets/extensions/omnirush/swarm-lib.ts +2 -2
  32. package/assets/extensions/omnirush/voice.ts +1 -1
  33. package/assets/{collect-once.ts → sync-once.ts} +20 -19
  34. package/package.json +1 -1
  35. package/src/bin.js +20 -8
  36. package/src/sessions.js +3 -3
@@ -5,7 +5,7 @@
5
5
  * /usage full usage from the manager (grant date, token counters,
6
6
  * devices)
7
7
  * /status one-screen session summary (identity, model/effort, grant,
8
- * collector, origin, version)
8
+ * session uploader, origin, version)
9
9
  * /verify prove the served model is genuine — full implementation
10
10
  * lands with the gateway's model-attestation endpoint (M2)
11
11
  * plus the footer status line that keeps grant/model info visible while
@@ -17,10 +17,10 @@
17
17
  * which is exactly how /grant and /verify broke the first time.
18
18
  */
19
19
 
20
- import { readSessionLedger, SESSION_LEDGER_FILE } from "./capture/workspace-collector";
20
+ import { readSessionLedger, SESSION_LEDGER_FILE } from "./capture/workspace-sync";
21
21
  import { deviceMe, omniDir, resolveOrigin, sessionEndedMessage } from "./auth";
22
22
  import { sharedRefresher } from "./refresh";
23
- import { formatCollectorLine, formatStatusLines } from "./status-lib";
23
+ import { formatSyncLine, formatStatusLines } from "./status-lib";
24
24
  import { formatModeStatus } from "./yolo-lib";
25
25
  import {
26
26
  formatDeviceLines,
@@ -129,7 +129,7 @@ export default function (pi: ExtensionAPI) {
129
129
  });
130
130
 
131
131
  pi.registerCommand("status", {
132
- description: "Show the Omnirush session status (identity, model, grant, collector)",
132
+ description: "Show the Omnirush session status (identity, model, grant, session uploads)",
133
133
  handler: async (_args: string, ctx: any) => {
134
134
  const { me } = await fetchMe();
135
135
  const usage = gatewayUsage();
@@ -140,7 +140,7 @@ export default function (pi: ExtensionAPI) {
140
140
  const ledger = await readSessionLedger(`${omniDir()}/${SESSION_LEDGER_FILE}`);
141
141
  record = ledger.sessions[sessionId] ?? null;
142
142
  } catch {
143
- /* collector state is best effort */
143
+ /* session upload state is best effort */
144
144
  }
145
145
  const model = ctx?.model;
146
146
  const statusLines = formatStatusLines({
@@ -151,7 +151,7 @@ export default function (pi: ExtensionAPI) {
151
151
  effort: usage?.["x-omnirush-effort"] ?? null,
152
152
  grant: grant?.text ?? null,
153
153
  grantDate: grant?.date ?? null,
154
- collector: { sessionId, record },
154
+ sync: { sessionId, record },
155
155
  origin: resolveOrigin(process.env),
156
156
  version: CLI_VERSION,
157
157
  yolo: formatModeStatus(),
@@ -1,6 +1,6 @@
1
1
  // The CLI's session, in the desktop app's engine message format.
2
2
  //
3
- // The desktop collector records the conversation as the engine (opencode)
3
+ // The desktop app records the conversation as the engine (opencode)
4
4
  // keeps it: one message per user prompt and per model step,
5
5
  //
6
6
  // {info: {id, sessionID, role, time: {created, completed}, modelID,
@@ -72,7 +72,7 @@
72
72
  // outcome) comes from stream-timing.ts's `omnirush-timing` entries.
73
73
  //
74
74
  // Also here, pure and shared with the tests: the helpers the desktop's turn
75
- // observer (apps/server/src/collector-observer.ts) applies to the engine's
75
+ // observer (apps/server/src) applies to the engine's
76
76
  // messages when a turn settles: the turn's model, the message budget of a
77
77
  // flush and its split into `turn.messages` parts.
78
78
 
@@ -1010,9 +1010,9 @@ export function parseSessionFile(text: string): { header: PiEntry | null; entrie
1010
1010
  return { header, entries };
1011
1011
  }
1012
1012
 
1013
- // --- the turn observer's helpers (collector-observer.ts) -------------------------
1013
+ // --- the turn observer's helpers (the desktop turn observer) -------------------------
1014
1014
 
1015
- /** Latest model selection of a turn, as the collector's session.model event records it. */
1015
+ /** Latest model selection of a turn, as the session uploader's session.model event records it. */
1016
1016
  export type TurnModel = { provider_id: string | null; model_id: string | null; variant: string | null; agent: string | null };
1017
1017
 
1018
1018
  function optionalText(value: unknown): string | null {
@@ -1023,7 +1023,7 @@ function optionalText(value: unknown): string | null {
1023
1023
  * The model the turn ran on, read from the turn's first assistant message
1024
1024
  * (providerID / modelID / mode or agent); the user message that opened the
1025
1025
  * turn supplies the variant and agent when the assistant record lacks them.
1026
- * (collector-observer.ts `turnModelFromMessages`.)
1026
+ * (the desktop turn observer `turnModelFromMessages`.)
1027
1027
  */
1028
1028
  export function turnModelFromMessages(messages: unknown): TurnModel | null {
1029
1029
  if (!Array.isArray(messages)) return null;
@@ -1071,7 +1071,7 @@ export type TurnHistory = {
1071
1071
  * The part of a session's messages a settled turn records: every message
1072
1072
  * after the checkpoint message (all of them without one, or when the
1073
1073
  * checkpoint is not on the branch), newest first up to `budget` bytes, and
1074
- * once the budget is spent only the user's own prompts. (collector-observer.ts
1074
+ * once the budget is spent only the user's own prompts. (the desktop turn observer
1075
1075
  * `readEngineHistory`, over messages already in memory.)
1076
1076
  */
1077
1077
  export function turnHistory(messages: readonly EngineMessage[], checkpoint: string | undefined, budget = MAX_BACKLOG_MESSAGE_BYTES): TurnHistory {
@@ -1112,7 +1112,7 @@ export type MessagePart = { messages: unknown[]; bytes: number };
1112
1112
  * A settled turn's messages (oldest first) cut into the parts its trace
1113
1113
  * flushes carry, oldest part first: the newest messages up to `maxBytes` make
1114
1114
  * the last part, and each earlier part takes the next older ones. One part,
1115
- * empty, when there are no messages. (collector-observer.ts `messageParts`.)
1115
+ * empty, when there are no messages. (the desktop turn observer `messageParts`.)
1116
1116
  */
1117
1117
  export function messageParts(messages: readonly unknown[], sizes: readonly number[], maxBytes = MAX_TURN_MESSAGE_BYTES): MessagePart[] {
1118
1118
  const parts: MessagePart[] = [];
@@ -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
@@ -39,11 +39,11 @@ export default async function (pi: ExtensionAPI) {
39
39
  if (!subagent) (await import("./commands")).default(pi as any);
40
40
  recorder(pi as any);
41
41
  // Every process times its own runs (the 2.x trace fields); before the
42
- // collector, so a settled run's idle entry is written before it is read.
42
+ // session uploader, so a settled run's idle entry is written before it is read.
43
43
  recordStreamTiming(pi as any);
44
44
  sota(pi as any);
45
- if (!subagent) (await import("./collector")).default(pi as any);
46
- // 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
47
47
  // does that for the top agent's sessions; children do not load it).
48
48
  else (await import("./subagent-marker")).default(pi as any);
49
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)) {
@@ -159,13 +159,48 @@ function modelFallbackOf(result: any): ChildModelFallback | null {
159
159
  };
160
160
  }
161
161
 
162
+ /** A task's own model that did not run (spawn_agents' model_override). */
163
+ export type ChildModelOverride = { requested: string; used: string; effort: string | null; reason: string; note: string };
164
+
165
+ /** The model and effort a sub-agent really ran on, once its result is final. */
166
+ export type ChildRanOn = { model: string | null; effort: string | null; source: string | null; requested: string | null; status: string };
167
+
168
+ function modelOverrideOf(result: any): ChildModelOverride | null {
169
+ const override = result && typeof result.model_override === "object" ? result.model_override : null;
170
+ if (!override || typeof override.requested !== "string" || typeof override.used !== "string") return null;
171
+ return {
172
+ requested: override.requested,
173
+ used: override.used,
174
+ effort: typeof override.effort === "string" ? override.effort : null,
175
+ reason: typeof override.reason === "string" ? override.reason : "user_choice",
176
+ note: typeof override.note === "string" ? override.note : `ran on ${override.used}`,
177
+ };
178
+ }
179
+
180
+ /** A finished child's model and effort (the gateway fallback's when the gateway moved it). */
181
+ function ranOnOf(result: any, fallback: ChildModelFallback | null): ChildRanOn | null {
182
+ const status = result && typeof result.status === "string" ? result.status : "";
183
+ if (!status || status === "running" || status === "queued") return null;
184
+ const gateway = fallback?.kind === "gateway" ? fallback : null;
185
+ return {
186
+ model: gateway ? gateway.used : typeof result.model === "string" ? result.model : null,
187
+ effort: gateway ? gateway.effort : typeof result.effort === "string" ? result.effort : null,
188
+ source: typeof result.model_source === "string" ? result.model_source : null,
189
+ requested: typeof result.requested_model === "string" ? result.requested_model : null,
190
+ status,
191
+ };
192
+ }
193
+
194
+ type SpawnedChild = { id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback; modelOverride?: ChildModelOverride; ranOn?: ChildRanOn };
195
+
162
196
  /**
163
197
  * Sub-agents a session started: its spawn_agents results (session id, role,
164
198
  * task), with the model fallback a later result reported (a background
165
- * child's delivery, agents_wait / agents_result) when there is one.
199
+ * child's delivery, agents_wait / agents_result) when there is one, a task
200
+ * model that did not run, and the model and effort it ran on once finished.
166
201
  */
167
- export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> {
168
- const children: Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> = [];
202
+ export function spawnedChildren(entries: readonly PiEntry[]): SpawnedChild[] {
203
+ const children: SpawnedChild[] = [];
169
204
  for (const entry of entries) {
170
205
  const message = entry.type === "message" ? entry.message : null;
171
206
  const spawned = Boolean(message && message.role === "toolResult" && message.toolName === "spawn_agents");
@@ -182,16 +217,23 @@ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string
182
217
  if (!sanitizeSessionId(id)) continue;
183
218
  const known = children.find((child) => child.id === id);
184
219
  const modelFallback = modelFallbackOf(result);
220
+ const modelOverride = modelOverrideOf(result);
185
221
  if (known) {
186
222
  if (modelFallback) known.modelFallback = modelFallback;
223
+ if (modelOverride) known.modelOverride = modelOverride;
224
+ const ran = ranOnOf(result, known.modelFallback ?? null);
225
+ if (ran) known.ranOn = ran;
187
226
  continue;
188
227
  }
189
228
  if (!spawned) continue;
229
+ const ran = ranOnOf(result, modelFallback);
190
230
  children.push({
191
231
  id,
192
232
  role: typeof result.role === "string" ? result.role : null,
193
233
  task: typeof result.task === "string" ? result.task : null,
194
234
  ...(modelFallback ? { modelFallback } : {}),
235
+ ...(modelOverride ? { modelOverride } : {}),
236
+ ...(ran ? { ranOn: ran } : {}),
195
237
  });
196
238
  }
197
239
  }
@@ -200,6 +242,15 @@ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string
200
242
 
201
243
  /** Fallbacks already recorded as trace events (root session : child session). */
202
244
  const recordedFallbacks = new Set<string>();
245
+ /** Overrides and ran-on models already recorded (same keys). */
246
+ const recordedOverrides = new Set<string>();
247
+ const recordedRanOn = new Set<string>();
248
+
249
+ /** A child's trace title: its task, with the model note when its model was not the one asked for. */
250
+ function childTitle(child: SpawnedChild): string | null {
251
+ const notes = [child.modelOverride?.note, child.modelFallback?.note].filter(Boolean);
252
+ return child.task && notes.length ? `${child.task} (${notes.join("; ")})` : child.task;
253
+ }
203
254
 
204
255
  /** The session file of `sessionId`: `<ts>_<id>.jsonl` in one of the session dirs. */
205
256
  async function findSessionFile(sessionId: string, dirs: readonly string[]): Promise<string | null> {
@@ -307,20 +358,20 @@ export async function parseSessionFileStream(file: string): Promise<{ header: Pi
307
358
  return { header, entries };
308
359
  }
309
360
 
310
- type Collector = Pick<
311
- WorkspaceCollector,
361
+ type Sync = Pick<
362
+ WorkspaceSync,
312
363
  "recordTrace" | "recordChildSession" | "childCheckpoints" | "childSessionIds"
313
364
  >;
314
365
 
315
366
  /**
316
367
  * Records the sub-agent tree below a settled session: one session.child event
317
368
  * per sub-agent carrying only its messages since its checkpoint, recursively
318
- * for the sub-agents it started (collector-observer.ts
369
+ * for the sub-agents it started (the desktop turn observer
319
370
  * `captureChildSessions`). `beforeRecord` hears the size of each event's
320
371
  * messages before it is recorded.
321
372
  */
322
373
  export async function captureChildSessions(input: {
323
- collector: Collector;
374
+ sync: Sync;
324
375
  rootSessionId: string;
325
376
  parentSessionId: string;
326
377
  parentEntries: readonly PiEntry[];
@@ -354,13 +405,13 @@ export async function captureChildSessions(input: {
354
405
  const messages = engineMessagesFromEntries(entries, { sessionId: child.id, agent, cwd });
355
406
  const history = turnHistory(messages, input.checkpoints[child.id], MAX_TURN_MESSAGE_BYTES);
356
407
  if (history.omitted > 0) {
357
- input.collector.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
408
+ input.sync.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
358
409
  }
359
410
  const fallbackKey = `${input.rootSessionId}:${child.id}`;
360
411
  if (child.modelFallback && !recordedFallbacks.has(fallbackKey)) {
361
412
  // As the desktop: the sub-agent ran on the main model, and why.
362
413
  recordedFallbacks.add(fallbackKey);
363
- input.collector.recordTrace(input.rootSessionId, "subagent.model_fallback", {
414
+ input.sync.recordTrace(input.rootSessionId, "subagent.model_fallback", {
364
415
  child_session_id: child.id,
365
416
  requested: child.modelFallback.requested,
366
417
  used: child.modelFallback.used,
@@ -369,13 +420,38 @@ export async function captureChildSessions(input: {
369
420
  kind: child.modelFallback.kind,
370
421
  });
371
422
  }
423
+ if (child.modelOverride && !recordedOverrides.has(fallbackKey)) {
424
+ // The task named a model that did not run: the user's /subagents
425
+ // choice won, or the gateway does not serve it to this account.
426
+ recordedOverrides.add(fallbackKey);
427
+ input.sync.recordTrace(input.rootSessionId, "subagent.model_override", {
428
+ child_session_id: child.id,
429
+ requested: child.modelOverride.requested,
430
+ used: child.modelOverride.used,
431
+ effort: child.modelOverride.effort,
432
+ reason: child.modelOverride.reason,
433
+ });
434
+ }
435
+ if (child.ranOn && !recordedRanOn.has(fallbackKey)) {
436
+ // What the sub-agent really ran on (after any gateway fallback).
437
+ recordedRanOn.add(fallbackKey);
438
+ input.sync.recordTrace(input.rootSessionId, "subagent.model", {
439
+ child_session_id: child.id,
440
+ role: child.role,
441
+ model: child.ranOn.model,
442
+ effort: child.ranOn.effort,
443
+ source: child.ranOn.source,
444
+ requested: child.ranOn.requested,
445
+ status: child.ranOn.status,
446
+ });
447
+ }
372
448
  if (history.delta.length > 0 || !input.known.has(child.id)) {
373
449
  await input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
374
- input.collector.recordChildSession(input.rootSessionId, {
450
+ input.sync.recordChildSession(input.rootSessionId, {
375
451
  childSessionId: child.id,
376
452
  parentSessionId: input.parentSessionId,
377
453
  depth: input.depth,
378
- title: child.task && child.modelFallback ? `${child.task} (${child.modelFallback.note})` : child.task,
454
+ title: childTitle(child),
379
455
  agent: turnModelFromMessages(messages)?.agent ?? agent,
380
456
  messages: history.delta,
381
457
  lastMessageId: typeof messages.at(-1)?.info.id === "string" ? (messages.at(-1)!.info.id as string) : null,
@@ -391,7 +467,7 @@ export default function (pi: any) {
391
467
  const parentSession = (process.env.OMNIRUSH_PARENT_SESSION || "").trim();
392
468
  if (parentSession) {
393
469
  // Its session says so (subagent-marker.ts; index.ts loads it for
394
- // sub-agent processes, which do not load the collector).
470
+ // sub-agent processes, which do not load the session uploader).
395
471
  markSubagentSession(pi, parentSession);
396
472
  return;
397
473
  }
@@ -411,7 +487,7 @@ export default function (pi: any) {
411
487
  if (level !== "warn" && !verbose) return;
412
488
  try {
413
489
  const detail = attributes && Object.keys(attributes).length > 0 ? ` ${JSON.stringify(attributes)}` : "";
414
- process.stderr.write(`omnirush collector: ${message}${detail}\n`);
490
+ process.stderr.write(`omnirush session upload: ${message}${detail}\n`);
415
491
  } catch {
416
492
  /* stderr may be closed late in shutdown */
417
493
  }
@@ -431,7 +507,7 @@ export default function (pi: any) {
431
507
 
432
508
  // Uploads carry the CURRENT on-disk token, not the one this process
433
509
  // 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
510
+ // Only requests that already carry a bearer (session upload, archive API) are
435
511
  // touched — presigned S3 part PUTs pass through unchanged.
436
512
  const withCurrentBearer = (input: string, init?: RequestInit): Promise<Response> => {
437
513
  const headers = new Headers(init?.headers ?? {});
@@ -450,7 +526,7 @@ export default function (pi: any) {
450
526
  return globalThis.fetch(input, init);
451
527
  };
452
528
 
453
- const collector = new WorkspaceCollector({
529
+ const sync = new WorkspaceSync({
454
530
  gatewayUrl,
455
531
  accessToken,
456
532
  refreshAccessToken,
@@ -484,12 +560,12 @@ export default function (pi: any) {
484
560
  });
485
561
  const archive = new ProjectArchiveLifecycle({
486
562
  archiver,
487
- enabled: projectArchiveEnabled(process.env) && collector.enabled,
563
+ enabled: projectArchiveEnabled(process.env) && sync.enabled,
488
564
  log,
489
565
  baseIdleMs: ARCHIVE_BASE_IDLE_MS,
490
566
  baseMaxDeferMs: ARCHIVE_BASE_MAX_DEFER_MS,
491
567
  });
492
- if (!collector.enabled) {
568
+ if (!sync.enabled) {
493
569
  // Signed out: clear what a previous run left queued (no network).
494
570
  archive.start();
495
571
  return;
@@ -502,21 +578,21 @@ export default function (pi: any) {
502
578
  id: string;
503
579
  root: string;
504
580
  ctx: any;
505
- /** A prompt went out in this process: the collector follows the session. */
581
+ /** A prompt went out in this process: the session uploader follows the session. */
506
582
  started: boolean;
507
583
  /** A prompt went out and its run has not settled. */
508
584
  running: boolean;
509
- /** The id of the last engine message recorded (the collector's checkpoint). */
585
+ /** The id of the last engine message recorded (the session uploader's checkpoint). */
510
586
  lastMessageId: string | undefined;
511
587
  toolStarts: Map<string, number>;
512
588
  seenFiles: Map<string, string>;
513
589
  /** Prompts sent in this process with the model and effort they went with, until their user entry is found. */
514
590
  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). */
591
+ /** User entry id -> what its prompt was sent with (engine-messages promptSettings). */
516
592
  promptSettings: Map<string, { model: { providerID: string; modelID: string } | null; variant: string | null }>;
517
593
  /** Prompts an extension sent (pi's input event, source "extension"), until their user entry is found. */
518
594
  extensionInputs: Array<{ at: number; text: string }>;
519
- /** User entries the user did not submit -> their parts' metadata (pi-engine injectedPrompts). */
595
+ /** User entries the user did not submit -> their parts' metadata (engine-messages injectedPrompts). */
520
596
  injectedPrompts: Map<string, Record<string, unknown>>;
521
597
  /** Whether the session is a sub-agent's (its first prompt the parent agent's task), once looked up. */
522
598
  origin: Promise<void> | null;
@@ -581,7 +657,7 @@ export default function (pi: any) {
581
657
  };
582
658
 
583
659
  /**
584
- * The session `ctx` is on. Its collection starts with its first prompt in
660
+ * The session `ctx` is on. Its upload starts with its first prompt in
585
661
  * this process, as the desktop starts it on a prompt dispatch: opening a
586
662
  * session and leaving without a prompt uploads nothing.
587
663
  */
@@ -609,7 +685,7 @@ export default function (pi: any) {
609
685
  session.ctx = ctx;
610
686
  // Idempotent: a session already started in this process keeps its segment.
611
687
  if (prompted) {
612
- collector.startSession(id, workspaceIdFor(session.root), session.root);
688
+ sync.startSession(id, workspaceIdFor(session.root), session.root);
613
689
  session.started = true;
614
690
  }
615
691
  return session;
@@ -624,20 +700,20 @@ export default function (pi: any) {
624
700
  const { id } = state;
625
701
  try {
626
702
  await captureChildSessions({
627
- collector,
703
+ sync,
628
704
  rootSessionId: id,
629
705
  parentSessionId: id,
630
706
  parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
631
707
  depth: 1,
632
708
  sessionDirs: sessionDirs(state.ctx),
633
- checkpoints: await collector.childCheckpoints(id),
634
- known: new Set(await collector.childSessionIds(id)),
709
+ checkpoints: await sync.childCheckpoints(id),
710
+ known: new Set(await sync.childSessionIds(id)),
635
711
  visited: new Set([id]),
636
712
  seenFiles: state.seenFiles,
637
713
  beforeRecord: room,
638
714
  });
639
715
  } catch (error) {
640
- collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
716
+ sync.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
641
717
  }
642
718
  };
643
719
 
@@ -653,9 +729,9 @@ export default function (pi: any) {
653
729
  let pending = 0;
654
730
  return async (bytes: number): Promise<void> => {
655
731
  if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
656
- collector.flushTrace(id);
732
+ sync.flushTrace(id);
657
733
  pending = 0;
658
- await collector.drained(id);
734
+ await sync.drained(id);
659
735
  }
660
736
  pending += bytes;
661
737
  };
@@ -673,37 +749,37 @@ export default function (pi: any) {
673
749
  let parts: Array<{ messages: unknown[]; bytes: number }> = [{ messages: [], bytes: 0 }];
674
750
  try {
675
751
  messages = messagesOf(state);
676
- const checkpoint = state.lastMessageId ?? (await collector.sessionCheckpoint(id)).lastMessageId;
752
+ const checkpoint = state.lastMessageId ?? (await sync.sessionCheckpoint(id)).lastMessageId;
677
753
  const history = turnHistory(messages, checkpoint);
678
754
  const lastId = messages.at(-1)?.info.id;
679
755
  if (typeof lastId === "string") {
680
756
  state.lastMessageId = lastId;
681
- void collector.setSessionCheckpoint(id, lastId);
757
+ void sync.setSessionCheckpoint(id, lastId);
682
758
  }
683
- if (history.omitted > 0) collector.recordTrace(id, "session.messages_omitted", { count: history.omitted });
759
+ if (history.omitted > 0) sync.recordTrace(id, "session.messages_omitted", { count: history.omitted });
684
760
  const model = turnModelFromMessages(history.delta);
685
- if (model) collector.recordSessionModel(id, model);
761
+ if (model) sync.recordSessionModel(id, model);
686
762
  // More messages than one flush holds: the older ones go ahead, a flush per part.
687
763
  parts = messageParts(history.delta, history.sizes);
688
764
  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);
765
+ sync.recordTrace(id, "turn.messages", { messages: part.messages, part: index + 1, parts: parts.length });
766
+ sync.flushTrace(id);
691
767
  // One part waits in memory at a time (a resumed session's backlog is up to 32 MiB).
692
- await collector.drained(id);
768
+ await sync.drained(id);
693
769
  }
694
770
  } catch (error) {
695
- collector.recordTrace(id, "session.messages_failed", { error: error instanceof Error ? error.message : "unknown" });
771
+ sync.recordTrace(id, "session.messages_failed", { error: error instanceof Error ? error.message : "unknown" });
696
772
  }
697
773
  const newest = parts.at(-1)!;
698
774
  // Sub-agent messages flush on their own before they would crowd the turn's out.
699
775
  const room = childRoom(id);
700
776
  await captureChildren(state, room);
701
777
  await room(newest.bytes);
702
- collector.recordTrace(id, "session.idle", { status });
778
+ sync.recordTrace(id, "session.idle", { status });
703
779
  // The turn snapshot runs first so the artifacts it discovers are part of
704
780
  // the trace flushed right behind it.
705
- collector.captureSnapshot(id, "turn_completed");
706
- collector.flushTrace(id, { messages: newest.messages });
781
+ sync.captureSnapshot(id, "turn_completed");
782
+ sync.flushTrace(id, { messages: newest.messages });
707
783
  archive.turnCompleted(id, messages);
708
784
  };
709
785
 
@@ -735,14 +811,14 @@ export default function (pi: any) {
735
811
  const state = session;
736
812
  if (!state?.started) return;
737
813
  for (const child of interrupted) {
738
- collector.recordTrace(state.id, "session.child_interrupted", { child_session_id: child.sessionId, agent: child.role });
814
+ sync.recordTrace(state.id, "session.child_interrupted", { child_session_id: child.sessionId, agent: child.role });
739
815
  }
740
816
  const lastId = messagesOf(state).at(-1)?.info.id;
741
817
  if (typeof lastId !== "string" || lastId === state.lastMessageId) {
742
818
  // No new message of its own: what its sub-agents did since the last settle.
743
819
  settling = settling.then(async () => {
744
820
  await captureChildren(state, childRoom(state.id));
745
- collector.flushTrace(state.id);
821
+ sync.flushTrace(state.id);
746
822
  }).catch(() => undefined);
747
823
  await settling;
748
824
  return;
@@ -755,7 +831,7 @@ export default function (pi: any) {
755
831
  let stopping: Promise<void> | null = null;
756
832
  const shutdown = (budgetMs: number): Promise<void> => (stopping ??= stop(budgetMs));
757
833
  const stop = async (budgetMs: number): Promise<void> => {
758
- // A ref'd timer: the collector's and the archive's own timers are unref'd,
834
+ // A ref'd timer: the session uploader's and the archive's own timers are unref'd,
759
835
  // and pi awaits this handler without holding the process open, so without
760
836
  // it a process with nothing else to do exits in the middle of an upload.
761
837
  let budget: ReturnType<typeof setTimeout> | undefined;
@@ -770,12 +846,12 @@ export default function (pi: any) {
770
846
  if (state) {
771
847
  // The settled turn's own snapshot (its turn.diff) and trace go out
772
848
  // before the session ends: a finished session skips queued captures.
773
- await collector.idle(state.id);
774
- collector.finishSession(state.id);
849
+ await sync.idle(state.id);
850
+ sync.finishSession(state.id);
775
851
  }
776
852
  // The base and turn deltas upload now; what is left waits in the queue.
777
853
  await archive.settled();
778
- await collector.stop();
854
+ await sync.stop();
779
855
  })(),
780
856
  deadline,
781
857
  ]);
@@ -837,7 +913,7 @@ export default function (pi: any) {
837
913
  // The prompt as the desktop records its engine request: text, attached
838
914
  // files (inline bytes replaced by a marker), model, variant, agent and
839
915
  // the system prompt it ran with.
840
- collector.recordTrace(state.id, "engine.request", {
916
+ sync.recordTrace(state.id, "engine.request", {
841
917
  method: "POST",
842
918
  path: `/session/${state.id}/prompt`,
843
919
  body: {
@@ -861,7 +937,7 @@ export default function (pi: any) {
861
937
  images.forEach((image: any, index: number) => {
862
938
  if (typeof image?.data !== "string") return;
863
939
  const bytes = Buffer.from(image.data, "base64");
864
- collector.recordAttachment(state.id, {
940
+ sync.recordAttachment(state.id, {
865
941
  name: `image-${index + 1}`,
866
942
  mime: typeof image.mimeType === "string" ? image.mimeType : "application/octet-stream",
867
943
  bytes: bytes.length,
@@ -869,7 +945,7 @@ export default function (pi: any) {
869
945
  text: null,
870
946
  });
871
947
  });
872
- collector.captureSnapshot(state.id, "prompt");
948
+ sync.captureSnapshot(state.id, "prompt");
873
949
  archive.sessionStarted({
874
950
  sessionId: state.id,
875
951
  root: state.root,