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
@@ -10,7 +10,7 @@
10
10
  // of the folder /resume and --continue read, so a user only ever sees
11
11
  // their own conversations there), under a session id the parent
12
12
  // picks (`--session-id`) and returns in the result (`session_id`): the
13
- // parent session's collector reads the child's session file from it and
13
+ // parent session's session uploader reads the child's session file from it and
14
14
  // records the child as a sub-agent of the turn (session.child events,
15
15
  // grandchildren included), the way the desktop records task sub-agents.
16
16
  //
@@ -35,6 +35,7 @@ import {
35
35
  ENV_FALLBACK_MODEL,
36
36
  FALLBACK_MARKER,
37
37
  fallbackNote,
38
+ sourceNote,
38
39
  piThinking,
39
40
  type SubagentFallback,
40
41
  } from "./subagents-lib";
@@ -87,7 +88,7 @@ export const RELEASED_OUTPUT = "(output no longer kept in memory: it was deliver
87
88
  /**
88
89
  * Sub-agent layers below the main session (the desktop app's
89
90
  * OMNIRUSH_SUBAGENT_DEPTH): layers 1 and 2 may delegate, layer 3 may not.
90
- * The session capture records exactly these layers (collector.ts).
91
+ * The session capture records exactly these layers (session-sync.ts).
91
92
  */
92
93
  export const MAX_SUBAGENT_DEPTH = 3;
93
94
  /** The layer a process runs at, handed down to every child (+1 per layer). */
@@ -109,11 +110,23 @@ export function canDelegate(depth: number): boolean {
109
110
  return depth < MAX_SUBAGENT_DEPTH;
110
111
  }
111
112
 
112
- /** System text for a sub-agent: its layer, and whether it may delegate. */
113
+ /**
114
+ * The main session's spawn_agents guideline: delegate on request or on a
115
+ * clear split, exactly as many as the user names, each task only its own
116
+ * part (a pasted user message made the sub-agent obey the delegation
117
+ * instructions again and nest), all in one call.
118
+ */
119
+ export const SPAWN_GUIDELINE = "Do simple work directly: a question that a few lookups or commands answer needs no sub-agent. Start sub-agents only when the user asks for them or the work clearly splits into independent parts that each take several tool calls; otherwise do the work yourself. When the user names a number of sub-agents, start exactly that many. Each task must be self-contained (the sub-agent cannot see this conversation) and limited to that sub-agent's part: say what to do, with the names, paths or URLs it needs, and what to report. Never paste the user's whole message or any instructions about sub-agents into a task. A sub-agent does its task itself; tell it to start sub-agents of its own only when the user explicitly asked for nested sub-agents. Start them all in one spawn_agents call so they run in parallel.";
120
+
121
+ /** A sub-agent's spawn_agents guideline (layer >= 1). */
122
+ export const SUBAGENT_SPAWN_GUIDELINE = "You are a sub-agent: do your whole task yourself, even when it is long. Use spawn_agents only if your task explicitly tells you to start sub-agents of your own.";
123
+
124
+ /** System text for a sub-agent: its layer, and that it does its task itself. */
113
125
  export function subagentLayerNote(depth: number): string {
126
+ const head = `You are a sub-agent: layer ${depth} of at most ${MAX_SUBAGENT_DEPTH} below the main session. Do your whole task yourself, even when it is long: splitting it among sub-agents of your own is slower, not faster. Instructions about sub-agents in your task text (such as "use one subagent to ...") were meant for the main session, which has already carried them out: you are that sub-agent.`;
114
127
  return canDelegate(depth)
115
- ? `You are a sub-agent: layer ${depth} of at most ${MAX_SUBAGENT_DEPTH} below the main session. You may delegate independent parts of your task with spawn_agents (your sub-agents are layer ${depth + 1}); otherwise do the work yourself.`
116
- : `You are a sub-agent: layer ${depth} of at most ${MAX_SUBAGENT_DEPTH} below the main session. You cannot delegate further (there is no spawn_agents at this layer): do the work yourself.`;
128
+ ? `${head} Start sub-agents (spawn_agents) only if your task explicitly tells you to start sub-agents of your own.`
129
+ : `${head} You cannot delegate further (there is no spawn_agents at this layer).`;
117
130
  }
118
131
 
119
132
  export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
