omnirush 0.8.6 → 0.9.1

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 (54) hide show
  1. package/assets/CHANGELOG.md +120 -0
  2. package/assets/extensions/omnirush/agents-lib.ts +501 -63
  3. package/assets/extensions/omnirush/agents.ts +140 -115
  4. package/assets/extensions/omnirush/bgshell-lib.ts +432 -0
  5. package/assets/extensions/omnirush/bgshell.ts +392 -0
  6. package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
  7. package/assets/extensions/omnirush/collector.ts +275 -43
  8. package/assets/extensions/omnirush/commands.ts +2 -0
  9. package/assets/extensions/omnirush/deliveries.ts +145 -0
  10. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  11. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  12. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  13. package/assets/extensions/omnirush/guard.ts +340 -0
  14. package/assets/extensions/omnirush/index.ts +32 -6
  15. package/assets/extensions/omnirush/mcp.ts +103 -8
  16. package/assets/extensions/omnirush/memory-lib.ts +516 -0
  17. package/assets/extensions/omnirush/pi-engine.ts +115 -3
  18. package/assets/extensions/omnirush/plan-lib.ts +38 -9
  19. package/assets/extensions/omnirush/plan.ts +85 -30
  20. package/assets/extensions/omnirush/sota.ts +52 -4
  21. package/assets/extensions/omnirush/status-lib.ts +3 -0
  22. package/assets/extensions/omnirush/subagent-marker.ts +21 -0
  23. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  24. package/assets/extensions/omnirush/subagents.ts +305 -0
  25. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  26. package/assets/extensions/omnirush/swarm.ts +95 -0
  27. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  28. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  29. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  30. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  31. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  32. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  33. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  34. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  35. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  36. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  37. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  38. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  39. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  40. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  41. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  42. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  43. package/assets/extensions/omnirush/voice.ts +838 -0
  44. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  45. package/assets/extensions/omnirush/yolo.ts +85 -0
  46. package/package.json +7 -3
  47. package/scripts/brand-engine.js +526 -0
  48. package/scripts/build-all-packages.py +29 -1
  49. package/scripts/smoke-packages.py +33 -1
  50. package/src/bin.js +232 -33
  51. package/src/compat.js +272 -0
  52. package/src/lib.js +64 -0
  53. package/src/sessions.js +222 -0
  54. package/scripts/patch-pi-branding.js +0 -251
@@ -17,7 +17,11 @@
17
17
  //
18
18
  // user message -> user message: text parts, image file parts
19
19
  // (bytes not embedded, as the desktop's
20
- // engine.request records attachments)
20
+ // engine.request records attachments); one
21
+ // the user did not submit (a sub-agent's task,
22
+ // an extension's sendUserMessage: the caller's
23
+ // `injectedPrompts`) has synthetic text parts,
24
+ // so it never becomes a turn's prompt
21
25
  // assistant message -> assistant message: step-start, reasoning
22
26
  // (the model's thinking), text and tool parts
23
27
  // in the model's order, a patch part for the
@@ -33,6 +37,10 @@
33
37
  // a command the user ran)
34
38
  // custom / branch summary -> user message with synthetic text parts (what
35
39
  // the model was shown; never a turn opener)
40
+ // background command -> the delivery message as above, answered by an
41
+ // results (bgshell.ts) assistant message holding one bash tool part
42
+ // per finished command (its output and exit
43
+ // code; no model step, no tokens)
36
44
  // compaction -> user message with a compaction part and the
37
45
  // summary as an assistant `summary` message
38
46
  // system prompt -> not a message (the desktop records it in
@@ -49,6 +57,17 @@
49
57
  import { createHash } from "node:crypto";
50
58
  import { isAbsolute, resolve } from "node:path";
51
59
 
