omnirush 0.8.5 → 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 (49) hide show
  1. package/assets/CHANGELOG.md +85 -0
  2. package/assets/collect-once.ts +6 -5
  3. package/assets/extensions/omnirush/agents-lib.ts +196 -12
  4. package/assets/extensions/omnirush/agents.ts +97 -105
  5. package/assets/extensions/omnirush/auth.js +399 -115
  6. package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
  7. package/assets/extensions/omnirush/bgshell.ts +392 -0
  8. package/assets/extensions/omnirush/collector.ts +103 -16
  9. package/assets/extensions/omnirush/commands.ts +14 -15
  10. package/assets/extensions/omnirush/deliveries.ts +145 -0
  11. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  12. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  13. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  14. package/assets/extensions/omnirush/guard.ts +340 -0
  15. package/assets/extensions/omnirush/index.ts +12 -0
  16. package/assets/extensions/omnirush/pi-engine.ts +65 -1
  17. package/assets/extensions/omnirush/sota.ts +104 -47
  18. package/assets/extensions/omnirush/status-lib.ts +3 -0
  19. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  20. package/assets/extensions/omnirush/subagents.ts +305 -0
  21. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  22. package/assets/extensions/omnirush/swarm.ts +95 -0
  23. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  24. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  25. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  26. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  27. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  28. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  29. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  30. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  31. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  32. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  33. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  34. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  35. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  36. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  37. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  38. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  39. package/assets/extensions/omnirush/voice.ts +838 -0
  40. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  41. package/assets/extensions/omnirush/yolo.ts +85 -0
  42. package/package.json +6 -2
  43. package/scripts/brand-engine.js +526 -0
  44. package/scripts/build-all-packages.py +29 -1
  45. package/scripts/smoke-packages.py +32 -1
  46. package/src/bin.js +252 -53
  47. package/src/compat.js +272 -0
  48. package/src/lib.js +64 -0
  49. 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.
