omnirush 0.8.6 → 0.9.0

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 (47) hide show
  1. package/assets/CHANGELOG.md +85 -0
  2. package/assets/extensions/omnirush/agents-lib.ts +134 -13
  3. package/assets/extensions/omnirush/agents.ts +51 -107
  4. package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
  5. package/assets/extensions/omnirush/bgshell.ts +392 -0
  6. package/assets/extensions/omnirush/collector.ts +72 -11
  7. package/assets/extensions/omnirush/commands.ts +2 -0
  8. package/assets/extensions/omnirush/deliveries.ts +145 -0
  9. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  10. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  11. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  12. package/assets/extensions/omnirush/guard.ts +340 -0
  13. package/assets/extensions/omnirush/index.ts +12 -0
  14. package/assets/extensions/omnirush/pi-engine.ts +65 -1
  15. package/assets/extensions/omnirush/sota.ts +52 -4
  16. package/assets/extensions/omnirush/status-lib.ts +3 -0
  17. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  18. package/assets/extensions/omnirush/subagents.ts +305 -0
  19. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  20. package/assets/extensions/omnirush/swarm.ts +95 -0
  21. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  22. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  23. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  24. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  25. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  26. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  27. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  28. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  29. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  30. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  31. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  32. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  33. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  34. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  35. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  36. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  37. package/assets/extensions/omnirush/voice.ts +838 -0
  38. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  39. package/assets/extensions/omnirush/yolo.ts +85 -0
  40. package/package.json +7 -3
  41. package/scripts/brand-engine.js +526 -0
  42. package/scripts/build-all-packages.py +29 -1
  43. package/scripts/smoke-packages.py +32 -1
  44. package/src/bin.js +205 -33
  45. package/src/compat.js +272 -0
  46. package/src/lib.js +64 -0
  47. package/scripts/patch-pi-branding.js +0 -251