60
+ /** customType of bgshell.ts's finished-background-command message. */
61
+ export const BACKGROUND_BASH_TYPE = "omnirush-bash-result";
62
+ /**
63
+ * customType of the entry a sub-agent process writes at the start of its
64
+ * own session (data: `{parent_session_id}`): the session is a sub-agent's,
65
+ * and its first user message is the task the parent agent wrote.
66
+ */
67
+ export const SUBAGENT_ENTRY_TYPE = "omnirush-subagent";
68
+ /** A sub-agent's prompt: the task the parent agent wrote, after this prefix (agents-lib.ts buildChildArgs). */
69
+ export const SUBAGENT_TASK_PREFIX = "Task: ";
70
+
52
71
  export type EngineMessage = { info: Record<string, unknown>; parts: Array<Record<string, unknown>> };
53
72
 
54
73
  /** One pi session entry (loosely typed: the files are parsed without validation). */
@@ -80,6 +99,13 @@ export type EngineConvertOptions = {
80
99
  * `--model <id>:<effort>` flag of a resumed session.
81
100
  */
82
101
  promptSettings?: ReadonlyMap<string, { model?: { providerID: string; modelID: string } | null; variant?: string | null }>;
102
+ /**
103
+ * User entries (by id) whose text the user did not submit, with the
104
+ * metadata their synthetic text parts carry: the task a parent agent gave
105
+ * a sub-agent whose session is continued as a main session, a prompt an
106
+ * extension sent. The viewer never takes them for a prompt.
107
+ */
108
+ injectedPrompts?: ReadonlyMap<string, Record<string, unknown>>;
83
109
  };
84
110
 
85
111
  /** The shell-command marker the engine writes on a user message holding a command the user ran (public_trace.py `_USER_SHELL_TEXT`). */
@@ -295,6 +321,60 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
295
321
  return messageVariant ?? null;
296
322
  };
297
323
 