@@ -43,7 +43,9 @@ async function main() {
43
43
  const { dir, dryRun } = parseArgs(process.argv.slice(2));
44
44
  const sessionId = `manual-${randomBytes(6).toString("hex")}`;
45
45
  const workspaceId = `cli_${createHash("sha256").update(path.resolve(dir)).digest("hex").slice(0, 16)}`;
46
- const accessToken = (process.env.OMNIRUSH_ACCESS_TOKEN || process.env.OMNIRUSH_TOKEN || "").trim();
46
+ const accessToken = (
47
+ sharedRefresher().accessToken() || process.env.OMNIRUSH_ACCESS_TOKEN || process.env.OMNIRUSH_TOKEN || ""
48
+ ).trim();
47
49
  if (!accessToken && !dryRun) {
48
50
  console.error("omnirush collect: no access token (run `omnirush login`)");
49
51
  process.exit(1);
@@ -57,10 +59,9 @@ async function main() {
57
59
  // A dry run keeps no state (nothing is spooled for a later run).
58
60
  ...(dryRun ? {} : { stateDir: omniDir() }),
59
61
  refreshAccessToken: async () => {
60
- const refresher = sharedRefresher();
61
- const refreshed = await refresher.refresh(current).catch(() => false);
62
- const next = refresher.auth?.accessToken ?? null;
63
- if (!refreshed || !next) return null;
62
+ const outcome = await sharedRefresher().recover(current).catch(() => null);
63
+ if (outcome?.status !== "ok") return null;
64
+ const next = outcome.accessToken;
64
65
  current = next;
65
66
  return next;
66
67
  },
@@ -28,6 +28,26 @@ 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";
39
+ import { childAuthEnv } from "./auth";
40
+ import { yoloActive } from "./yolo-lib";
41
+
42
+ /** childAuthEnv, never throwing (a spawn must not fail on the auth file). */
43
+ function childAuthEnvSafe(): Record<string, string> {
44
+ try {
45
+ return childAuthEnv(process.env);
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
31
51
  /**
32
52
  * Inactivity watchdog default: a child that prints nothing (no model
33
53
  * stream, no tool event) for this long is treated as hung and stopped.
@@ -55,6 +75,24 @@ export const CHILD_OUTPUT_CAP_BYTES = 50 * 1024;
55
75
  export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
56
76
  export type AgentRole = (typeof AGENT_ROLES)[number];
57
77
 
78
+ /**
79
+ * Resolve the model selected for the current parent session. Pi keeps the
80
+ * effort level separately from the model id, while the CLI accepts the
81
+ * canonical `model:effort` spelling for child argv.
82
+ */
83
+ export function modelSelectionFromContext(ctx: any): string | undefined {
84
+ const model = ctx?.model;
85
+ const id = typeof model?.id === "string"
86
+ ? model.id.trim()
87
+ : typeof model?.modelID === "string"
88
+ ? model.modelID.trim()
89
+ : "";
90
+ if (!id) return undefined;
91
+ const effort = typeof ctx?.thinkingLevel === "string" ? ctx.thinkingLevel.trim() : "";
92
+ if (!effort || effort === "off" || id.includes(":")) return id;
93
+ return `${id}:${effort}`;
94
+ }
95
+
58
96
  export interface RolePreset {
59
97
  role: AgentRole;
60
98
  label: string;
@@ -135,8 +173,15 @@ export function buildChildArgs(
135
173
  promptFilePath: string | null,
136
174
  model?: string,
137
175
  sessionId?: string,
176
+ effort?: string | null,
177
+ approve = false,
138
178
  ): string[] {
139
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
+ }
140
185
  if (sessionId) {
141
186
  // The child's session id, picked by the parent so its session file can
142
187
  // be found and captured as this session's sub-agent.
@@ -148,6 +193,11 @@ export function buildChildArgs(
148
193
  if (model && model.trim()) {
149
194
  args.push("--provider", "omnirush", "--model", model.trim());
150
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
+ }
151
201
  args.push(`Task: ${task}`);
152
202
  return args;
153
203
  }
@@ -178,6 +228,25 @@ export interface ChildTask {
178
228
  * (e.g. "muse-spark-1.3" for cheap swarm workers under an astra
179
229
  * parent). Undefined = inherit the parent's model. */
180
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;
181
250
  }
182
251
 
183
252
  export type ChildStatus = "completed" | "failed" | "timeout" | "stalled" | "cancelled" | "interrupted";
@@ -189,6 +258,10 @@ export interface ChildResult {
189
258
  session_id?: string;
190
259
  /** The gateway model the child ran on (cross-model children). */
191
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;
192
265
  status: ChildStatus;
193
266
  /** Process exit code (null when killed by a signal or still unknown). */
194
267
  exitCode: number | null;
@@ -208,6 +281,33 @@ export interface ChildResultInput {
208
281
  turns: number;
209
282
  status?: ChildStatus;
210
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;
211
311
  }
212
312
 
213
313
  /** Final structured result for one child. */
@@ -224,11 +324,14 @@ export function buildChildResult(
224
324
  capped = output.slice(0, CHILD_OUTPUT_CAP_BYTES);
225
325
  while (Buffer.byteLength(capped, "utf8") > CHILD_OUTPUT_CAP_BYTES) capped = capped.slice(0, -1);
226
326
  }
327
+ const modelFallback = childModelFallback(task, input.gatewayFallback);
227
328
  return {
228
329
  role: task.role,
229
330
  task: task.task,
230
331
  ...(sessionId ? { session_id: sessionId } : {}),
231
332
  ...(task.model ? { model: task.model } : {}),
333
+ ...(task.effort ? { effort: task.effort } : {}),
334
+ ...(modelFallback ? { model_fallback: modelFallback } : {}),
232
335
  status: input.status ?? (input.exitCode === 0 ? "completed" : "failed"),
233
336
  exitCode: input.exitCode,
234
337
  output: capped,
@@ -363,13 +466,15 @@ export async function runChildAgent(
363
466
  const sessionId = options.sessionId ?? randomUUID();
364
467
 
365
468
  return withRolePromptFile(task.role, async (promptFile) => {
366
- const args = buildChildArgs(task.task, promptFile, task.model, sessionId);
469
+ const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive());
367
470
  const invocation = childInvocation(args);
368
471
 
369
472
  return await new Promise<ChildResult>((resolvePromise) => {
370
473
  let stdout = "";
371
474
  let stderr = "";
372
475
  let pendingLine = "";
476
+ let pendingErrLine = "";
477
+ let gatewayFallback: ChildResultInput["gatewayFallback"];
373
478
  let settled = false;
374
479
  let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
375
480
  let turns = 0;
@@ -382,10 +487,10 @@ export async function runChildAgent(
382
487
  cwd: options.cwd,
383
488
  shell: false,
384
489
  stdio: ["ignore", "pipe", "pipe"],
385
- env: {
386
- ...process.env,
387
- OMNIRUSH_PARENT_SESSION: options.parentSessionId,
388
- },
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() }),
389
494
  });
390
495
 
391
496
  const finish = (input: ChildResultInput) => {
@@ -396,7 +501,7 @@ export async function runChildAgent(
396
501
  if (options.signal && abortHandler) {
397
502
  options.signal.removeEventListener("abort", abortHandler);
398
503
  }
399
- resolvePromise(buildChildResult(task, input, now() - startedAt, sessionId));
504
+ resolvePromise(buildChildResult(task, { ...input, ...(gatewayFallback ? { gatewayFallback } : {}) }, now() - startedAt, sessionId));
400
505
  };
401
506
 
402
507
  const killTree = () => {
@@ -473,7 +578,12 @@ export async function runChildAgent(
473
578
  options.onChildStdout?.(task.role, text);
474
579
  });
475
580
  child.stderr?.on("data", (chunk: Buffer | string) => {
476
- 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;
477
587
  if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
478
588
  alive();
479
589
  });
@@ -518,6 +628,44 @@ function formatMinutes(ms: number): string {
518
628
  return `${Math.max(1, Math.round(ms / 1000))} s`;
519
629
  }
520
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
+
521
669
  /**
522
670
  * Run tasks with a concurrency cap, preserving input order in the
523
671
  * results (ports the pi subagent example's mapWithConcurrencyLimit).
@@ -547,8 +695,12 @@ export function renderChildResults(results: ChildResult[], ids?: readonly string
547
695
  const succeeded = results.filter((result) => result.status === "completed").length;
548
696
  const sections = results.map((result, index) => {
549
697
  const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
550
- 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" : ""})`;
551
702
  const meta: string[] = [`task: ${result.task}`];
703
+ if (result.model_fallback) meta.push(`note: ${result.model_fallback.note}`);
552
704
  if (result.error) meta.push(`error: ${result.error}`);
553
705
  return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
554
706
  });
@@ -571,8 +723,11 @@ export interface AgentRecord {
571
723
  role: AgentRole;
572
724
  task: string;
573
725
  model?: string;
726
+ effort?: string;
574
727
  /** Dispatched with wait:false: its result comes back as a message. */
575
728
  background: boolean;
729
+ /** Null means the batch was intentionally uncapped; otherwise the batch limit. */
730
+ parallelLimit: number | null;
576
731
  /** Background delivery: one message per child, or one per batch. */
577
732
  notify: "each" | "batch";
578
733
  status: AgentState;
@@ -611,6 +766,8 @@ export interface DispatchOptions {
611
766
  signal?: AbortSignal;
612
767
  /** A child settled (progress updates). */
613
768
  onSettled?: (record: AgentRecord) => void;
769
+ /** A child was queued, started, updated, or settled. */
770
+ onChanged?: () => void;
614
771
  /** Explicit wall-clock limit per child (none by default). */
615
772
  timeoutMs?: number;
616
773
  /** Workspace the children run in. */
@@ -627,6 +784,7 @@ export class AgentManager {
627
784
  private readonly runner: AgentRunner;
628
785
  private readonly now: () => number;
629
786
  private readonly onDeliverable: () => void;
787
+ private readonly onChanged: () => void;
630
788
  private readonly bySession = new Map<string, AgentRecord[]>();
631
789
  private waiters: Array<{ records: AgentRecord[]; resolve: () => void }> = [];
632
790
  private changeWaiters: Array<() => void> = [];
@@ -634,10 +792,11 @@ export class AgentManager {
634
792
  private nextAgent = 1;
635
793
  private nextBatch = 1;
636
794
 
637
- constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void }) {
795
+ constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void; onChanged?: () => void }) {
638
796
  this.runner = options.run;
639
797
  this.now = options.now ?? Date.now;
640
798
  this.onDeliverable = options.onDeliverable ?? (() => undefined);
799
+ this.onChanged = options.onChanged ?? (() => undefined);
641
800
  }
642
801
 
643
802
  dispatch(
@@ -660,7 +819,9 @@ export class AgentManager {
660
819
  role: task.role,
661
820
  task: task.task,
662
821
  ...(task.model ? { model: task.model } : {}),
822
+ ...(task.effort ? { effort: task.effort } : {}),
663
823
  background: options.background,
824
+ parallelLimit: options.concurrency === undefined ? null : Math.max(1, Math.floor(options.concurrency)),
664
825
  notify: options.notify ?? "batch",
665
826
  status: "queued",
666
827
  queuedAt: at,
@@ -679,6 +840,8 @@ export class AgentManager {
679
840
  list.push(record);
680
841
  return record;
681
842
  });
843
+ this.onChanged();
844
+ options.onChanged?.();
682
845
  if (options.signal) {
683
846
  const abort = () => { void this.cancel(records); };
684
847
  if (options.signal.aborted) abort();
@@ -697,6 +860,8 @@ export class AgentManager {
697
860
  record.status = "running";
698
861
  record.startedAt = this.now();
699
862
  record.lastActivityAt = record.startedAt;
863
+ this.onChanged();
864
+ options.onChanged?.();
700
865
  let result: ChildResult;
701
866
  try {
702
867
  result = await this.runner(task, {
@@ -709,6 +874,8 @@ export class AgentManager {
709
874
  record.lastActivityAt = activity.at;
710
875
  record.turns = activity.turns;
711
876
  record.toolsRunning = activity.toolsRunning;
877
+ this.onChanged();
878
+ options.onChanged?.();
712
879
  },
713
880
  });
714
881
  } catch (error) {
@@ -730,6 +897,9 @@ export class AgentManager {
730
897
  record.finishedAt = this.now();
731
898
  record.turns = result.turns;
732
899
  record.toolsRunning = 0;
900
+ this.onChanged();
901
+ // The dispatch callback is not available here; settle callers also invoke
902
+ // their onSettled hook immediately after start() returns.
733
903
  this.resolvers.get(record)?.(result);
734
904
  this.resolvers.delete(record);
735
905
  // Waiters first: what they return is delivered through their tool result.
@@ -873,6 +1043,21 @@ function minutesOf(ms: number): number {
873
1043
  return Math.round((ms / 60_000) * 10) / 10;
874
1044
  }
875
1045
 
1046
+ /** Compact status used by both the tool stream and the TUI status bar. */
1047
+ export function agentStatusSummary(records: AgentRecord[]): string {
1048
+ if (records.length === 0) return "No sub-agents in this session.";
1049
+ const running = records.filter((record) => record.status === "running").length;
1050
+ const queued = records.filter((record) => record.status === "queued").length;
1051
+ const settled = records.filter((record) => Boolean(record.result)).length;
1052
+ const limits = [...new Set(records.map((record) => record.parallelLimit))];
1053
+ const parallel = limits.length === 1 && limits[0] === null
1054
+ ? "unlimited"
1055
+ : limits.length === 1
1056
+ ? String(limits[0])
1057
+ : "per-batch";
1058
+ return `${records.length} sub-agents: ${running} running, ${queued} queued, ${settled} settled (parallel: ${parallel})`;
1059
+ }
1060
+
876
1061
  /** One status line per sub-agent (agents_status, /agents). */
877
1062
  export function renderAgentStatus(records: AgentRecord[], now: number = Date.now()): string {
878
1063
  if (records.length === 0) return "No sub-agents in this session.";
@@ -880,7 +1065,7 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
880
1065
  const since = record.startedAt ?? record.queuedAt;
881
1066
  const elapsed = minutesOf((record.finishedAt ?? now) - since);
882
1067
  const bits = [`${record.id}`, `[${record.status}]`, record.role];
883
- if (record.model) bits.push(`[${record.model}]`);
1068
+ if (record.model) bits.push(`[${record.model}${record.effort ? ` · ${record.effort}` : ""}]`);
884
1069
  bits.push(record.background ? "background" : "blocking");
885
1070
  const detail: string[] = [`${elapsed} min`];
886
1071
  if (!record.result && record.startedAt) {
@@ -892,8 +1077,7 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
892
1077
  const task = record.task.length > 80 ? `${record.task.slice(0, 79)}…` : record.task;
893
1078
  return `- ${bits.join(" ")} (${detail.join(", ")}) — ${task}`;
894
1079
  });
895
- const running = records.filter((record) => !record.result).length;
896
- return `${records.length} sub-agents, ${running} running:\n${lines.join("\n")}`;
1080
+ return `${agentStatusSummary(records)}:\n${lines.join("\n")}`;
897
1081
  }
898
1082
 
899
1083
  /** The message that brings finished background agents back to the parent. */