@@ -0,0 +1,85 @@
1
+ # Omnirush CLI release notes
2
+
3
+ Shown by `/changelog` inside the agent and after an update. Newest first;
4
+ keep entries free of links (the agent core rewrites relative links).
5
+
6
+ ## [0.9.0]
7
+
8
+ ### Added
9
+ - Voice mode: `/voice` turns on dictation. Hold Space to talk and your words
10
+ are typed into the prompt at the cursor as you speak, ready to edit before
11
+ you send. Esc cancels and puts your draft back. `/voice tap` toggles with a
12
+ tap instead, `/voice key <key>` picks another key, `/voice auto-send on`
13
+ sends dictations of three words or more when you let go, and `/voice off`
14
+ turns it off. Audio is never saved to disk and never becomes part of your
15
+ session; only the text you send does.
16
+ - Long shell commands no longer get killed. A command still running after
17
+ five minutes (or the time the agent asked for) keeps going in the
18
+ background and its output arrives when it finishes; servers, watchers and
19
+ long builds can start in the background right away. `/jobs` lists them and
20
+ `/jobs kill <id|all>` stops them. `OMNIRUSH_BASH_FOREGROUND_SECONDS`
21
+ changes the five minutes.
22
+ - `/subagents` picks the model and effort sub-agents run on, as in the
23
+ desktop app: `/subagents model <id|same>`, `/subagents effort <level|same>`
24
+ and `/subagents reset`. The choice is remembered for the next runs;
25
+ `--subagent-model` / `--subagent-effort` (or `OMNIRUSH_SUBAGENT_MODEL` /
26
+ `OMNIRUSH_SUBAGENT_EFFORT`) set it for one run. If your account cannot use
27
+ the picked model, sub-agents run on your main model and say so. A task's
28
+ own `model:effort` still wins over the pick.
29
+ - Swarms: when you ask for a big job split across three or more sub-agents
30
+ (or ask for a swarm), omnirush coordinates them on a shared board in
31
+ `.omnirush/swarm.md` and archives it to `.omnirush/swarms/` when the swarm
32
+ ends. Smaller requests never create a board, and the board is kept out of
33
+ git.
34
+ - Guarded mode, on by default, as in the desktop app: before the agent
35
+ commits, pushes, rewrites git history, runs a destructive command
36
+ (`rm -rf`, `git reset --hard`, `git push --force` ...), uses `sudo`, reads
37
+ a `.env` file or touches files outside the project, omnirush shows you the
38
+ command and asks: allow once, allow for this session, or deny. Sub-agents
39
+ ask in the same place. Runs with `-p` cannot ask, so those calls are
40
+ blocked there with a note to rerun with `--yolo`.
41
+ - Yolo mode: `omnirush --yolo`, `/yolo on` or `/mode yolo` (remembered), or
42
+ `OMNIRUSH_YOLO=1`. Nothing asks, every command is allowed and the project
43
+ folder is trusted. The footer, `/status` and `/mode` show which mode you
44
+ are in.
45
+
46
+ ### Changed
47
+ - Omnirush everywhere: every screen, help page, message, file name and
48
+ update note now says omnirush.
49
+ - Your agent data (sessions, settings, models.json, provider logins,
50
+ installed packages) now lives in `~/.omnirush/agent`. It is copied over
51
+ automatically the first time 0.9.0 starts; the previous folder is left
52
+ untouched, and `--continue` and `/resume` find your earlier sessions.
53
+ - Project settings live in `<project>/.omnirush/`. A project that still has
54
+ its settings in the folder older versions used keeps working unchanged.
55
+ - Environment variables: every agent setting can be set as `OMNIRUSH_<NAME>`
56
+ (for example `OMNIRUSH_CACHE_RETENTION`); the older names still work.
57
+ `OMNIRUSH_AGENT_DIR` moves the agent data folder.
58
+
59
+ ### Removed
60
+ - `/bug` and `/share`: omnirush no longer sends reports, sessions, install
61
+ reports, analytics or version checks to the agent core's upstream service.
62
+
63
+ ## [0.8.6]
64
+
65
+ ### Added
66
+ - Sub-agents run on your session's current model and effort, and a task can
67
+ name its own as `model:effort` (for example `gpt-6-sol:high`).
68
+ - A footer line shows your sub-agents while they run: how many are running,
69
+ queued and finished.
70
+
71
+ ### Fixed
72
+ - No more false "this device's session is no longer valid" after another
73
+ omnirush process refreshed the sign-in.
74
+
75
+ ## [0.8.1]
76
+
77
+ ### Added
78
+ - GPT 6 Sol, and the model list now comes from the gateway's catalog, as in
79
+ the desktop app.
80
+
81
+ ## [0.8.0]
82
+
83
+ ### Added
84
+ - Session capture in the desktop app's format, with sub-agents and project
85
+ archives.
@@ -28,7 +28,16 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
28
28
  import { tmpdir } from "node:os";
29
29
  import path from "node:path";
30
30
 
31
+ import {
32
+ ENV_FALLBACK_EFFORT,
33
+ ENV_FALLBACK_MODEL,
34
+ FALLBACK_MARKER,
35
+ fallbackNote,
36
+ piThinking,
37
+ type SubagentFallback,
38
+ } from "./subagents-lib";
31
39
  import { childAuthEnv } from "./auth";
40
+ import { yoloActive } from "./yolo-lib";
32
41
 
33
42
  /** childAuthEnv, never throwing (a spawn must not fail on the auth file). */