324
+ /**
325
+ * Finished background shell commands (bgshell.ts delivery): their output
326
+ * as bash tool parts, the way the engine records a command the user ran.
327
+ */
328
+ const backgroundBash = (parentId: string, entryId: string, details: unknown, at: number | null, index: number): void => {
329
+ const jobs = isRecord(details) && Array.isArray(details.jobs) ? details.jobs.filter(isRecord) : [];
330
+ if (jobs.length === 0) return;
331
+ const shellId = engineMessageId(entryId, "_bash");
332
+ const shellModel = model ?? nextAssistantModel.get(index) ?? null;
333
+ const time = at ?? 0;
334
+ const parts: Array<Record<string, unknown>> = jobs.map((job, jobIndex) => {
335
+ const start = typeof job.started_at === "number" ? job.started_at : time;
336
+ const end = typeof job.finished_at === "number" ? job.finished_at : time;
337
+ const failed = job.status === "failed";
338
+ return {
339
+ type: "tool",
340
+ callID: `bg_${typeof job.id === "string" ? job.id : jobIndex}_${entryId}`,
341
+ tool: "bash",
342
+ state: {
343
+ status: failed ? "error" : "completed",
344
+ input: { command: typeof job.command === "string" ? job.command : "", background_id: job.id ?? null },
345
+ ...(failed ? { error: typeof job.error === "string" ? job.error : "failed" } : {}),
346
+ output: typeof job.output === "string" ? job.output : "",
347
+ metadata: {
348
+ background: true,
349
+ ...(typeof job.exit_code === "number" ? { exit: job.exit_code } : {}),
350
+ ...(typeof job.status === "string" ? { status: job.status } : {}),
351
+ ...(job.interrupted === true ? { interrupted: true } : {}),
352
+ ...(job.truncated === true ? { truncated: true } : {}),
353
+ ...(typeof job.tool_call_id === "string" ? { startedBy: job.tool_call_id } : {}),
354
+ },
355
+ time: { start, end },
356
+ },
357
+ };
358
+ });
359
+ out.push({
360
+ info: {
361
+ id: shellId,
362
+ sessionID: sessionId,
363
+ role: "assistant",
364
+ parentID: parentId,
365
+ time: { created: time, completed: time },
366
+ ...(shellModel ? { modelID: shellModel.modelID, providerID: shellModel.providerID } : {}),
367
+ agent,
368
+ mode: agent,
369
+ path: { cwd, root: cwd },
370
+ cost: 0,
371
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
372
+ finish: "stop",
373
+ },
374
+ parts: parts.map((part, partIndex) => ({ id: partId(shellId, partIndex), sessionID: sessionId, messageID: shellId, ...part })),
375
+ });
376
+ };
377
+
298
378
  entries.forEach((entry, index) => {
299
379
  const entryId = typeof entry.id === "string" && entry.id ? entry.id : null;
300
380
  if (!entryId) return;
@@ -340,7 +420,9 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
340
420
  }
341
421
  if (entry.type === "custom_message") {
342
422
  const parts = contentParts(entry.content, { synthetic: true, metadata: { customType: entry.customType ?? null } });
343
- if (parts.length > 0) userMessage(engineMessageId(entryId), at, parts, index);
423
+ const id = engineMessageId(entryId);
424
+ if (parts.length > 0) userMessage(id, at, parts, index);
425
+ if (entry.customType === BACKGROUND_BASH_TYPE) backgroundBash(id, entryId, (entry as any).details, at, index);
344
426
  return;
345
427
  }
346
428
  if (entry.type !== "message" || !isRecord(entry.message)) return;
@@ -349,13 +431,15 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
349
431
  const created = epochMs(message.timestamp) ?? at;
350
432
  switch (message.role) {
351
433
  case "user": {
352
- const parts = contentParts(message.content);
434
+ const injected = options.injectedPrompts?.get(entryId);
435
+ const parts = contentParts(message.content, injected ? { synthetic: true, metadata: { ...injected } } : {});
353
436
  if (parts.length > 0) turnVariant = userMessage(id, created, parts, index);
354
437
  return;
355
438
  }
356
439
  case "custom": {
357
440
  const parts = contentParts(message.content, { synthetic: true, metadata: { customType: message.customType ?? null } });
358
441
  if (parts.length > 0) userMessage(id, created, parts, index);
442
+ if (message.customType === BACKGROUND_BASH_TYPE) backgroundBash(id, entryId, message.details, created, index);
359
443
  return;
360
444
  }
361
445
  case "branchSummary":
@@ -509,6 +593,34 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
509
593
  return out;
510
594
  }
511
595
 
596
+ /** The text of a pi user message (its text blocks). */
597
+ export function userText(message: Record<string, any> | undefined): string {
598
+ return message ? contentText(message.content) : "";
599
+ }
600
+
601
+ /**
602
+ * The first user message of a branch when it may be a sub-agent's task
603
+ * (the session is a sub-agent's, opened as a main session by `--continue`,
604
+ * `--resume` or `--session`): `marked` when the sub-agent's own marker
605
+ * entry comes before it (then `parentSessionId` is known), else when its
606
+ * text has the task prefix, for the caller to confirm (sessions of
607
+ * sub-agents that wrote no marker). Null for any other session.
608
+ */
609
+ export function subagentTask(entries: readonly PiEntry[]): { entryId: string; marked: boolean; parentSessionId: string | null } | null {
610
+ let marker: PiEntry | null = null;
611
+ for (const entry of entries) {
612
+ if (entry.type === "custom" && entry.customType === SUBAGENT_ENTRY_TYPE) marker ??= entry;
613
+ if (entry.type !== "message" || !isRecord(entry.message) || entry.message.role !== "user") continue;
614
+ if (typeof entry.id !== "string" || !entry.id) return null;
615
+ if (marker) {
616
+ const parent = isRecord(marker.data) && typeof marker.data.parent_session_id === "string" ? marker.data.parent_session_id : null;
617
+ return { entryId: entry.id, marked: true, parentSessionId: parent };
618
+ }
619
+ return userText(entry.message).startsWith(SUBAGENT_TASK_PREFIX) ? { entryId: entry.id, marked: false, parentSessionId: null } : null;
620
+ }
621
+ return null;
622
+ }
623
+
512
624
  /** The active branch of a pi session file's entries: from the last entry up its parent chain. */
513
625
  export function branchOfEntries(entries: readonly PiEntry[]): PiEntry[] {
514
626
  const withIds = entries.filter((entry) => entry.type !== "session" && typeof entry.id === "string");
@@ -16,6 +16,9 @@ export interface PlanFile {
16
16
  schema_version: 1;
17
17
  items: PlanItem[];
18
18
  updated_at: string;
19
+ /** The session that wrote it (a new session in the same folder does
20
+ * not pick up another session's list). Absent in older files. */
21
+ session_id?: string;
19
22
  }
20
23
 
21
24
  export const PLAN_MAX_ITEMS = 50;
@@ -66,19 +69,44 @@ function clip(text: string, max: number): string {
66
69
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
67
70
  }
68
71
 
72
+ /** Non-plan tool calls after which an untouched list reads as stale. */
73
+ export const PLAN_STALE_TOOL_CALLS = 25;
74
+
75
+ /**
76
+ * How current the list is, for the footer:
77
+ * - "active": updated during the running request,
78
+ * - "idle": no request running, or the list is left over from an
79
+ * earlier request (restored on resume / not touched since).
80
+ */
81
+ export type PlanFooterPhase = "active" | "idle";
82
+
83
+ export interface PlanFooterOptions {
84
+ phase?: PlanFooterPhase;
85
+ /** Non-plan tool calls since the last plan update. */
86
+ toolCallsSinceUpdate?: number;
87
+ }
88
+
69
89
  /**
70
- * One-line footer for ctx.ui.setStatus, e.g.
71
- * "plan 2/5 · ▸ wire tests"
72
- * or "plan 4/5 done". Null when there is nothing to show.
90
+ * One-line footer for ctx.ui.setStatus. It reads as a checklist (never
91
+ * as a mode — the footer's status line also carries "guarded"/"yolo"
92
+ * right before it), e.g.
93
+ * "tasks 2/5 · ▸ wire tests" (being worked on)
94
+ * "tasks 2/5 · not updated for 30 tool calls" (stale while working)
95
+ * "tasks 2/5 · idle" (between requests)
96
+ * Null when there is nothing to show — including a finished list.
73
97
  */
74
- export function renderPlanFooter(items: PlanItem[]): string | null {
98
+ export function renderPlanFooter(items: PlanItem[] | null, options: PlanFooterOptions = {}): string | null {
75
99
  if (!Array.isArray(items) || items.length === 0) return null;
76
100
  const { done, total, current } = planProgress(items);
101
+ if (done === total) return null;
102
+ const head = `tasks ${done}/${total}`;
103
+ if ((options.phase ?? "active") === "idle") return `${head} · idle`;
104
+ const since = options.toolCallsSinceUpdate ?? 0;
105
+ if (since >= PLAN_STALE_TOOL_CALLS) return `${head} · not updated for ${since} tool calls`;
77
106
  // The ▸ task is the in-progress one, falling back to the first
78
107
  // not-done item (what is next up).
79
108
  const pointed = current ?? items.find((item) => item.status !== "done")?.title ?? null;
80
- if (pointed) return `plan ${done}/${total} · ▸ ${clip(pointed, 60)}`;
81
- return `plan ${done}/${total} done`;
109
+ return pointed ? `${head} · ▸ ${clip(pointed, 60)}` : head;
82
110
  }
83
111
 
84
112
  /**
@@ -94,8 +122,8 @@ export function renderPlanItems(items: PlanItem[]): string {
94
122
  return `${lines.join("\n")}\n\n${done}/${total} done`;
95
123
  }
96
124
 
97
- export function buildPlanFile(items: PlanItem[], updatedAt: string): PlanFile {
98
- return { schema_version: 1, items, updated_at: updatedAt };
125
+ export function buildPlanFile(items: PlanItem[], updatedAt: string, sessionId?: string): PlanFile {
126
+ return { schema_version: 1, items, updated_at: updatedAt, ...(sessionId ? { session_id: sessionId } : {}) };
99
127
  }
100
128
 
101
129
  /** Parse a persisted plan.json, returning null on anything unusable. */
@@ -106,7 +134,8 @@ export function parsePlanFile(text: string): PlanFile | null {
106
134
  const items = normalizePlanItems(parsed.items);
107
135
  if (!items) return null;
108
136
  const updatedAt = typeof parsed.updated_at === "string" ? parsed.updated_at : "";
109
- return { schema_version: 1, items, updated_at: updatedAt };
137
+ const sessionId = typeof parsed.session_id === "string" && parsed.session_id ? parsed.session_id : undefined;
138
+ return { schema_version: 1, items, updated_at: updatedAt, ...(sessionId ? { session_id: sessionId } : {}) };
110
139
  } catch {
111
140
  return null;
112
141
  }
@@ -7,9 +7,16 @@
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
9
9
  // part of the session's engine messages),
10
- // - renders one line in the footer via ctx.ui.setStatus,
10
+ // - renders one line in the footer via ctx.ui.setStatus ("tasks 1/3 ·
11
+ // ▸ …": a checklist, never a mode), marked idle between requests and
12
+ // stale when the agent keeps working without updating it; a finished
13
+ // list clears,
11
14
  // - returns the full list as the tool result.
12
15
  //
16
+ // A sub-agent (OMNIRUSH_PARENT_SESSION set) runs in its parent's folder:
17
+ // it keeps its list in its own session only, so it never overwrites the
18
+ // parent's plan.json.
19
+ //
13
20
  // Full-replace semantics (like codex update_plan): every call sends
14
21
  // the WHOLE list with current statuses.
15
22
 
@@ -27,6 +34,7 @@ import {
27
34
  planProgress,
28
35
  renderPlanFooter,
29
36
  renderPlanItems,
37
+ type PlanFooterPhase,
30
38
  type PlanItem,
31
39
  } from "./plan-lib";
32
40
 
@@ -42,7 +50,7 @@ const PlanParams = Type.Object({
42
50
  Type.Object({
43
51
  title: Type.String({ description: "Short task title (imperative, e.g. 'add failing test first')" }),
44
52
  status: Type.Optional(StringEnum(["pending", "in-progress", "done"] as const, {
45
- description: "Task status; defaults to pending. Exactly one item should be in-progress while working.",
53
+ description: "Task status; defaults to pending. Exactly one item should be in-progress while working; mark it done as soon as it is finished.",
46
54
  })),
47
55
  }),
48
56
  { description: "The FULL ordered task list with current statuses (replaces the previous plan)" },
@@ -50,10 +58,21 @@ const PlanParams = Type.Object({
50
58
  });
51
59
 
52
60
  export default function (pi: ExtensionAPI) {
53
- /** Show the footer for a plan (or clear it when the plan is gone). */
54
- const showFooter = (items: PlanItem[] | null, ctx: any) => {
61
+ // Footer state: the latest list, whether it was updated during the
62
+ // running request ("active") or is left over ("idle"), and how many
63
+ // other tool calls ran since its last update.
64
+ let items: PlanItem[] | null = null;
65
+ let phase: PlanFooterPhase = "idle";
66
+ let toolCallsSinceUpdate = 0;
67
+ let lastLine: string | null | undefined;
68
+ let lastCtx: any = null;
69
+
70
+ const showFooter = (ctx: any = lastCtx) => {
71
+ if (ctx) lastCtx = ctx;
72
+ const line = renderPlanFooter(items, { phase, toolCallsSinceUpdate });
73
+ if (line === lastLine) return;
74
+ lastLine = line;
55
75
  try {
56
- const line = items ? renderPlanFooter(items) : null;
57
76
  ctx?.ui?.setStatus?.(PLAN_STATUS_KEY, line ?? undefined);
58
77
  } catch {
59
78
  /* non-TUI host */
@@ -62,55 +81,84 @@ export default function (pi: ExtensionAPI) {
62
81
 
63
82
  pi.registerTool({
64
83
  name: PLAN_TOOL_NAME,
65
- label: "Plan",
84
+ label: "Tasks",
66
85
  description: [
67
- "Maintain the ordered task list for the current work (todo list).",
68
- "Send the FULL list with current statuses every time; keep exactly one item in-progress while working and mark items done as you complete them.",
69
- "The list persists for the session and is visible to the user in the footer.",
86
+ "Maintain the ordered task list (todo list) for the current request.",
87
+ "Send the FULL list with current statuses every time.",
88
+ "Keep it current while you work: mark an item in-progress when you start it and done as soon as it is finished (not all at the end), and send the final list when the work is complete.",
89
+ "The user sees the list's progress in the footer.",
70
90
  ].join(" "),
71
91
  promptSnippet: "plan: maintain an ordered task list (todo) with statuses",
72
92
  promptGuidelines: [
73
- "Use plan for multi-step work (3+ steps or user-requested tracking): send the full task list before starting, mark items done as you finish them, and keep exactly one item in-progress.",
93
+ "Use plan for multi-step work (3+ steps or user-requested tracking): send the full task list before starting, then update it as you go — mark each item done when you finish it and the next one in-progress, keeping exactly one item in-progress.",
94
+ "Before you end your reply, send the final list (every finished item done); drop items you decided not to do.",
74
95
  "Do not use plan for single trivial actions.",
75
96
  ],
76
97
  parameters: PlanParams,
77
98
 
78
99
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
79
- const items = normalizePlanItems(params.items);
80
- if (!items) {
100
+ const next = normalizePlanItems(params.items);
101
+ if (!next) {
81
102
  throw new Error(
82
103
  "invalid plan items: provide a non-empty array of {title, status} objects (status pending | in-progress | done)",
83
104
  );
84
105
  }
85
106
  const updatedAt = new Date().toISOString();
86
107
  const cwd = ctx?.cwd ? resolve(ctx.cwd) : process.cwd();
87
- try {
88
- const planPath = join(cwd, AGENT_DIR, "plan.json");
89
- await mkdir(dirname(planPath), { recursive: true });
90
- await writeFile(planPath, JSON.stringify(buildPlanFile(items, updatedAt), null, 2) + "\n", "utf8");
91
- } catch {
92
- // Persistence is best effort: the session entry + footer still
93
- // carry the plan (and the tool result returns it).
108
+ if (!isSubagent()) {
109
+ try {
110
+ const planPath = join(cwd, AGENT_DIR, "plan.json");
111
+ const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "") || undefined;
112
+ await mkdir(dirname(planPath), { recursive: true });
113
+ await writeFile(planPath, JSON.stringify(buildPlanFile(next, updatedAt, sessionId), null, 2) + "\n", "utf8");
114
+ } catch {
115
+ // Persistence is best effort: the session entry + footer still
116
+ // carry the plan (and the tool result returns it).
117
+ }
94
118
  }
95
119
  try {
96
- pi.appendEntry(PLAN_ENTRY_TYPE, { items, updated_at: updatedAt });
120
+ pi.appendEntry(PLAN_ENTRY_TYPE, { items: next, updated_at: updatedAt });
97
121
  } catch {
98
122
  /* sessions may be unavailable in some hosts */
99
123
  }
100
- showFooter(items, ctx);
101
- const { done, total } = planProgress(items);
124
+ items = next;
125
+ phase = "active";
126
+ toolCallsSinceUpdate = 0;
127
+ showFooter(ctx);
128
+ const { done, total } = planProgress(next);
129
+ const reminder = done < total
130
+ ? "\nKeep this list current: mark each item done as soon as it is finished and the next one in-progress."
131
+ : "";
102
132
  return {
103
- content: [{ type: "text", text: renderPlanItems(items) }],
104
- details: { done, total, items },
133
+ content: [{ type: "text", text: renderPlanItems(next) + reminder }],
134
+ details: { done, total, items: next },
105
135
  };
106
136
  },
107
137
  });
108
138
 
109
- // Restore the footer after reload/resume: the session's latest plan
110
- // entry wins, falling back to the persisted plan.json.
139
+ // Other tool calls age the list: after enough of them without an
140
+ // update the footer stops pointing at a task as if it were current.
141
+ pi.on("tool_execution_end", (event: any, ctx: any) => {
142
+ if (!items || event?.toolName === PLAN_TOOL_NAME) return;
143
+ toolCallsSinceUpdate += 1;
144
+ showFooter(ctx);
145
+ });
146
+
147
+ // The request is over: an unfinished list is shown as idle (a finished
148
+ // one is already cleared) until the model updates it again.
149
+ pi.on("agent_end", (_event: any, ctx: any) => {
150
+ phase = "idle";
151
+ showFooter(ctx);
152
+ });
153
+
154
+ // Restore after reload/resume from THIS session's latest plan entry
155
+ // (a new session starts with no list, even when the folder still holds
156
+ // another session's plan.json). Restored lists are idle.
111
157
  pi.on("session_start", async (_event: any, ctx: any) => {
158
+ items = null;
159
+ phase = "idle";
160
+ toolCallsSinceUpdate = 0;
112
161
  try {
113
- let items: PlanItem[] | null = null;
114
162
  const entries = ctx?.sessionManager?.getEntries?.() ?? [];
115
163
  for (let index = entries.length - 1; index >= 0; index -= 1) {
116
164
  const entry = entries[index];
@@ -119,17 +167,24 @@ export default function (pi: ExtensionAPI) {
119
167
  if (items) break;
120
168
  }
121
169
  }
122
- if (!items && ctx?.cwd) {
170
+ const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
171
+ if (!items && sessionId && ctx?.cwd) {
123
172
  try {
124
173
  const text = await readFile(join(resolve(ctx.cwd), AGENT_DIR, "plan.json"), "utf8");
125
- items = parsePlanFile(text)?.items ?? null;
174
+ const file = parsePlanFile(text);
175
+ if (file?.session_id === sessionId) items = file.items;
126
176
  } catch {
127
177
  /* no persisted plan */
128
178
  }
129
179
  }
130
- showFooter(items, ctx);
131
180
  } catch {
132
181
  /* footer is cosmetic */
133
182
  }
183
+ lastLine = undefined;
184
+ showFooter(ctx);
134
185
  });
135
186
  }
187
+
188
+ function isSubagent(): boolean {
189
+ return Boolean((process.env.OMNIRUSH_PARENT_SESSION || "").trim());
190
+ }
@@ -41,6 +41,14 @@ import {
41
41
  streamIdleMs,
42
42
  withStallWatchdog,
43
43
  } from "./sota-lib";
44
+ import { omniDir } from "./auth";
45
+ import {
46
+ ENV_FALLBACK_EFFORT,
47
+ ENV_FALLBACK_MODEL,
48
+ FALLBACK_MARKER,
49
+ subagentFallbackFetch,
50
+ type FallbackRoute,
51
+ } from "./subagents-lib";
44
52
  import {
45
53
  formatUnavailableSummary,
46
54
  isRetryableStatus,
@@ -222,7 +230,7 @@ async function readErrorBody(response: any): Promise<string> {
222
230
  */
223
231
  function tapFetch(
224
232
  baseFetch: any,
225
- expectedModelId: string,
233
+ expectedModel: string | (() => string),
226
234
  onViolation: (expected: string, served: string) => void,
227
235
  idleMs: number,
228
236
  ): any {
@@ -356,6 +364,8 @@ function tapFetch(
356
364
  const onData = (data: string) => {
357
365
  if (reported) return;
358
366
  const served = servedModelFromSseData(data);
367
+ // A sub-agent moved to the main model expects that one.
368
+ const expectedModelId = typeof expectedModel === "function" ? expectedModel() : expectedModel;
359
369
  if (served === undefined || !isSotaViolation(expectedModelId, served)) return;
360
370
  reported = true;
361
371
  onViolation(expectedModelId, served);
@@ -390,22 +400,60 @@ export default function (pi: any) {
390
400
  const responses = openAIResponsesApi();
391
401
  const strictSota = (): boolean => pi.getFlag("strict-sota") === true;
392
402
 
403
+ // A sub-agent on a picked model: the main model it falls back to when the
404
+ // gateway refuses the picked one (set by the parent's spawn_agents).
405
+ const fallbackModel = (process.env[ENV_FALLBACK_MODEL] || "").trim();
406
+ const fallbackEffort = (process.env[ENV_FALLBACK_EFFORT] || "").trim() || null;
407
+
393
408
  const streamWith =
394
409
  (simple: boolean) =>
395
410
  (model: any, context: any, options: any) => {
396
- const baseFetch = options?.fetch ?? globalThis.fetch;
411
+ let baseFetch = options?.fetch ?? globalThis.fetch;
412
+ const route: FallbackRoute = { expected: model.id, movedTo: null };
413
+ if (fallbackModel && fallbackModel !== model.id) {
414
+ baseFetch = subagentFallbackFetch(baseFetch, {
415
+ model: fallbackModel,
416
+ effort: fallbackEffort,
417
+ dir: omniDir(),
418
+ route,
419
+ report: (event) => {
420
+ // The parent's spawn_agents reads this line from the child's stderr.
421
+ try {
422
+ process.stderr.write(`\n${FALLBACK_MARKER}${JSON.stringify(event)}\n`);
423
+ } catch {
424
+ /* stderr may be closed */
425
+ }
426
+ },
427
+ });
428
+ }
397
429
  const tapped = {
398
430
  ...(options ?? {}),
399
431
  fetch: tapFetch(
400
432
  baseFetch,
401
- model.id,
433
+ () => route.expected,
402
434
  (expected, served) => reportViolation(strictSota(), expected, served),
403
435
  streamIdleMs(process.env.OMNIRUSH_STREAM_IDLE_MS, options?.timeoutMs),
404
436
  ),
405
437
  };
406
- return simple
438
+ const stream = simple
407
439
  ? responses.streamSimple(model, context, tapped)
408
440
  : responses.stream(model, context, tapped);
441
+ if (fallbackModel && stream && typeof stream.push === "function") {
442
+ // The message records the model (and effort) that really answered.
443
+ const push = stream.push.bind(stream);
444
+ stream.push = (event: any) => {
445
+ if (route.movedTo) {
446
+ for (const message of [event?.partial, event?.message, event?.error]) {
447
+ if (message && typeof message === "object" && message.role === "assistant") {
448
+ message.model = route.movedTo.model;
449
+ if (route.movedTo.effort) message.providerThinkingLevel = route.movedTo.effort;
450
+ }
451
+ }
452
+ }
453
+ return push(event);
454
+ };
455
+ }
456
+ return stream;
409
457
  };
410
458
 
411
459
  pi.registerProvider({
@@ -52,6 +52,8 @@ export interface StatusInput {
52
52
  origin: string;
53
53
  /** omnirush CLI version ("unknown" when not provided by the launcher). */
54
54
  version: string;
55
+ /** The approval mode line (yolo-lib formatModeStatus), when known. */
56
+ yolo?: string | null;
55
57
  }
56
58
 
57
59
  /**
@@ -79,6 +81,7 @@ export function formatStatusLines(input: StatusInput): string[] {
79
81
  }
80
82
 
81
83
  lines.push(formatCollectorLine(input.collector));
84
+ if (input.yolo) lines.push(input.yolo);
82
85
  lines.push(`manager: ${input.origin}`);
83
86
  lines.push(`version: ${input.version}`);
84
87
  return lines;
@@ -0,0 +1,21 @@
1
+ // A sub-agent process (spawn_agents sets OMNIRUSH_PARENT_SESSION) marks its
2
+ // own session with an `omnirush-subagent` entry naming its parent, before
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).
7
+
8
+ import { SUBAGENT_ENTRY_TYPE } from "./pi-engine";
9
+
10
+ export default function markSubagentSession(pi: any, parentSession: string = (process.env.OMNIRUSH_PARENT_SESSION || "").trim()): void {
11
+ if (!parentSession) return;
12
+ pi.on("session_start", (_event: any, ctx: any) => {
13
+ try {
14
+ const entries = ctx?.sessionManager?.getEntries?.() ?? [];
15
+ if (entries.some((entry: any) => entry?.type === "custom" && entry.customType === SUBAGENT_ENTRY_TYPE)) return;
16
+ pi.appendEntry(SUBAGENT_ENTRY_TYPE, { parent_session_id: parentSession });
17
+ } catch {
18
+ /* never in the child's way */
19
+ }
20
+ });
21
+ }