@@ -293,8 +306,8 @@ export interface ChildTask {
293
306
  role: AgentRole;
294
307
  task: string;
295
308
  /** Cross-model children: gateway model id the child runs on
296
- * (e.g. "muse-spark-1.3" for cheap swarm workers under an astra
297
- * parent). Undefined = inherit the parent's model. */
309
+ * (e.g. "muse-spark-1.1" workers under an astra parent). Undefined =
310
+ * inherit the parent's model. */
298
311
  model?: string;
299
312
  /** The effort the child runs on (gateway spelling); undefined = pi's default. */
300
313
  effort?: string;
@@ -304,6 +317,21 @@ export interface ChildTask {
304
317
  gatewayFallback?: { model: string; effort: string | null };
305
318
  /** Extra environment for the child (the sub-agent setting and main model, for nested layers). */
306
319
  env?: Record<string, string>;
320
+ /** The model[:effort] the delegating agent asked for (the task's own `model`), when it named one. */
321
+ requestedModel?: string;
322
+ /** That model did not run: the user's /subagents choice won, or the gateway does not serve it. */
323
+ override?: ChildModelOverride;
324
+ /** Where `model` comes from ("subagents", "user_prompt", "task", "parent"). */
325
+ modelSource?: string;
326
+ }
327
+
328
+ /** A task model that did not run, and what ran instead (the result's model_override). */
329
+ export interface ChildModelOverride {
330
+ requested: string;
331
+ used: string;
332
+ effort: string | null;
333
+ reason: string;
334
+ note: string;
307
335
  }
308
336
 
309
337
  /** A sub-agent that ran on the main model instead of the picked one. */
@@ -330,6 +358,12 @@ export interface ChildResult {
330
358
  effort?: string;
331
359
  /** It ran on the main model instead of the picked one (why, and since when). */
332
360
  model_fallback?: ChildModelFallback;
361
+ /** The task's own model did not run (the user's /subagents choice, or not served). */
362
+ model_override?: ChildModelOverride;
363
+ /** Where the model comes from: "subagents", "user_prompt", "task" or "parent". */
364
+ model_source?: string;
365
+ /** The model[:effort] the task asked for, when it named one. */
366
+ requested_model?: string;
333
367
  status: ChildStatus;
334
368
  /** Process exit code (null when killed by a signal or still unknown). */
335
369
  exitCode: number | null;
@@ -400,6 +434,9 @@ export function buildChildResult(
400
434
  ...(task.model ? { model: task.model } : {}),
401
435
  ...(task.effort ? { effort: task.effort } : {}),
402
436
  ...(modelFallback ? { model_fallback: modelFallback } : {}),
437
+ ...(task.override ? { model_override: { ...task.override } } : {}),
438
+ ...(task.modelSource ? { model_source: task.modelSource } : {}),
439
+ ...(task.requestedModel ? { requested_model: task.requestedModel } : {}),
403
440
  status: input.status ?? (input.exitCode === 0 ? "completed" : "failed"),
404
441
  exitCode: input.exitCode,
405
442
  output: capped,
@@ -632,7 +669,7 @@ export interface ChildActivity {
632
669
  }
633
670
 
634
671
  /**
635
- * The collector reaches the running sub-agents through this global (it must
672
+ * The session uploader reaches the running sub-agents through this global (it must
636
673
  * stop them before its last capture; pi runs its session_shutdown handler
637
674
  * before the agents extension's).
638
675
  */
@@ -909,16 +946,34 @@ export async function mapWithConcurrency<TIn, TOut>(
909
946
  return results;
910
947
  }
911
948
 
949
+ /** The model and effort a child really ran on (after a gateway fallback), and why that one. */
950
+ export function ranOn(result: Pick<ChildResult, "model" | "effort" | "model_fallback">): { model: string | null; effort: string | null } {
951
+ if (result.model_fallback?.kind === "gateway") return { model: result.model_fallback.used, effort: result.model_fallback.effort ?? null };
952
+ return { model: result.model ?? null, effort: result.effort ?? null };
953
+ }
954
+
955
+ function ranOnText(result: ChildResult): string {
956
+ const { model, effort } = ranOn(result);
957
+ if (!model) return "the parent's model (not an omnirush.ai model)";
958
+ const why = result.model_fallback?.kind === "gateway"
959
+ ? "the gateway refused the chosen model"
960
+ : result.model_fallback
961
+ ? "the main model"
962
+ : sourceNote(result.model_source);
963
+ return `${model}${effort ? ` · ${effort}` : ""}${why ? ` (${why})` : ""}`;
964
+ }
965
+
912
966
  /** Structured result text for the parent model (one section per child). */
913
967
  export function renderChildResults(results: ChildResult[], ids?: readonly string[]): string {
914
968
  const succeeded = results.filter((result) => result.status === "completed").length;
915
969
  const sections = results.map((result, index) => {
916
970
  const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
917
- const ranOn = result.model_fallback?.kind === "gateway" ? result.model_fallback.used : result.model;
918
- const effort = result.model_fallback?.kind === "gateway" ? result.model_fallback.effort ?? undefined : result.effort;
919
- const label = ranOn ? ` [${ranOn}${effort ? ` · ${effort}` : ""}]` : "";
971
+ const ran = ranOn(result);
972
+ const label = ran.model ? ` [${ran.model}${ran.effort ? ` · ${ran.effort}` : ""}]` : "";
920
973
  const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${label} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
921
974
  const meta: string[] = [`task: ${result.task}`];
975
+ meta.push(`ran on: ${ranOnText(result)}`);
976
+ if (result.model_override) meta.push(`note: ${result.model_override.note}`);
922
977
  if (result.model_fallback) meta.push(`note: ${result.model_fallback.note}`);
923
978
  if (result.error) meta.push(`error: ${result.error}`);
924
979
  return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
@@ -27,7 +27,7 @@
27
27
  // <session dir>/subagents/ (children run without --no-session; that
28
28
  // folder keeps them out of /resume and --continue)
29
29
  // under a session id the parent picks, and the parent session's
30
- // collector records each child's conversation as a sub-agent of the
30
+ // session uploader records each child's conversation as a sub-agent of the
31
31
  // turn (session.child events with parent and depth, as the desktop
32
32
  // records task sub-agents) — a background child that finishes in a later
33
33
  // turn is recorded as it progresses, and one the session ends under is
@@ -61,6 +61,8 @@ import {
61
61
  childInactivityMs,
62
62
  renderAgentStatus,
63
63
  subagentLayerNote,
64
+ SPAWN_GUIDELINE,
65
+ SUBAGENT_SPAWN_GUIDELINE,
64
66
  renderChildResults,
65
67
  renderDelivery,
66
68
  runChildAgent,
@@ -71,6 +73,7 @@ import {
71
73
  } from "./agents-lib";
72
74
  import { SWARM_GUIDELINE } from "./swarm-lib";
73
75
  import { omniDir } from "./auth";
76
+ import { loadCatalog, type CatalogModel } from "./subagents-lib";
74
77
  import { MemoryAdmission, readMemorySettings } from "./memory-lib";
75
78
 
76
79
  /** Blocking spawn_agents: progress updates at most this often (ms). */
@@ -82,16 +85,32 @@ const MIN_TIMEOUT_MINUTES = 1;
82
85
  /** customType of the message that brings background results back. */
83
86
  export const DELIVERY_TYPE = "omnirush-agents-result";
84
87
 
85
- const SpawnAgentsParams = Type.Object({
88
+ /** Served ids used as examples when the account's catalog is not cached (the live list at 0.10.3). */
89
+ export const SERVED_MODEL_EXAMPLES = ["gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol", "meta-muse-spark", "muse-spark-1.1"];
90
+
91
+ /** Examples for the per-task model: the ids the account's live catalog lists (served models only). */
92
+ export function modelExamples(catalog: readonly CatalogModel[] | null): string[] {
93
+ if (!catalog || catalog.length === 0) return SERVED_MODEL_EXAMPLES;
94
+ return catalog.map((model) => model.id);
95
+ }
96
+
97
+ export function modelParamDescription(examples: readonly string[]): string {
98
+ return [
99
+ "Optional model[:effort] for THIS task. Usually omit it: set it only when the user asked for a model for the sub-agents.",
100
+ `Served models: ${examples.map((id) => `"${id}"`).join(", ")}, optionally with an effort (e.g. "${examples[0] ?? "gpt-6-astra"}:high"); a model the account is not served is never used (the child runs on your model and effort).`,
101
+ "The user's /subagents choice always wins: while it is set, a model named here runs only if the user's own message names it.",
102
+ "Omitted, the child runs on the /subagents choice, else this session's current model and effort.",
103
+ ].join(" ");
104
+ }
105
+
106
+ const spawnAgentsParams = (examples: readonly string[]) => Type.Object({
86
107
  tasks: Type.Array(
87
108
  Type.Object({
88
109
  role: StringEnum(AGENT_ROLES, {
89
110
  description: "code-searcher: read-only codebase exploration; researcher-web: web research via web_search/web_fetch; general-worker: full-tool implementation work",
90
111
  }),
91
112
  task: Type.String({ description: "Self-contained task description for the child agent (it sees ONLY this, not your conversation)" }),
92
- model: Type.Optional(Type.String({
93
- description: 'Optional model[:effort] for THIS task (e.g. "muse-spark-1.3", "muse-spark-1.2-contributor" for cheap swarm workers, "meta-muse-spark", "muse-spark-1.1:low", "gpt-6-astra", "gpt-6-sol:high", "gpt-5.6-sol"). Omit (or leave empty) for the sub-agent model the user picked with /subagents, else the parent session\'s current model and effort',
94
- })),
113
+ model: Type.Optional(Type.String({ description: modelParamDescription(examples) })),
95
114
  }),
96
115
  { description: "Tasks to delegate; they all run in parallel", minItems: 1 },
97
116
  ),
@@ -113,6 +132,26 @@ const IdsParam = Type.Optional(Type.Array(Type.String(), {
113
132
  description: 'Agent ids from spawn_agents (e.g. "a3"); omit (or "all") for every agent of this session',
114
133
  }));
115
134
 
135
+ /** The text of the latest user message on the session's branch (null when there is none). */
136
+ export function lastUserMessageText(ctx: any): string | null {
137
+ let entries: any[] = [];
138
+ try {
139
+ entries = ctx?.sessionManager?.getBranch?.() ?? ctx?.sessionManager?.getEntries?.() ?? [];
140
+ } catch {
141
+ return null;
142
+ }
143
+ for (let index = entries.length - 1; index >= 0; index--) {
144
+ const message = entries[index]?.type === "message" ? entries[index].message : null;
145
+ if (!message || message.role !== "user") continue;
146
+ if (typeof message.content === "string") return message.content;
147
+ if (Array.isArray(message.content)) {
148
+ return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
149
+ }
150
+ return null;
151
+ }
152
+ return null;
153
+ }
154
+
116
155
  function resultOf(record: AgentRecord): ChildResult & { agent_id: string } {
117
156
  return { ...(record.result as ChildResult), agent_id: record.id };
118
157
  }
@@ -225,6 +264,30 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
225
264
  };
226
265
  (globalThis as any)[AGENTS_HOOK] = hook;
227
266
 
267
+ // The user's latest message (the text they typed, not an extension's):
268
+ // a task's own model runs over the /subagents choice only when it names
269
+ // that model. Sub-agents have no user: their task text never counts.
270
+ let lastUserInput: { session: string; text: string } | null = null;
271
+ const sessionIdOf = (ctx: any): string => {
272
+ try {
273
+ return String(ctx?.sessionManager?.getSessionId?.() ?? "");
274
+ } catch {
275
+ return "";
276
+ }
277
+ };
278
+ if (depth === 0) {
279
+ pi.on("input", (event: any, ctx: any) => {
280
+ if (event?.source === "extension" || typeof event?.text !== "string") return;
281
+ lastUserInput = { session: sessionIdOf(ctx), text: event.text };
282
+ });
283
+ }
284
+ const latestUserPrompt = (ctx: any): string | null => {
285
+ if (depth > 0) return null;
286
+ const session = sessionIdOf(ctx);
287
+ if (lastUserInput && lastUserInput.session === session) return lastUserInput.text;
288
+ return lastUserMessageText(ctx);
289
+ };
290
+
228
291
  const sessionOf = (ctx: any): string => {
229
292
  lastContext = ctx ?? lastContext;
230
293
  return hub.sessionOf(ctx);
@@ -242,19 +305,20 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
242
305
  "Default (wait true) blocks until all children finish and returns their results.",
243
306
  "wait:false dispatches them in the BACKGROUND: the call returns at once with agent ids, you keep working, and their results arrive automatically as a message in this conversation when they finish.",
244
307
  "Manage background agents with agents_status, agents_wait, agents_result and agents_cancel.",
245
- 'Each task can run on a DIFFERENT model via its "model" field — e.g. cheap muse workers for wide swarm sweeps under an astra parent.',
308
+ 'Each task can run on a different model via its "model" field when the user asks for one; the user\'s /subagents choice wins over it, and a model the account is not served is not used. Every result says the model and effort the child ran on.',
246
309
  "Every task description must be SELF-CONTAINED: the child cannot see this conversation.",
247
310
  "Use for parallelizable work: broad code surveys, independent research questions, independent implementation chunks.",
311
+ "Give each task only its own part of the work: never the user's whole message or instructions about sub-agents (the child would follow them and delegate again).",
248
312
  ].join(" "),
249
313
  promptSnippet: "spawn_agents: delegate parallel tasks to role-preset subagents (blocking, or wait:false for background work)",
250
314
  promptGuidelines: [
251
- "Use spawn_agents when 2+ independent sub-tasks would each take several tool calls (surveys, research, independent fixes); write each task brief as a self-contained mini-prompt with the goal, relevant paths and the expected report format.",
315
+ depth > 0 ? SUBAGENT_SPAWN_GUIDELINE : SPAWN_GUIDELINE,
252
316
  "Short fan-outs whose results you need right away (quick searches, lookups): the default blocking call.",
253
317
  "Long work (implementation chunks, builds, test suites, long research — anything that may take many minutes) while you have other things to do: spawn_agents with wait:false, then keep working; the results arrive on their own as a message, so do not poll agents_status in a loop. Call agents_wait only when you have nothing else to do and need the results before going on.",
254
318
  "Do not use spawn_agents for a single quick action — doing it yourself is cheaper.",
255
- SWARM_GUIDELINE,
319
+ ...(depth > 0 ? [] : [SWARM_GUIDELINE]),
256
320
  ],
257
- parameters: SpawnAgentsParams,
321
+ parameters: spawnAgentsParams(modelExamples(loadCatalog(omniDir()))),
258
322
 
259
323
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
260
324
  const parentSessionId = sessionOf(ctx);
@@ -263,9 +327,9 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
263
327
  if (!raw || typeof raw.task !== "string" || !raw.task.trim()) {
264
328
  throw new Error("invalid tasks: every entry needs a non-empty task string");
265
329
  }
266
- // A task's own model[:effort] wins; an omitted (or empty) one takes
267
- // the /subagents pick, else the parent session's current model and
268
- // effort (resolved below by subagentTasks).
330
+ // A task's own model[:effort] (resolved below by subagentTasks):
331
+ // the user's /subagents choice wins over it unless the user's message
332
+ // names that model; unset, it runs only when the gateway serves it.
269
333
  const requestedModel = typeof raw.model === "string" ? raw.model.trim() : "";
270
334
  tasks.push({
271
335
  role: raw.role,
@@ -274,11 +338,11 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
274
338
  });
275
339
  }
276
340
  if (tasks.length === 0) throw new Error("no tasks given");
277
- // The model and effort each child runs on (subagents.ts): the task's
278
- // own model[:effort], else the /subagents pick, else the parent
341
+ // The model and effort each child runs on (subagents.ts): the
342
+ // /subagents choice, else a served task model, else the parent
279
343
  // session's current model and effort; the main model when a pick is
280
- // unavailable.
281
- const resolvedTasks = subagentTasks(pi, ctx, tasks);
344
+ // unavailable. Overrides are noted on the result and the trace.
345
+ const resolvedTasks = subagentTasks(pi, ctx, tasks, { userPrompt: latestUserPrompt(ctx) });
282
346
  if (!parentSessionId) throw new Error("no parent session id — subagents cannot be traced");
283
347
  const workdir = ctx?.cwd ? String(ctx.cwd) : process.cwd();
284
348
  // Only a deliberate limit counts: models fill optional numbers with
@@ -331,7 +395,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
331
395
 
332
396
  if (background) {
333
397
  const lines = dispatched.records.map((record, index) => {
334
- const note = childModelFallback(resolvedTasks[index])?.note;
398
+ const note = [resolvedTasks[index].override?.note, childModelFallback(resolvedTasks[index])?.note].filter(Boolean).join("; ");
335
399
  return `- ${record.id}: ${record.role}${record.model ? ` [${record.model}${record.effort ? ` · ${record.effort}` : ""}]` : ""} — ${record.task.length > 100 ? `${record.task.slice(0, 99)}…` : record.task}${note ? ` (${note})` : ""}`;
336
400
  });
337
401
  return {
@@ -360,6 +424,9 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
360
424
  ...(record.model ? { model: record.model } : {}),
361
425
  ...(record.effort ? { effort: record.effort } : {}),
362
426
  ...(modelFallback ? { model_fallback: modelFallback } : {}),
427
+ ...(resolvedTasks[index].override ? { model_override: { ...resolvedTasks[index].override } } : {}),
428
+ ...(resolvedTasks[index].modelSource ? { model_source: resolvedTasks[index].modelSource } : {}),
429
+ ...(resolvedTasks[index].requestedModel ? { requested_model: resolvedTasks[index].requestedModel } : {}),
363
430
  parallel_limit: record.parallelLimit,
364
431
  status: "running",
365
432
  };
@@ -474,7 +541,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admissi
474
541
  });
475
542
 
476
543
  pi.on("session_shutdown", async () => {
477
- // Children the session ends under are stopped (the collector, when it
544
+ // Children the session ends under are stopped (the session uploader, when it
478
545
  // runs, already did this before its last capture).
479
546
  await manager.interruptAll().catch(() => undefined);
480
547
  });
@@ -1,5 +1,5 @@
1
1
  // Omnirush device-flow auth core — shared by the CLI (src/bin.js), the
2
- // extensions (sota guard 401-refresh, collector), and node:test.
2
+ // extensions (sota guard 401-refresh, session uploader), and node:test.
3
3
  //
4
4
  // Plain ESM JavaScript on node builtins only: pi extensions are loaded
5
5
  // through jiti with a fixed virtual-module set (pi packages + typebox),
@@ -23,7 +23,8 @@
23
23
  // -> 409 {"detail":"refresh_token_already_used"} (race)
24
24
  // -> 403 {"detail":"account_inactive|account_pending|account_rejected"}
25
25
  // GET /device/me -> 200 device identity | 401 device_token_invalid
26
- // POST /collect -> zstd body, X-Omnirush-Session-ID header
26
+ // POST <upload endpoint> -> zstd body, X-Omnirush-Session-ID header
27
+ // (UPLOAD_ENDPOINT_PATH, capture/workspace-sync.ts)
27
28
  //
28
29
  // SECURITY: nothing in this module ever returns or logs a token; error
29
30
  // messages carry only server `detail` strings and HTTP statuses.
@@ -41,7 +41,7 @@ import {
41
41
  tailText,
42
42
  } from "./bgshell-lib";
43
43
  import { deliveryHub } from "./deliveries";
44
- import { BACKGROUND_BASH_TYPE } from "./pi-engine";
44
+ import { BACKGROUND_BASH_TYPE } from "./engine-messages";
45
45
  import { sanitizeToolEnvironment } from "./secret-env";
46
46
 
47
47
  /** customType of the message that brings finished background commands back (the trace maps it to bash tool parts). */
@@ -78,14 +78,14 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
78
78
  const fgDefault = defaultForegroundSeconds();
79
79
 
80
80
  /** New output of each job, marked read. */
81
- const collect = (jobs: ShellJob[]) => jobs.map((job) => ({ job, ...manager.read(job) }));
81
+ const readAll = (jobs: ShellJob[]) => jobs.map((job) => ({ job, ...manager.read(job) }));
82
82
 
83
83
  hub.addSource({
84
84
  hasPending: (session) => manager.hasPending(session),
85
85
  take: (session) => {
86
86
  const jobs = manager.takeDeliverable(session);
87
87
  if (jobs.length === 0) return [];
88
- const parts = collect(jobs);
88
+ const parts = readAll(jobs);
89
89
  return [{
90
90
  customType: BASH_DELIVERY_TYPE,
91
91
  content: renderJobDelivery(parts),
@@ -115,7 +115,7 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
115
115
 
116
116
  // --- stopping at the end of the session -------------------------------------
117
117
 
118
- /** What the session's end stopped (the collector's hook may do it before our own handler runs). */
118
+ /** What the session's end stopped (the session uploader's hook may do it before our own handler runs). */
119
119
  let stoppedAtEnd: ShellJob[] = [];
120
120
  const stopAll = async (session?: string): Promise<ShellJob[]> => {
121
121
  const live = manager.running(session);
@@ -127,7 +127,7 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
127
127
  const background = stopped.filter((job) => job.background);
128
128
  if (background.length > 0) {
129
129
  try {
130
- const parts = collect(background);
130
+ const parts = readAll(background);
131
131
  pi.sendMessage(
132
132
  {
133
133
  customType: BASH_DELIVERY_TYPE,
@@ -296,7 +296,7 @@ export default function (pi: any, options: { exec?: ShellExec; now?: () => numbe
296
296
  }));
297
297
 
298
298
  const readResult = (jobs: ShellJob[], unknown: string[]) => {
299
- const parts = collect(jobs);
299
+ const parts = readAll(jobs);
300
300
  const sections = parts.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text);
301
301
  if (unknown.length) sections.push(`Unknown ids: ${unknown.join(", ")}`);
302
302
  return {
@@ -1,11 +1,11 @@
1
1
  Ported from omnirush-ai/omnirush-gui @ 2e166a9f9843dccbedc8277addcd94b752e66adb
2
2
  (apps/server/src), unchanged except for the CLI seams marked "CLI:" in the code:
3
3
 
4
- workspace-collector.ts vendored minimatch import; exported ledger reader and
4
+ workspace-sync.ts vendored minimatch import; exported ledger reader and
5
5
  .gitignore helpers; `watch: false` (poll on snapshots)
6
6
  and `envelopeMetadata` options
7
7
  turn-diff.ts unchanged
8
- collect-upload-budget.ts unchanged
8
+ sync-upload-budget.ts unchanged
9
9
  session-archive/detect.ts, files.ts, lifecycle.ts, pack.ts, seal.ts unchanged
10
10
  session-archive/index.ts, policy.ts, upload.ts vendored zod import
11
11
  session-archive/manifest.ts, touched.ts gitignored content left out
@@ -14,8 +14,11 @@ Ported from omnirush-ai/omnirush-gui @ 2e166a9f9843dccbedc8277addcd94b752e66adb
14
14
  CLI files beside them:
15
15
  session-archive/ignored.ts git's ignore rules for the archive scans
16
16
  server-fetch.ts external egress = the global fetch
17
- omnirush-swarm.ts the one constant the collector reads
17
+ omnirush-swarm.ts the one constant workspace-sync.ts reads
18
18
  vendor/ zod and minimatch (npm run build:capture-vendor)
19
19
 
20
- Resync: copy the files again, re-apply the seams, then run
20
+ The CLI names some modules and exports differently from upstream
21
+ (dev/gui-parity-tests.sh maps them back for the desktop's test suites).
22
+
23
+ Resync: copy the files again, re-apply the seams and the CLI names, then run
21
24
  dev/gui-parity-tests.sh <omnirush-gui checkout>.
@@ -1,6 +1,6 @@
1
- // The one constant the ported workspace collector reads from the desktop's
1
+ // The one constant the ported workspace uploader reads from the desktop's
2
2
  // swarm module (apps/server/src/omnirush-swarm.ts): the trace event that
3
3
  // records a sub-agent answered on the main model instead of the picked one.
4
4
  // The CLI never emits it (its sub-agents run in their own process on the
5
- // model they were given), but the collector keeps the same handling.
5
+ // model they were given), but the session uploader keeps the same handling.
6
6
  export const SUBAGENT_MODEL_FALLBACK_TRACE = "subagent.model_fallback";
@@ -1,7 +1,7 @@
1
1
  // Egress seam of the ported capture modules (desktop: apps/server/src/
2
2
  // server-fetch.ts). The desktop routes external egress through the embedding
3
3
  // runtime's network stack; the CLI has one runtime and one fetch, so every
4
- // upload (collect envelopes, archive API calls, presigned S3 part PUTs) goes
4
+ // upload (session upload envelopes, archive API calls, presigned S3 part PUTs) goes
5
5
  // through the global fetch.
6
6
  export function externalFetch(input: string, init?: RequestInit): Promise<Response> {
7
7
  return globalThis.fetch(input, init);
@@ -12,7 +12,7 @@ import { lstat, open, realpath } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import path, { isAbsolute, join, parse, posix, relative, resolve, sep, win32 } from "node:path";
14
14
 
15
- import { isCollectorDirectoryDenied } from "../workspace-collector.js";
15
+ import { isSyncDirectoryDenied } from "../workspace-sync.js";
16
16
  import type { ArchivePolicy } from "./policy.js";
17
17
 
18
18
  export type ArchivableProject = {
@@ -291,7 +291,7 @@ export function foldGatePath(path: string, platform: NodeJS.Platform): string {
291
291
  * - the Electron userData directory, and anything above it;
292
292
  * - a credential store wherever it is (`.ssh`, `.aws`, `.gnupg`, `.kube`,
293
293
  * `.docker`, `.azure`, `.password-store`, `Keychains`, `.config/gcloud`),
294
- * and any folder the collector's denylist denies as a whole (`keys`,
294
+ * and any folder the session uploader's denylist denies as a whole (`keys`,
295
295
  * `secrets`, `credentials*`, `.env*`, `node_modules`, `.git`, ...);
296
296
  * - app data wherever it is (`AppData`, `Library/Application Support`);
297
297
  * - in the home directory, and in the other folders beside it (other
@@ -317,7 +317,7 @@ export function refusedFolderRoot(root: string, context: FolderGateContext): Fol
317
317
 
318
318
  const names = target.slice(paths.parse(target).root.length).split(paths.sep).filter(Boolean).map((name) => name.toLowerCase());
319
319
  const hasPair = (pairs: ReadonlyArray<readonly [string, string]>) => names.some((name, index) => pairs.some(([first, second]) => name === first && names[index + 1] === second));
320
- if (names.some((name) => CREDENTIAL_DIRS.has(name)) || hasPair(CREDENTIAL_DIR_PAIRS) || isCollectorDirectoryDenied(names.join("/"))) return "root_credentials";
320
+ if (names.some((name) => CREDENTIAL_DIRS.has(name)) || hasPair(CREDENTIAL_DIR_PAIRS) || isSyncDirectoryDenied(names.join("/"))) return "root_credentials";
321
321
  if (names.some((name) => APP_DATA_DIRS.has(name)) || hasPair(APP_DATA_DIR_PAIRS)) return "root_app_data";
322
322
 
323
323
  for (const home of homes) {
@@ -72,7 +72,7 @@ export async function readJsonFile(path: string): Promise<unknown> {
72
72
  /**
73
73
  * Runs a full garbage collection when the runtime offers one (Bun.gc); a
74
74
  * no-op elsewhere. Streaming an archive churns through hundreds of MiB of
75
- * short-lived buffers (zstd output, AES-GCM output) that the collector would
75
+ * short-lived buffers (zstd output, AES-GCM output) that the session uploader would
76
76
  * otherwise let pile up well past the stream's bounded working set. Callers
77
77
  * hint once per 64 MiB of data; a collection takes a few milliseconds at the
78
78
  * heap sizes involved.
@@ -14,7 +14,7 @@
14
14
  * ignored folder listed once as `dir/` so the scan prunes it without
15
15
  * walking it. Where git cannot answer (git missing, not a work tree, a
16
16
  * timeout or an output past the cap) the folder's `.gitignore` files are
17
- * applied level by level, with the same matcher the workspace collector's
17
+ * applied level by level, with the same matcher the workspace uploader's
18
18
  * fallback listing uses (`ignoredByRules`).
19
19
  *
20
20
  * This file is a CLI addition to the ported desktop modules; the desktop's
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { join } from "node:path";
24
24
 
25
- import { ignoredByRules, readGitignoreFile, scopedIgnoreRules } from "../workspace-collector.js";
25
+ import { ignoredByRules, readGitignoreFile, scopedIgnoreRules } from "../workspace-sync.js";
26
26
  import { runGit } from "./manifest.js";
27
27
 
28
28
  /** The ignored-paths listing is read up to this size; past it the .gitignore fallback applies. */
@@ -8,7 +8,7 @@
8
8
  * with the touched-files policy on, the same chain holding only the files
9
9
  * the agent touched there (touched.ts), its base at the first capture that
10
10
  * has one. Each archive is sealed to the omnirush.ai archive key, queued
11
- * durably under the collector state dir and uploaded to S3 through
11
+ * durably under the state dir and uploaded to S3 through
12
12
  * presigned multipart URLs. The embedded server drives it through
13
13
  * lifecycle.ts; see README.md.
14
14
  */
@@ -68,7 +68,7 @@ export { gitMarkerDetector, gitParentDetector, isArchivableProject, type Archiva
68
68
  export { isArchiveCredentialPath, type ArchiveTrigger, type FinalReason } from "./manifest.js";
69
69
  export type { ArchiveApiRequest } from "./upload.js";
70
70
 
71
- /** Subdirectory of the collector state dir that holds everything the archiver keeps. */
71
+ /** Subdirectory of the state dir that holds everything the archiver keeps. */
72
72
  export const ARCHIVE_STATE_DIRECTORY = "omnirush-archive";
73
73
  const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/;
74
74
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -88,16 +88,16 @@ const SIGN_OUT_ABORT_TIMEOUT_MS = 5_000;
88
88
  export const POLICY_TTL_MS = 5 * 60_000;
89
89
 
90
90
  export type SessionArchiverOptions = {
91
- /** As the collector: the archive routes are derived from it like the collect URL. */
91
+ /** As the session uploader: the archive routes are derived from it like the upload URL. */
92
92
  gatewayUrl?: string;
93
93
  accessToken?: string;
94
- /** The collector's hook: a fresh bearer after a 401, or null. */
94
+ /** The session uploader's hook: a fresh bearer after a 401, or null. */
95
95
  refreshAccessToken?: () => Promise<string | null>;
96
96
  /** External egress for S3 part PUTs (and API calls without `request`); externalFetch by default. */
97
97
  fetch?: ArchiveFetch;
98
98
  /** Authenticated API calls through the device-session owner (the gateway broker); replaces gatewayUrl + accessToken. */
99
99
  request?: ArchiveApiRequest;
100
- /** The collector state dir; the archiver keeps its files in `<stateDir>/omnirush-archive/`. */
100
+ /** The state dir; the archiver keeps its files in `<stateDir>/omnirush-archive/`. */
101
101
  stateDir: string;
102
102
  /** App state/temp/data dirs pruned when under a root (the state dir itself is always pruned). */
103
103
  excludedDirs?: string[];
@@ -358,7 +358,7 @@ export class SessionArchiver {
358
358
  return this.started;
359
359
  }
360
360
 
361
- /** The device bearer changed (collector token rotation). */
361
+ /** The device bearer changed (uploader token rotation). */
362
362
  setAccessToken(token: string | null): void {
363
363
  this.uploader.setAccessToken(token);
364
364
  }
@@ -491,7 +491,7 @@ export class SessionArchiver {
491
491
 
492
492
  /**
493
493
  * A path the session touched, workspace-relative (portable `/`), as the
494
- * collector reports it: a tool's path in the trace, or a change the
494
+ * session uploader reports it: a tool's path in the trace, or a change the
495
495
  * watcher saw. Kept (on disk, a moment later) for a touched-files session;
496
496
  * dropped for any other once the gate has run. Cheap: called for every
497
497
  * file event.
@@ -827,7 +827,7 @@ export class SessionArchiver {
827
827
  }
828
828
 
829
829
  /**
830
- * One capture under the session's lock. The collection hints around it keep
830
+ * One capture under the session's lock. The garbage collection hints around it keep
831
831
  * a capture's per-entry garbage from stacking on top of the previous one's.
832
832
  */
833
833
  private async captureArchive(state: SessionState, kind: ArchiveKind, turn: number, key: ArchiveKey, generation: number, options: CaptureOptions = {}): Promise<CaptureResult> {
@@ -272,7 +272,7 @@ export class ProjectArchiveLifecycle {
272
272
  }
273
273
 
274
274
  /**
275
- * The collector saw the session touch a path (workspace-relative): a
275
+ * The session uploader saw the session touch a path (workspace-relative): a
276
276
  * tool's path in the trace, or a change on disk. Kept for a touched-files
277
277
  * session (the archiver drops it for any other); nothing for a session
278
278
  * this app run has not started or knows is not archived.
@@ -12,7 +12,7 @@ import { lstat, open, readdir, readlink, realpath } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
14
14
 
15
- import { clampCollectorBytes, isCollectorPathDenied, stripRemoteUserinfo } from "../workspace-collector.js";
15
+ import { clampSyncBytes, isSyncPathDenied, stripRemoteUserinfo } from "../workspace-sync.js";
16
16
  import { hintGarbageCollection } from "./files.js";
17
17
 
18
18
  export const ARCHIVE_SCHEMA = "omnirush.archive.v1";
@@ -143,19 +143,19 @@ export function compareArchivePaths(left: string, right: string): number {
143
143
 
144
144
  /**
145
145
  * True when a file or symlink must be left out of the archive (section 5.3):
146
- * the collector's credential denylist, with git internals exempt and
146
+ * the session uploader's credential denylist, with git internals exempt and
147
147
  * `node_modules` not counted as a denial.
148
148
  */
149
149
  export function isArchiveCredentialPath(relPath: string): boolean {
150
150
  const parts = relPath.split("/");
151
151
  if (parts.some((part) => part.toLowerCase() === ".git")) return false;
152
152
  const rest = parts.filter((part) => part.toLowerCase() !== "node_modules");
153
- return rest.length > 0 && isCollectorPathDenied(rest.join("/"));
153
+ return rest.length > 0 && isSyncPathDenied(rest.join("/"));
154
154
  }
155
155
 
156
156
  /** workspace.label: the root's basename, at most 255 UTF-8 bytes. */
157
157
  export function archiveLabel(root: string): string {
158
- return clampCollectorBytes(basename(resolve(root)), MAX_LABEL_BYTES).text;
158
+ return clampSyncBytes(basename(resolve(root)), MAX_LABEL_BYTES).text;
159
159
  }
160
160
 
161
161
  // --- hash cache -------------------------------------------------------------------