34
43
  function childAuthEnvSafe(): Record<string, string> {
@@ -164,8 +173,15 @@ export function buildChildArgs(
164
173
  promptFilePath: string | null,
165
174
  model?: string,
166
175
  sessionId?: string,
176
+ effort?: string | null,
177
+ approve = false,
167
178
  ): string[] {
168
179
  const args: string[] = ["--mode", "json", "-p"];
180
+ if (approve) {
181
+ // Yolo mode: a headless child has no trust prompt, so without this it
182
+ // would ignore the project's settings the parent runs with.
183
+ args.push("--approve");
184
+ }
169
185
  if (sessionId) {
170
186
  // The child's session id, picked by the parent so its session file can
171
187
  // be found and captured as this session's sub-agent.
@@ -177,6 +193,11 @@ export function buildChildArgs(
177
193
  if (model && model.trim()) {
178
194
  args.push("--provider", "omnirush", "--model", model.trim());
179
195
  }
196
+ if (effort && effort.trim()) {
197
+ // The effort the sub-agent runs on (the picked one, or the main agent's
198
+ // mapped to the levels this model offers).
199
+ args.push("--thinking", piThinking(effort.trim()));
200
+ }
180
201
  args.push(`Task: ${task}`);
181
202
  return args;
182
203
  }
@@ -207,6 +228,25 @@ export interface ChildTask {
207
228
  * (e.g. "muse-spark-1.3" for cheap swarm workers under an astra
208
229
  * parent). Undefined = inherit the parent's model. */
209
230
  model?: string;
231
+ /** The effort the child runs on (gateway spelling); undefined = pi's default. */
232
+ effort?: string;
233
+ /** The picked sub-agent model could not be used: `model` is the main one. */
234
+ fallback?: SubagentFallback;
235
+ /** The main model the child's gateway guard moves to when the gateway refuses `model`. */
236
+ gatewayFallback?: { model: string; effort: string | null };
237
+ /** Extra environment for the child (the sub-agent setting and main model, for nested layers). */
238
+ env?: Record<string, string>;
239
+ }
240
+
241
+ /** A sub-agent that ran on the main model instead of the picked one. */
242
+ export interface ChildModelFallback {
243
+ requested: string;
244
+ used: string;
245
+ effort?: string | null;
246
+ reason: string;
247
+ /** "selection": picked before it started; "gateway": the gateway refused the picked model mid-run. */
248
+ kind: "selection" | "gateway";
249
+ note: string;
210
250
  }
211
251
 
212
252
  export type ChildStatus = "completed" | "failed" | "timeout" | "stalled" | "cancelled" | "interrupted";
@@ -218,6 +258,10 @@ export interface ChildResult {
218
258
  session_id?: string;
219
259
  /** The gateway model the child ran on (cross-model children). */
220
260
  model?: string;
261
+ /** The effort it ran on. */
262
+ effort?: string;
263
+ /** It ran on the main model instead of the picked one (why, and since when). */
264
+ model_fallback?: ChildModelFallback;
221
265
  status: ChildStatus;
222
266
  /** Process exit code (null when killed by a signal or still unknown). */
223
267
  exitCode: number | null;
@@ -237,6 +281,33 @@ export interface ChildResultInput {
237
281
  turns: number;
238
282
  status?: ChildStatus;
239
283
  error?: string;
284
+ /** The gateway moved the child to the main model mid-run. */
285
+ gatewayFallback?: { requested: string; used: string; effort?: string | null; reason: string };
286
+ }
287
+
288
+ /** The selection or gateway fallback of a child, for its result. */
289
+ export function childModelFallback(task: ChildTask, gateway?: ChildResultInput["gatewayFallback"]): ChildModelFallback | undefined {
290
+ if (task.fallback) {
291
+ return {
292
+ requested: task.fallback.requested,
293
+ used: task.fallback.used,
294
+ effort: task.effort ?? null,
295
+ reason: String(task.fallback.reason),
296
+ kind: "selection",
297
+ note: fallbackNote(task.fallback),
298
+ };
299
+ }
300
+ if (gateway) {
301
+ return {
302
+ requested: gateway.requested,
303
+ used: gateway.used,
304
+ effort: gateway.effort ?? null,
305
+ reason: gateway.reason,
306
+ kind: "gateway",
307
+ note: fallbackNote({ requested: gateway.requested, used: gateway.used, reason: gateway.reason }),
308
+ };
309
+ }
310
+ return undefined;
240
311
  }
241
312
 
242
313
  /** Final structured result for one child. */
@@ -253,11 +324,14 @@ export function buildChildResult(
253
324
  capped = output.slice(0, CHILD_OUTPUT_CAP_BYTES);
254
325
  while (Buffer.byteLength(capped, "utf8") > CHILD_OUTPUT_CAP_BYTES) capped = capped.slice(0, -1);
255
326
  }
327
+ const modelFallback = childModelFallback(task, input.gatewayFallback);
256
328
  return {
257
329
  role: task.role,
258
330
  task: task.task,
259
331
  ...(sessionId ? { session_id: sessionId } : {}),
260
332
  ...(task.model ? { model: task.model } : {}),
333
+ ...(task.effort ? { effort: task.effort } : {}),
334
+ ...(modelFallback ? { model_fallback: modelFallback } : {}),
261
335
  status: input.status ?? (input.exitCode === 0 ? "completed" : "failed"),
262
336
  exitCode: input.exitCode,
263
337
  output: capped,
@@ -392,13 +466,15 @@ export async function runChildAgent(
392
466
  const sessionId = options.sessionId ?? randomUUID();
393
467
 
394
468
  return withRolePromptFile(task.role, async (promptFile) => {
395
- const args = buildChildArgs(task.task, promptFile, task.model, sessionId);
469
+ const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive());
396
470
  const invocation = childInvocation(args);
397
471
 
398
472
  return await new Promise<ChildResult>((resolvePromise) => {
399
473
  let stdout = "";
400
474
  let stderr = "";
401
475
  let pendingLine = "";
476
+ let pendingErrLine = "";
477
+ let gatewayFallback: ChildResultInput["gatewayFallback"];
402
478
  let settled = false;
403
479
  let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
404
480
  let turns = 0;
@@ -411,14 +487,10 @@ export async function runChildAgent(
411
487
  cwd: options.cwd,
412
488
  shell: false,
413
489
  stdio: ["ignore", "pipe", "pipe"],
414
- env: {
415
- ...process.env,
416
- // Shared credentials by location (OMNIRUSH_DIR), never a token
417
- // frozen at the parent's launch: the child re-reads auth.json
418
- // for every request and takes part in the refresh lock.
419
- ...childAuthEnvSafe(),
420
- OMNIRUSH_PARENT_SESSION: options.parentSessionId,
421
- },
490
+ // Shared credentials by location (OMNIRUSH_DIR), never a token
491
+ // frozen at the parent's launch: the child re-reads auth.json for
492
+ // every request and takes part in the refresh lock.
493
+ env: childEnvironment(task, options.parentSessionId, { ...process.env, ...childAuthEnvSafe() }),
422
494
  });
423
495
 
424
496
  const finish = (input: ChildResultInput) => {
@@ -429,7 +501,7 @@ export async function runChildAgent(
429
501
  if (options.signal && abortHandler) {
430
502
  options.signal.removeEventListener("abort", abortHandler);
431
503
  }
432
- resolvePromise(buildChildResult(task, input, now() - startedAt, sessionId));
504
+ resolvePromise(buildChildResult(task, { ...input, ...(gatewayFallback ? { gatewayFallback } : {}) }, now() - startedAt, sessionId));
433
505
  };
434
506
 
435
507
  const killTree = () => {
@@ -506,7 +578,12 @@ export async function runChildAgent(
506
578
  options.onChildStdout?.(task.role, text);
507
579
  });
508
580
  child.stderr?.on("data", (chunk: Buffer | string) => {
509
- stderr += String(chunk);
581
+ const text = String(chunk);
582
+ // The child's gateway guard reports a move to the main model here.
583
+ const lines = (pendingErrLine + text).split("\n");
584
+ pendingErrLine = lines.pop() ?? "";
585
+ for (const line of lines) gatewayFallback = parseFallbackMarker(line) ?? gatewayFallback;
586
+ stderr += text;
510
587
  if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
511
588
  alive();
512
589
  });
@@ -551,6 +628,44 @@ function formatMinutes(ms: number): string {
551
628
  return `${Math.max(1, Math.round(ms / 1000))} s`;
552
629
  }
553
630
 
631
+ /**
632
+ * A child's environment: the parent's, the parent session id (the child
633
+ * uploads nothing itself), the sub-agent setting and main model handed down
634
+ * to nested layers, and the main model its gateway guard falls back to (only
635
+ * for this child: a nested one gets its own or none).
636
+ */
637
+ export function childEnvironment(task: ChildTask, parentSessionId: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
638
+ const env: NodeJS.ProcessEnv = { ...base, ...(task.env ?? {}), OMNIRUSH_PARENT_SESSION: parentSessionId };
639
+ // Guarded mode: the name the parent's approval prompt shows for this child.
640
+ const oneLine = task.task.replace(/\s+/g, " ").trim();
641
+ env.OMNIRUSH_SUBAGENT_LABEL = `${ROLE_PRESETS[task.role]?.label ?? task.role}: ${oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine}`;
642
+ delete env[ENV_FALLBACK_MODEL];
643
+ delete env[ENV_FALLBACK_EFFORT];
644
+ if (task.gatewayFallback?.model) {
645
+ env[ENV_FALLBACK_MODEL] = task.gatewayFallback.model;
646
+ if (task.gatewayFallback.effort) env[ENV_FALLBACK_EFFORT] = task.gatewayFallback.effort;
647
+ }
648
+ return env;
649
+ }
650
+
651
+ /** A child's stderr line reporting its gateway fallback (see sota.ts), or null. */
652
+ export function parseFallbackMarker(line: string): ChildResultInput["gatewayFallback"] | null {
653
+ const at = line.indexOf(FALLBACK_MARKER);
654
+ if (at < 0) return null;
655
+ try {
656
+ const parsed = JSON.parse(line.slice(at + FALLBACK_MARKER.length));
657
+ if (typeof parsed?.requested !== "string" || typeof parsed?.used !== "string") return null;
658
+ return {
659
+ requested: parsed.requested,
660
+ used: parsed.used,
661
+ effort: typeof parsed.effort === "string" ? parsed.effort : null,
662
+ reason: typeof parsed.reason === "string" ? parsed.reason : "refused",
663
+ };
664
+ } catch {
665
+ return null;
666
+ }
667
+ }
668
+
554
669
  /**
555
670
  * Run tasks with a concurrency cap, preserving input order in the
556
671
  * results (ports the pi subagent example's mapWithConcurrencyLimit).
@@ -580,8 +695,12 @@ export function renderChildResults(results: ChildResult[], ids?: readonly string
580
695
  const succeeded = results.filter((result) => result.status === "completed").length;
581
696
  const sections = results.map((result, index) => {
582
697
  const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
583
- const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${result.model ? ` [${result.model}]` : ""} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
698
+ const ranOn = result.model_fallback?.kind === "gateway" ? result.model_fallback.used : result.model;
699
+ const effort = result.model_fallback?.kind === "gateway" ? result.model_fallback.effort ?? undefined : result.effort;
700
+ const label = ranOn ? ` [${ranOn}${effort ? ` · ${effort}` : ""}]` : "";
701
+ const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${label} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
584
702
  const meta: string[] = [`task: ${result.task}`];
703
+ if (result.model_fallback) meta.push(`note: ${result.model_fallback.note}`);
585
704
  if (result.error) meta.push(`error: ${result.error}`);
586
705
  return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
587
706
  });
@@ -604,6 +723,7 @@ export interface AgentRecord {
604
723
  role: AgentRole;
605
724
  task: string;
606
725
  model?: string;
726
+ effort?: string;
607
727
  /** Dispatched with wait:false: its result comes back as a message. */
608
728
  background: boolean;
609
729
  /** Null means the batch was intentionally uncapped; otherwise the batch limit. */
@@ -699,6 +819,7 @@ export class AgentManager {
699
819
  role: task.role,
700
820
  task: task.task,
701
821
  ...(task.model ? { model: task.model } : {}),
822
+ ...(task.effort ? { effort: task.effort } : {}),
702
823
  background: options.background,
703
824
  parallelLimit: options.concurrency === undefined ? null : Math.max(1, Math.floor(options.concurrency)),
704
825
  notify: options.notify ?? "batch",
@@ -944,7 +1065,7 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
944
1065
  const since = record.startedAt ?? record.queuedAt;
945
1066
  const elapsed = minutesOf((record.finishedAt ?? now) - since);
946
1067
  const bits = [`${record.id}`, `[${record.status}]`, record.role];
947
- if (record.model) bits.push(`[${record.model}]`);
1068
+ if (record.model) bits.push(`[${record.model}${record.effort ? ` · ${record.effort}` : ""}]`);
948
1069
  bits.push(record.background ? "background" : "blocking");
949
1070
  const detail: string[] = [`${elapsed} min`];
950
1071
  if (!record.result && record.startedAt) {
@@ -36,6 +36,8 @@ import { StringEnum } from "@earendil-works/pi-ai";
36
36
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
37
37
  import { Type } from "typebox";
38
38
 
39
+ import { deliveryHub } from "./deliveries";
40
+ import { subagentTasks } from "./subagents";
39
41
  import {
40
42
  AGENT_ROLES,
41
43
  AGENTS_HOOK,
@@ -48,11 +50,12 @@ import {
48
50
  renderAgentStatus,
49
51
  renderChildResults,
50
52
  renderDelivery,
51
- modelSelectionFromContext,
52
53
  runChildAgent,
54
+ childModelFallback,
53
55
  type ChildResult,
54
56
  type ChildTask,
55
57
  } from "./agents-lib";
58
+ import { SWARM_GUIDELINE } from "./swarm-lib";
56
59
 
57
60
  /** A timeout_minutes below this is taken for a placeholder, not a limit. */
58
61
  const MIN_TIMEOUT_MINUTES = 1;
@@ -68,7 +71,7 @@ const SpawnAgentsParams = Type.Object({
68
71
  }),
69
72
  task: Type.String({ description: "Self-contained task description for the child agent (it sees ONLY this, not your conversation)" }),
70
73
  model: Type.Optional(Type.String({
71
- description: 'Optional model[:effort] for THIS task (e.g. "muse-spark-1.3", "gpt-6-sol:high"). Omit (or leave empty) to inherit the parent session\'s current model and effort',
74
+ 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',
72
75
  })),
73
76
  }),
74
77
  { description: "Tasks to delegate; they all run in parallel", minItems: 1 },
@@ -108,19 +111,14 @@ function renderSome(records: AgentRecord[], unknown: string[] = []): string {
108
111
 
109
112
  export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {}) {
110
113
  const watchdogMinutes = Math.round(childInactivityMs() / 60_000);
111
- /** The session this runtime is on (deliveries go to it). */
112
- let currentSession = "";
113
- let cwd = process.cwd();
114
- let inheritedModel = "";
114
+ const hub = deliveryHub(pi);
115
+ /** The last context seen (the TUI status bar for running agents). */
115
116
  let lastContext: any = null;
116
117
  let statusTimer: ReturnType<typeof setTimeout> | null = null;
117
- /** An agent run is under way (one-shot runs deliver through the settle loop otherwise). */
118
- let busy = false;
119
- let oneShot = false;
120
118
 
121
119
  function publishStatus(): void {
122
120
  statusTimer = null;
123
- const session = currentSession;
121
+ const session = hub.currentSession;
124
122
  if (!session || !lastContext?.ui?.setStatus) return;
125
123
  const records = manager.list(session);
126
124
  if (records.length > 0) lastContext.ui.setStatus("omnirush-agents", agentStatusSummary(records));
@@ -132,31 +130,9 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
132
130
  statusTimer.unref?.();
133
131
  }
134
132
 
135
- const send = (records: AgentRecord[]) => {
136
- pi.sendMessage(
137
- {
138
- customType: DELIVERY_TYPE,
139
- content: renderDelivery(records),
140
- display: true,
141
- details: { results: records.map(resultOf) },
142
- },
143
- // Busy: into the running turn at its next step. Idle: a new turn.
144
- { triggerTurn: true, deliverAs: "steer" },
145
- );
146
- };
147
-
148
- const deliver = () => {
149
- if (!currentSession) return;
150
- // One-shot and between runs: the agent_settled loop delivers.
151
- if (oneShot && !busy) {
152
- return;
153
- }
154
- for (const group of manager.takeDeliverable(currentSession)) send(group.records);
155
- };
156
-
157
133
  const manager = new AgentManager({
158
134
  run: options.run ?? ((task, options) => runChildAgent(task, {
159
- cwd: options.cwd ?? cwd,
135
+ cwd: options.cwd ?? hub.cwd,
160
136
  parentSessionId: options.parentSessionId,
161
137
  sessionId: options.sessionId,
162
138
  signal: options.signal,
@@ -164,10 +140,21 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
164
140
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
165
141
  })),
166
142
  // After the settle's own bookkeeping (waiters took theirs).
167
- onDeliverable: () => queueMicrotask(deliver),
143
+ onDeliverable: () => hub.notify(),
168
144
  onChanged: scheduleStatus,
169
145
  });
170
146
 
147
+ hub.addSource({
148
+ hasPending: (session) => manager.hasPending(session),
149
+ // One message per finished batch (or per child with notify "each").
150
+ take: (session) => manager.takeDeliverable(session).map(({ records }) => ({
151
+ customType: DELIVERY_TYPE,
152
+ content: renderDelivery(records),
153
+ details: { results: records.map(resultOf) },
154
+ })),
155
+ changed: () => manager.changed(),
156
+ });
157
+
171
158
  const hook: AgentsHook = {
172
159
  async interruptAll(parentSessionId?: string) {
173
160
  const stopped = await manager.interruptAll(parentSessionId);
@@ -177,17 +164,8 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
177
164
  (globalThis as any)[AGENTS_HOOK] = hook;
178
165
 
179
166
  const sessionOf = (ctx: any): string => {
180
- const id = String(ctx?.sessionManager?.getSessionId?.() ?? "");
181
- if (id && id !== currentSession) {
182
- currentSession = id;
183
- inheritedModel = "";
184
- }
185
167
  lastContext = ctx ?? lastContext;
186
- const selected = modelSelectionFromContext(ctx);
187
- if (selected) inheritedModel = selected;
188
- if (ctx?.cwd) cwd = String(ctx.cwd);
189
- if (ctx?.mode === "print" || ctx?.mode === "json") oneShot = true;
190
- return id;
168
+ return hub.sessionOf(ctx);
191
169
  };
192
170
 
193
171
  pi.registerTool({
@@ -212,6 +190,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
212
190
  "Short fan-outs whose results you need right away (quick searches, lookups): the default blocking call.",
213
191
  "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.",
214
192
  "Do not use spawn_agents for a single quick action — doing it yourself is cheaper.",
193
+ SWARM_GUIDELINE,
215
194
  ],
216
195
  parameters: SpawnAgentsParams,
217
196
 
@@ -222,16 +201,22 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
222
201
  if (!raw || typeof raw.task !== "string" || !raw.task.trim()) {
223
202
  throw new Error("invalid tasks: every entry needs a non-empty task string");
224
203
  }
225
- // An omitted model inherits the live parent selection. Explicit task
226
- // models remain available for deliberate mixed-model swarms.
204
+ // A task's own model[:effort] wins; an omitted (or empty) one takes
205
+ // the /subagents pick, else the parent session's current model and
206
+ // effort (resolved below by subagentTasks).
227
207
  const requestedModel = typeof raw.model === "string" ? raw.model.trim() : "";
228
208
  tasks.push({
229
209
  role: raw.role,
230
210
  task: raw.task.trim(),
231
- ...((requestedModel || inheritedModel) ? { model: requestedModel || inheritedModel } : {}),
211
+ ...(requestedModel ? { model: requestedModel } : {}),
232
212
  });
233
213
  }
234
214
  if (tasks.length === 0) throw new Error("no tasks given");
215
+ // The model and effort each child runs on (subagents.ts): the task's
216
+ // own model[:effort], else the /subagents pick, else the parent
217
+ // session's current model and effort; the main model when a pick is
218
+ // unavailable.
219
+ const resolvedTasks = subagentTasks(pi, ctx, tasks);
235
220
  if (!parentSessionId) throw new Error("no parent session id — subagents cannot be traced");
236
221
  const workdir = ctx?.cwd ? String(ctx.cwd) : process.cwd();
237
222
  // Only a deliberate limit counts: models fill optional numbers with
@@ -247,7 +232,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
247
232
  : undefined;
248
233
  const background = params.wait === false;
249
234
 
250
- const dispatched = manager.dispatch(parentSessionId, tasks, {
235
+ const dispatched = manager.dispatch(parentSessionId, resolvedTasks, {
251
236
  background,
252
237
  concurrency,
253
238
  notify: params.notify === "each" ? "each" : "batch",
@@ -271,7 +256,10 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
271
256
  });
272
257
 
273
258
  if (background) {
274
- const lines = dispatched.records.map((record) => `- ${record.id}: ${record.role}${record.model ? ` [${record.model}]` : ""} — ${record.task.length > 100 ? `${record.task.slice(0, 99)}…` : record.task}`);
259
+ const lines = dispatched.records.map((record, index) => {
260
+ const note = childModelFallback(resolvedTasks[index])?.note;
261
+ 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})` : ""}`;
262
+ });
275
263
  return {
276
264
  content: [{
277
265
  type: "text",
@@ -288,15 +276,20 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
288
276
  details: {
289
277
  background: true,
290
278
  batch: dispatched.batch,
291
- results: dispatched.records.map((record) => ({
292
- agent_id: record.id,
293
- role: record.role,
294
- task: record.task,
295
- session_id: record.sessionId,
296
- ...(record.model ? { model: record.model } : {}),
297
- parallel_limit: record.parallelLimit,
298
- status: "running",
299
- })),
279
+ results: dispatched.records.map((record, index) => {
280
+ const modelFallback = childModelFallback(resolvedTasks[index]);
281
+ return {
282
+ agent_id: record.id,
283
+ role: record.role,
284
+ task: record.task,
285
+ session_id: record.sessionId,
286
+ ...(record.model ? { model: record.model } : {}),
287
+ ...(record.effort ? { effort: record.effort } : {}),
288
+ ...(modelFallback ? { model_fallback: modelFallback } : {}),
289
+ parallel_limit: record.parallelLimit,
290
+ status: "running",
291
+ };
292
+ }),
300
293
  },
301
294
  };
302
295
  }
@@ -404,55 +397,6 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
404
397
  },
405
398
  });
406
399
 
407
- pi.on("session_start", (_event: any, ctx: any) => {
408
- sessionOf(ctx);
409
- });
410
-
411
- pi.on("agent_start", (_event: any, ctx: any) => {
412
- sessionOf(ctx);
413
- busy = true;
414
- });
415
-
416
- /**
417
- * One-shot runs (print/json mode) end when the agent settles: background
418
- * children still out would be lost. The run waits for them here and
419
- * continues with their results (a new run nested in this settle, whose
420
- * own settle resumes this one when everything is done).
421
- */
422
- let resumeOuter: (() => void) | null = null;
423
- pi.on("agent_settled", async (_event: any, ctx: any) => {
424
- busy = false;
425
- const session = sessionOf(ctx);
426
- if (!oneShot) {
427
- // Anything that finished right as the run ended.
428
- deliver();
429
- return;
430
- }
431
- const outer = resumeOuter;
432
- resumeOuter = null;
433
- try {
434
- for (;;) {
435
- if (typeof ctx?.isIdle === "function" && !ctx.isIdle()) {
436
- // A run started (a delivery): its settle resumes this one.
437
- await new Promise<void>((resolve) => { resumeOuter = resolve; });
438
- break;
439
- }
440
- if (!session || !manager.hasPending(session)) break;
441
- const groups = manager.takeDeliverable(session);
442
- if (groups.length === 0) {
443
- await manager.changed();
444
- continue;
445
- }
446
- busy = true;
447
- const records = groups.flatMap((group) => group.records);
448
- send(records);
449
- if (typeof ctx?.isIdle === "function" && ctx.isIdle()) busy = false;
450
- }
451
- } finally {
452
- outer?.();
453
- }
454
- });
455
-
456
400
  pi.on("session_shutdown", async () => {
457
401
  // Children the session ends under are stopped (the collector, when it
458
402
  // runs, already did this before its last capture).