omnirush 0.8.5 → 0.8.6

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.
@@ -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,17 @@ 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 { childAuthEnv } from "./auth";
32
+
33
+ /** childAuthEnv, never throwing (a spawn must not fail on the auth file). */
34
+ function childAuthEnvSafe(): Record<string, string> {
35
+ try {
36
+ return childAuthEnv(process.env);
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
31
42
  /**
32
43
  * Inactivity watchdog default: a child that prints nothing (no model
33
44
  * stream, no tool event) for this long is treated as hung and stopped.
@@ -55,6 +66,24 @@ export const CHILD_OUTPUT_CAP_BYTES = 50 * 1024;
55
66
  export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
56
67
  export type AgentRole = (typeof AGENT_ROLES)[number];
57
68
 
69
+ /**
70
+ * Resolve the model selected for the current parent session. Pi keeps the
71
+ * effort level separately from the model id, while the CLI accepts the
72
+ * canonical `model:effort` spelling for child argv.
73
+ */
74
+ export function modelSelectionFromContext(ctx: any): string | undefined {
75
+ const model = ctx?.model;
76
+ const id = typeof model?.id === "string"
77
+ ? model.id.trim()
78
+ : typeof model?.modelID === "string"
79
+ ? model.modelID.trim()
80
+ : "";
81
+ if (!id) return undefined;
82
+ const effort = typeof ctx?.thinkingLevel === "string" ? ctx.thinkingLevel.trim() : "";
83
+ if (!effort || effort === "off" || id.includes(":")) return id;
84
+ return `${id}:${effort}`;
85
+ }
86
+
58
87
  export interface RolePreset {
59
88
  role: AgentRole;
60
89
  label: string;
@@ -384,6 +413,10 @@ export async function runChildAgent(
384
413
  stdio: ["ignore", "pipe", "pipe"],
385
414
  env: {
386
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(),
387
420
  OMNIRUSH_PARENT_SESSION: options.parentSessionId,
388
421
  },
389
422
  });
@@ -573,6 +606,8 @@ export interface AgentRecord {
573
606
  model?: string;
574
607
  /** Dispatched with wait:false: its result comes back as a message. */
575
608
  background: boolean;
609
+ /** Null means the batch was intentionally uncapped; otherwise the batch limit. */
610
+ parallelLimit: number | null;
576
611
  /** Background delivery: one message per child, or one per batch. */
577
612
  notify: "each" | "batch";
578
613
  status: AgentState;
@@ -611,6 +646,8 @@ export interface DispatchOptions {
611
646
  signal?: AbortSignal;
612
647
  /** A child settled (progress updates). */
613
648
  onSettled?: (record: AgentRecord) => void;
649
+ /** A child was queued, started, updated, or settled. */
650
+ onChanged?: () => void;
614
651
  /** Explicit wall-clock limit per child (none by default). */
615
652
  timeoutMs?: number;
616
653
  /** Workspace the children run in. */
@@ -627,6 +664,7 @@ export class AgentManager {
627
664
  private readonly runner: AgentRunner;
628
665
  private readonly now: () => number;
629
666
  private readonly onDeliverable: () => void;
667
+ private readonly onChanged: () => void;
630
668
  private readonly bySession = new Map<string, AgentRecord[]>();
631
669
  private waiters: Array<{ records: AgentRecord[]; resolve: () => void }> = [];
632
670
  private changeWaiters: Array<() => void> = [];
@@ -634,10 +672,11 @@ export class AgentManager {
634
672
  private nextAgent = 1;
635
673
  private nextBatch = 1;
636
674
 
637
- constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void }) {
675
+ constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void; onChanged?: () => void }) {
638
676
  this.runner = options.run;
639
677
  this.now = options.now ?? Date.now;
640
678
  this.onDeliverable = options.onDeliverable ?? (() => undefined);
679
+ this.onChanged = options.onChanged ?? (() => undefined);
641
680
  }
642
681
 
643
682
  dispatch(
@@ -661,6 +700,7 @@ export class AgentManager {
661
700
  task: task.task,
662
701
  ...(task.model ? { model: task.model } : {}),
663
702
  background: options.background,
703
+ parallelLimit: options.concurrency === undefined ? null : Math.max(1, Math.floor(options.concurrency)),
664
704
  notify: options.notify ?? "batch",
665
705
  status: "queued",
666
706
  queuedAt: at,
@@ -679,6 +719,8 @@ export class AgentManager {
679
719
  list.push(record);
680
720
  return record;
681
721
  });
722
+ this.onChanged();
723
+ options.onChanged?.();
682
724
  if (options.signal) {
683
725
  const abort = () => { void this.cancel(records); };
684
726
  if (options.signal.aborted) abort();
@@ -697,6 +739,8 @@ export class AgentManager {
697
739
  record.status = "running";
698
740
  record.startedAt = this.now();
699
741
  record.lastActivityAt = record.startedAt;
742
+ this.onChanged();
743
+ options.onChanged?.();
700
744
  let result: ChildResult;
701
745
  try {
702
746
  result = await this.runner(task, {
@@ -709,6 +753,8 @@ export class AgentManager {
709
753
  record.lastActivityAt = activity.at;
710
754
  record.turns = activity.turns;
711
755
  record.toolsRunning = activity.toolsRunning;
756
+ this.onChanged();
757
+ options.onChanged?.();
712
758
  },
713
759
  });
714
760
  } catch (error) {
@@ -730,6 +776,9 @@ export class AgentManager {
730
776
  record.finishedAt = this.now();
731
777
  record.turns = result.turns;
732
778
  record.toolsRunning = 0;
779
+ this.onChanged();
780
+ // The dispatch callback is not available here; settle callers also invoke
781
+ // their onSettled hook immediately after start() returns.
733
782
  this.resolvers.get(record)?.(result);
734
783
  this.resolvers.delete(record);
735
784
  // Waiters first: what they return is delivered through their tool result.
@@ -873,6 +922,21 @@ function minutesOf(ms: number): number {
873
922
  return Math.round((ms / 60_000) * 10) / 10;
874
923
  }
875
924
 
925
+ /** Compact status used by both the tool stream and the TUI status bar. */
926
+ export function agentStatusSummary(records: AgentRecord[]): string {
927
+ if (records.length === 0) return "No sub-agents in this session.";
928
+ const running = records.filter((record) => record.status === "running").length;
929
+ const queued = records.filter((record) => record.status === "queued").length;
930
+ const settled = records.filter((record) => Boolean(record.result)).length;
931
+ const limits = [...new Set(records.map((record) => record.parallelLimit))];
932
+ const parallel = limits.length === 1 && limits[0] === null
933
+ ? "unlimited"
934
+ : limits.length === 1
935
+ ? String(limits[0])
936
+ : "per-batch";
937
+ return `${records.length} sub-agents: ${running} running, ${queued} queued, ${settled} settled (parallel: ${parallel})`;
938
+ }
939
+
876
940
  /** One status line per sub-agent (agents_status, /agents). */
877
941
  export function renderAgentStatus(records: AgentRecord[], now: number = Date.now()): string {
878
942
  if (records.length === 0) return "No sub-agents in this session.";
@@ -892,8 +956,7 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
892
956
  const task = record.task.length > 80 ? `${record.task.slice(0, 79)}…` : record.task;
893
957
  return `- ${bits.join(" ")} (${detail.join(", ")}) — ${task}`;
894
958
  });
895
- const running = records.filter((record) => !record.result).length;
896
- return `${records.length} sub-agents, ${running} running:\n${lines.join("\n")}`;
959
+ return `${agentStatusSummary(records)}:\n${lines.join("\n")}`;
897
960
  }
898
961
 
899
962
  /** The message that brings finished background agents back to the parent. */
@@ -40,6 +40,7 @@ import {
40
40
  AGENT_ROLES,
41
41
  AGENTS_HOOK,
42
42
  AgentManager,
43
+ agentStatusSummary,
43
44
  type AgentRunner,
44
45
  type AgentsHook,
45
46
  type AgentRecord,
@@ -47,6 +48,7 @@ import {
47
48
  renderAgentStatus,
48
49
  renderChildResults,
49
50
  renderDelivery,
51
+ modelSelectionFromContext,
50
52
  runChildAgent,
51
53
  type ChildResult,
52
54
  type ChildTask,
@@ -66,7 +68,7 @@ const SpawnAgentsParams = Type.Object({
66
68
  }),
67
69
  task: Type.String({ description: "Self-contained task description for the child agent (it sees ONLY this, not your conversation)" }),
68
70
  model: Type.Optional(Type.String({
69
- description: 'Cross-model child: gateway model id 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", "gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol"). Omit (or leave empty) to inherit this session\'s model',
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',
70
72
  })),
71
73
  }),
72
74
  { description: "Tasks to delegate; they all run in parallel", minItems: 1 },
@@ -99,7 +101,7 @@ function renderSome(records: AgentRecord[], unknown: string[] = []): string {
99
101
  const open = records.filter((record) => !record.result);
100
102
  const parts: string[] = [];
101
103
  if (finished.length > 0) parts.push(renderChildResults(finished.map((record) => record.result as ChildResult), finished.map((record) => record.id)));
102
- if (open.length > 0) parts.push(`Still running:\n${renderAgentStatus(open)}`);
104
+ if (open.length > 0) parts.push(`Open sub-agents:\n${renderAgentStatus(open)}`);
103
105
  if (unknown.length > 0) parts.push(`Unknown agent ids: ${unknown.join(", ")}`);
104
106
  return parts.join("\n\n") || "No matching sub-agents.";
105
107
  }
@@ -109,10 +111,27 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
109
111
  /** The session this runtime is on (deliveries go to it). */
110
112
  let currentSession = "";
111
113
  let cwd = process.cwd();
114
+ let inheritedModel = "";
115
+ let lastContext: any = null;
116
+ let statusTimer: ReturnType<typeof setTimeout> | null = null;
112
117
  /** An agent run is under way (one-shot runs deliver through the settle loop otherwise). */
113
118
  let busy = false;
114
119
  let oneShot = false;
115
120
 
121
+ function publishStatus(): void {
122
+ statusTimer = null;
123
+ const session = currentSession;
124
+ if (!session || !lastContext?.ui?.setStatus) return;
125
+ const records = manager.list(session);
126
+ if (records.length > 0) lastContext.ui.setStatus("omnirush-agents", agentStatusSummary(records));
127
+ }
128
+
129
+ function scheduleStatus(): void {
130
+ if (statusTimer) return;
131
+ statusTimer = setTimeout(publishStatus, 250);
132
+ statusTimer.unref?.();
133
+ }
134
+
116
135
  const send = (records: AgentRecord[]) => {
117
136
  pi.sendMessage(
118
137
  {
@@ -146,6 +165,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
146
165
  })),
147
166
  // After the settle's own bookkeeping (waiters took theirs).
148
167
  onDeliverable: () => queueMicrotask(deliver),
168
+ onChanged: scheduleStatus,
149
169
  });
150
170
 
151
171
  const hook: AgentsHook = {
@@ -158,7 +178,13 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
158
178
 
159
179
  const sessionOf = (ctx: any): string => {
160
180
  const id = String(ctx?.sessionManager?.getSessionId?.() ?? "");
161
- if (id) currentSession = id;
181
+ if (id && id !== currentSession) {
182
+ currentSession = id;
183
+ inheritedModel = "";
184
+ }
185
+ lastContext = ctx ?? lastContext;
186
+ const selected = modelSelectionFromContext(ctx);
187
+ if (selected) inheritedModel = selected;
162
188
  if (ctx?.cwd) cwd = String(ctx.cwd);
163
189
  if (ctx?.mode === "print" || ctx?.mode === "json") oneShot = true;
164
190
  return id;
@@ -190,21 +216,22 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
190
216
  parameters: SpawnAgentsParams,
191
217
 
192
218
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
219
+ const parentSessionId = sessionOf(ctx);
193
220
  const tasks: ChildTask[] = [];
194
221
  for (const raw of params.tasks ?? []) {
195
222
  if (!raw || typeof raw.task !== "string" || !raw.task.trim()) {
196
223
  throw new Error("invalid tasks: every entry needs a non-empty task string");
197
224
  }
198
- // An empty model (models fill optional fields) inherits the session's.
199
- const model = typeof raw.model === "string" ? raw.model.trim() : "";
225
+ // An omitted model inherits the live parent selection. Explicit task
226
+ // models remain available for deliberate mixed-model swarms.
227
+ const requestedModel = typeof raw.model === "string" ? raw.model.trim() : "";
200
228
  tasks.push({
201
229
  role: raw.role,
202
230
  task: raw.task.trim(),
203
- ...(model ? { model } : {}),
231
+ ...((requestedModel || inheritedModel) ? { model: requestedModel || inheritedModel } : {}),
204
232
  });
205
233
  }
206
234
  if (tasks.length === 0) throw new Error("no tasks given");
207
- const parentSessionId = sessionOf(ctx);
208
235
  if (!parentSessionId) throw new Error("no parent session id — subagents cannot be traced");
209
236
  const workdir = ctx?.cwd ? String(ctx.cwd) : process.cwd();
210
237
  // Only a deliberate limit counts: models fill optional numbers with
@@ -212,26 +239,33 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
212
239
  const timeoutMs = typeof params.timeout_minutes === "number" && params.timeout_minutes >= MIN_TIMEOUT_MINUTES
213
240
  ? params.timeout_minutes * 60_000
214
241
  : undefined;
215
- // No cap: every task runs at once unless the caller throttles.
216
- const concurrency = typeof params.max_parallel === "number" && params.max_parallel >= 1
242
+ // No cap: every task runs at once unless the caller deliberately
243
+ // supplies max_parallel.
244
+ const hasParallelLimit = typeof params.max_parallel === "number" && params.max_parallel >= 1;
245
+ const concurrency = hasParallelLimit
217
246
  ? Math.floor(params.max_parallel)
218
- : tasks.length;
247
+ : undefined;
219
248
  const background = params.wait === false;
220
249
 
221
- let settledCount = 0;
222
250
  const dispatched = manager.dispatch(parentSessionId, tasks, {
223
251
  background,
224
252
  concurrency,
225
253
  notify: params.notify === "each" ? "each" : "batch",
226
254
  ...(background ? {} : { signal }),
227
255
  onSettled: (record) => {
228
- settledCount += 1;
229
256
  if (background) return;
230
257
  onUpdate?.({
231
- content: [{ type: "text", text: `spawn_agents: ${settledCount}/${tasks.length} settled (latest: ${record.id} ${record.role})` }],
258
+ content: [{ type: "text", text: `${agentStatusSummary(dispatched.records)} (latest: ${record.id} ${record.role})` }],
232
259
  details: { results: dispatched.records.filter((candidate) => candidate.result).map(resultOf) },
233
260
  });
234
261
  },
262
+ onChanged: () => {
263
+ if (background) return;
264
+ onUpdate?.({
265
+ content: [{ type: "text", text: agentStatusSummary(manager.list(parentSessionId)) }],
266
+ details: { results: manager.list(parentSessionId).filter((candidate) => candidate.result).map(resultOf) },
267
+ });
268
+ },
235
269
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
236
270
  cwd: workdir,
237
271
  });
@@ -260,6 +294,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
260
294
  task: record.task,
261
295
  session_id: record.sessionId,
262
296
  ...(record.model ? { model: record.model } : {}),
297
+ parallel_limit: record.parallelLimit,
263
298
  status: "running",
264
299
  })),
265
300
  },
@@ -286,7 +321,20 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
286
321
  const text = renderAgentStatus(records) + (unknown.length ? `\nUnknown agent ids: ${unknown.join(", ")}` : "");
287
322
  return {
288
323
  content: [{ type: "text", text }],
289
- details: { agents: records.map((record) => ({ agent_id: record.id, status: record.status, role: record.role, session_id: record.sessionId })) },
324
+ details: {
325
+ agents: records.map((record) => ({
326
+ agent_id: record.id,
327
+ status: record.status,
328
+ role: record.role,
329
+ task: record.task,
330
+ session_id: record.sessionId,
331
+ ...(record.model ? { model: record.model } : {}),
332
+ parallel_limit: record.parallelLimit,
333
+ turns: record.turns,
334
+ tools_running: record.toolsRunning,
335
+ last_activity_at: record.lastActivityAt,
336
+ })),
337
+ },
290
338
  };
291
339
  },
292
340
  });