omnirush 0.8.4 → 0.8.5

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.
@@ -4,20 +4,33 @@
4
4
  // the SAME workspace:
5
5
  // - role presets: code-searcher (read-only exploration),
6
6
  // researcher-web (web_fetch research), general-worker (full tools)
7
- // - parallel execution, default max 4 children at once
8
- // - per-child wall-clock timeout (default 10 min): kill + keep the
9
- // partial result
10
- // - structured results returned to the parent model
7
+ // - parallel execution, every task at once unless max_parallel throttles
8
+ // - no wall-clock limit: a child runs until it is done. Only an explicit
9
+ // timeout_minutes, the inactivity watchdog (no output at all for
10
+ // OMNIRUSH_AGENT_INACTIVITY_MINUTES, default 30, longer while one of
11
+ // its tools runs), a cancel, or the session ending stop it
12
+ // - blocking (default) or background dispatch (`wait: false`): the call
13
+ // returns at once with agent ids and the parent keeps working; the
14
+ // results come back as a message in the conversation when the batch
15
+ // (or each child, notify "each") finishes — steered into the running
16
+ // turn, or starting a new turn when the parent is idle. In one-shot
17
+ // runs (print/json mode) the run does not end while background
18
+ // children are out: it waits for them and continues with their results
19
+ // - agents_status / agents_wait / agents_result / agents_cancel manage
20
+ // running children
11
21
  //
12
22
  // Tracing is free: every child writes its own session JSONL under
13
23
  // <agentDir>/sessions/<cwd-slug>/ (children run without --no-session)
14
24
  // under a session id the parent picks, and the parent session's
15
25
  // collector records each child's conversation as a sub-agent of the
16
26
  // turn (session.child events with parent and depth, as the desktop
17
- // records task sub-agents). Children get OMNIRUSH_PARENT_SESSION, so a
18
- // child process uploads nothing of its own.
27
+ // records task sub-agents) — a background child that finishes in a later
28
+ // turn is recorded as it progresses, and one the session ends under is
29
+ // stopped and recorded as interrupted. Children get
30
+ // OMNIRUSH_PARENT_SESSION, so a child process uploads nothing of its own.
19
31
  //
20
- // `/agents` lists this session's subagents (running + finished).
32
+ // `/agents` lists this session's subagents (running + finished);
33
+ // `/agents cancel <id|all>` stops running ones.
21
34
 
22
35
  import { StringEnum } from "@earendil-works/pi-ai";
23
36
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -25,14 +38,26 @@ import { Type } from "typebox";
25
38
 
26
39
  import {
27
40
  AGENT_ROLES,
28
- CHILD_TIMEOUT_MS,
29
- mapWithConcurrency,
41
+ AGENTS_HOOK,
42
+ AgentManager,
43
+ type AgentRunner,
44
+ type AgentsHook,
45
+ type AgentRecord,
46
+ childInactivityMs,
47
+ renderAgentStatus,
30
48
  renderChildResults,
49
+ renderDelivery,
31
50
  runChildAgent,
32
51
  type ChildResult,
33
52
  type ChildTask,
34
53
  } from "./agents-lib";
35
54
 
55
+ /** A timeout_minutes below this is taken for a placeholder, not a limit. */
56
+ const MIN_TIMEOUT_MINUTES = 1;
57
+
58
+ /** customType of the message that brings background results back. */
59
+ export const DELIVERY_TYPE = "omnirush-agents-result";
60
+
36
61
  const SpawnAgentsParams = Type.Object({
37
62
  tasks: Type.Array(
38
63
  Type.Object({
@@ -41,33 +66,103 @@ const SpawnAgentsParams = Type.Object({
41
66
  }),
42
67
  task: Type.String({ description: "Self-contained task description for the child agent (it sees ONLY this, not your conversation)" }),
43
68
  model: Type.Optional(Type.String({
44
- 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 to inherit this session\'s model',
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',
45
70
  })),
46
71
  }),
47
72
  { description: "Tasks to delegate; they all run in parallel", minItems: 1 },
48
73
  ),
74
+ wait: Type.Optional(Type.Boolean({
75
+ description: "true (default): block until every child finished and return their results. false: background dispatch — return at once with agent ids and keep working; the results arrive later as a message in this conversation (no polling needed)",
76
+ })),
77
+ notify: Type.Optional(StringEnum(["batch", "each"] as const, {
78
+ description: 'Background dispatch only: deliver one message when the whole batch finished ("batch", default) or one per child as each finishes ("each")',
79
+ })),
49
80
  timeout_minutes: Type.Optional(Type.Number({
50
- description: `Per-child wall-clock limit in minutes (default ${CHILD_TIMEOUT_MS / 60_000}; a timed-out child is killed and its partial result is kept)`,
51
- minimum: 0.1,
52
- maximum: 60,
81
+ description: "Leave out (or 0) unless the user asked for a hard limit: children then run until they are done, however long that takes. A value of 1 or more kills a child after that many minutes (its partial result is kept); smaller values are ignored",
53
82
  })),
54
83
  max_parallel: Type.Optional(Type.Number({
55
- description: "Optional throttle: run at most this many children at once. Omit to run every task in parallel (no cap)",
56
- minimum: 1,
84
+ description: "Leave out (or 0) to run every task in parallel (no cap, the default); a positive number throttles to that many children at once",
57
85
  })),
58
86
  });
59
87
 
60
- interface AgentRegistryEntry {
61
- role: string;
62
- task: string;
63
- status: "running" | ChildResult["status"];
64
- startedAt: number;
65
- durationMs: number | null;
88
+ const IdsParam = Type.Optional(Type.Array(Type.String(), {
89
+ description: 'Agent ids from spawn_agents (e.g. "a3"); omit (or "all") for every agent of this session',
90
+ }));
91
+
92
+ function resultOf(record: AgentRecord): ChildResult & { agent_id: string } {
93
+ return { ...(record.result as ChildResult), agent_id: record.id };
66
94
  }
67
95
 
68
- export default function (pi: ExtensionAPI) {
69
- /** session id -> subagent records (this process only). */
70
- const registry = new Map<string, AgentRegistryEntry[]>();
96
+ /** Results of finished records plus a status line for the others. */
97
+ function renderSome(records: AgentRecord[], unknown: string[] = []): string {
98
+ const finished = records.filter((record) => record.result);
99
+ const open = records.filter((record) => !record.result);
100
+ const parts: string[] = [];
101
+ 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)}`);
103
+ if (unknown.length > 0) parts.push(`Unknown agent ids: ${unknown.join(", ")}`);
104
+ return parts.join("\n\n") || "No matching sub-agents.";
105
+ }
106
+
107
+ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {}) {
108
+ const watchdogMinutes = Math.round(childInactivityMs() / 60_000);
109
+ /** The session this runtime is on (deliveries go to it). */
110
+ let currentSession = "";
111
+ let cwd = process.cwd();
112
+ /** An agent run is under way (one-shot runs deliver through the settle loop otherwise). */
113
+ let busy = false;
114
+ let oneShot = false;
115
+
116
+ const send = (records: AgentRecord[]) => {
117
+ pi.sendMessage(
118
+ {
119
+ customType: DELIVERY_TYPE,
120
+ content: renderDelivery(records),
121
+ display: true,
122
+ details: { results: records.map(resultOf) },
123
+ },
124
+ // Busy: into the running turn at its next step. Idle: a new turn.
125
+ { triggerTurn: true, deliverAs: "steer" },
126
+ );
127
+ };
128
+
129
+ const deliver = () => {
130
+ if (!currentSession) return;
131
+ // One-shot and between runs: the agent_settled loop delivers.
132
+ if (oneShot && !busy) {
133
+ return;
134
+ }
135
+ for (const group of manager.takeDeliverable(currentSession)) send(group.records);
136
+ };
137
+
138
+ const manager = new AgentManager({
139
+ run: options.run ?? ((task, options) => runChildAgent(task, {
140
+ cwd: options.cwd ?? cwd,
141
+ parentSessionId: options.parentSessionId,
142
+ sessionId: options.sessionId,
143
+ signal: options.signal,
144
+ onActivity: options.onActivity,
145
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
146
+ })),
147
+ // After the settle's own bookkeeping (waiters took theirs).
148
+ onDeliverable: () => queueMicrotask(deliver),
149
+ });
150
+
151
+ const hook: AgentsHook = {
152
+ async interruptAll(parentSessionId?: string) {
153
+ const stopped = await manager.interruptAll(parentSessionId);
154
+ return stopped.map((record) => ({ sessionId: record.sessionId, id: record.id, role: record.role }));
155
+ },
156
+ };
157
+ (globalThis as any)[AGENTS_HOOK] = hook;
158
+
159
+ const sessionOf = (ctx: any): string => {
160
+ const id = String(ctx?.sessionManager?.getSessionId?.() ?? "");
161
+ if (id) currentSession = id;
162
+ if (ctx?.cwd) cwd = String(ctx.cwd);
163
+ if (ctx?.mode === "print" || ctx?.mode === "json") oneShot = true;
164
+ return id;
165
+ };
71
166
 
72
167
  pi.registerTool({
73
168
  name: "spawn_agents",
@@ -76,14 +171,20 @@ export default function (pi: ExtensionAPI) {
76
171
  "Delegate tasks to parallel subagents with isolated context windows.",
77
172
  "Each child is a headless agent run in this workspace with a role preset:",
78
173
  "code-searcher (read-only exploration), researcher-web (web research via web_search + web_fetch), general-worker (full tools).",
79
- `Children run in parallel — every task at once unless max_parallel throttles it — and each gets its own timeout (default ${CHILD_TIMEOUT_MS / 60_000} min).`,
174
+ "Children run in parallel — every task at once unless max_parallel throttles it — and have no time limit: they run until done",
175
+ `(timeout_minutes sets one explicitly${watchdogMinutes > 0 ? `; a child that prints nothing at all for ${watchdogMinutes}+ min is stopped as hung` : ""}).`,
176
+ "Default (wait true) blocks until all children finish and returns their results.",
177
+ "wait:false dispatches them in the BACKGROUND: the call returns at once with agent ids, you keep working, and their results arrive automatically as a message in this conversation when they finish.",
178
+ "Manage background agents with agents_status, agents_wait, agents_result and agents_cancel.",
80
179
  'Each task can run on a DIFFERENT model via its "model" field — e.g. cheap muse workers for wide swarm sweeps under an astra parent.',
81
180
  "Every task description must be SELF-CONTAINED: the child cannot see this conversation.",
82
181
  "Use for parallelizable work: broad code surveys, independent research questions, independent implementation chunks.",
83
182
  ].join(" "),
84
- promptSnippet: "spawn_agents: delegate parallel tasks to role-preset subagents",
183
+ promptSnippet: "spawn_agents: delegate parallel tasks to role-preset subagents (blocking, or wait:false for background work)",
85
184
  promptGuidelines: [
86
185
  "Use spawn_agents when 2+ independent sub-tasks would each take several tool calls (surveys, research, independent fixes); write each task brief as a self-contained mini-prompt with the goal, relevant paths and the expected report format.",
186
+ "Short fan-outs whose results you need right away (quick searches, lookups): the default blocking call.",
187
+ "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.",
87
188
  "Do not use spawn_agents for a single quick action — doing it yourself is cheaper.",
88
189
  ],
89
190
  parameters: SpawnAgentsParams,
@@ -94,10 +195,8 @@ export default function (pi: ExtensionAPI) {
94
195
  if (!raw || typeof raw.task !== "string" || !raw.task.trim()) {
95
196
  throw new Error("invalid tasks: every entry needs a non-empty task string");
96
197
  }
198
+ // An empty model (models fill optional fields) inherits the session's.
97
199
  const model = typeof raw.model === "string" ? raw.model.trim() : "";
98
- if (raw.model !== undefined && !model) {
99
- throw new Error("invalid tasks: model must be a non-empty gateway model id");
100
- }
101
200
  tasks.push({
102
201
  role: raw.role,
103
202
  task: raw.task.trim(),
@@ -105,78 +204,231 @@ export default function (pi: ExtensionAPI) {
105
204
  });
106
205
  }
107
206
  if (tasks.length === 0) throw new Error("no tasks given");
108
- const parentSessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
207
+ const parentSessionId = sessionOf(ctx);
109
208
  if (!parentSessionId) throw new Error("no parent session id — subagents cannot be traced");
110
- const cwd = ctx?.cwd ? String(ctx.cwd) : process.cwd();
111
- const timeoutMinutes = typeof params.timeout_minutes === "number" && params.timeout_minutes > 0
112
- ? params.timeout_minutes
209
+ const workdir = ctx?.cwd ? String(ctx.cwd) : process.cwd();
210
+ // Only a deliberate limit counts: models fill optional numbers with
211
+ // their minimum (a 0.1-minute "limit" killed every child in 6 s).
212
+ const timeoutMs = typeof params.timeout_minutes === "number" && params.timeout_minutes >= MIN_TIMEOUT_MINUTES
213
+ ? params.timeout_minutes * 60_000
113
214
  : undefined;
114
215
  // No cap: every task runs at once unless the caller throttles.
115
216
  const concurrency = typeof params.max_parallel === "number" && params.max_parallel >= 1
116
217
  ? Math.floor(params.max_parallel)
117
218
  : tasks.length;
219
+ const background = params.wait === false;
118
220
 
119
- const entries = registry.get(parentSessionId) ?? [];
120
- registry.set(parentSessionId, entries);
121
- const marks = tasks.map((task) => {
122
- const entry: AgentRegistryEntry = {
123
- role: task.role,
124
- task: task.task,
125
- status: "running",
126
- startedAt: Date.now(),
127
- durationMs: null,
128
- };
129
- entries.push(entry);
130
- return entry;
131
- });
132
-
133
- // Children settled so far, for progress updates (the result array
134
- // below only exists once every child settled).
135
- const settled: ChildResult[] = [];
136
- const results = await mapWithConcurrency<ChildTask, ChildResult>(
137
- tasks,
221
+ let settledCount = 0;
222
+ const dispatched = manager.dispatch(parentSessionId, tasks, {
223
+ background,
138
224
  concurrency,
139
- async (task, index) => {
140
- const result = await runChildAgent(task, {
141
- cwd,
142
- parentSessionId,
143
- timeoutMs: timeoutMinutes === undefined ? undefined : timeoutMinutes * 60_000,
144
- signal,
145
- });
146
- const mark = marks[index];
147
- mark.status = result.status;
148
- mark.durationMs = result.durationMs;
149
- settled.push(result);
225
+ notify: params.notify === "each" ? "each" : "batch",
226
+ ...(background ? {} : { signal }),
227
+ onSettled: (record) => {
228
+ settledCount += 1;
229
+ if (background) return;
150
230
  onUpdate?.({
151
- content: [{ type: "text", text: `spawn_agents: ${settled.length}/${tasks.length} settled (latest: ${task.role})` }],
152
- details: { results: [...settled] },
231
+ content: [{ type: "text", text: `spawn_agents: ${settledCount}/${tasks.length} settled (latest: ${record.id} ${record.role})` }],
232
+ details: { results: dispatched.records.filter((candidate) => candidate.result).map(resultOf) },
153
233
  });
154
- return result;
155
234
  },
156
- );
235
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
236
+ cwd: workdir,
237
+ });
238
+
239
+ if (background) {
240
+ 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}`);
241
+ return {
242
+ content: [{
243
+ type: "text",
244
+ text: [
245
+ `spawn_agents: dispatched ${dispatched.records.length} background sub-agent${dispatched.records.length === 1 ? "" : "s"} (batch ${dispatched.batch}):`,
246
+ ...lines,
247
+ "",
248
+ params.notify === "each"
249
+ ? "Keep working: each result arrives as a message in this conversation when that agent finishes."
250
+ : "Keep working: the results arrive as one message in this conversation when all of them finished.",
251
+ "agents_status shows progress; agents_wait blocks for them; agents_cancel stops them.",
252
+ ].join("\n"),
253
+ }],
254
+ details: {
255
+ background: true,
256
+ batch: dispatched.batch,
257
+ results: dispatched.records.map((record) => ({
258
+ agent_id: record.id,
259
+ role: record.role,
260
+ task: record.task,
261
+ session_id: record.sessionId,
262
+ ...(record.model ? { model: record.model } : {}),
263
+ status: "running",
264
+ })),
265
+ },
266
+ };
267
+ }
268
+
269
+ await dispatched.done;
270
+ const records = dispatched.records;
271
+ return {
272
+ content: [{ type: "text", text: renderChildResults(records.map((record) => record.result as ChildResult), records.map((record) => record.id)) }],
273
+ details: { results: records.map(resultOf) },
274
+ };
275
+ },
276
+ });
277
+
278
+ pi.registerTool({
279
+ name: "agents_status",
280
+ label: "Agents Status",
281
+ description: "Status of this session's sub-agents (from spawn_agents): id, state, elapsed time, turns, last activity. Background results are delivered automatically — use this to check progress, not to poll in a loop.",
282
+ promptSnippet: "agents_status: list sub-agents and their progress",
283
+ parameters: Type.Object({ ids: IdsParam }),
284
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
285
+ const { records, unknown } = manager.resolve(sessionOf(ctx), params.ids);
286
+ const text = renderAgentStatus(records) + (unknown.length ? `\nUnknown agent ids: ${unknown.join(", ")}` : "");
287
+ return {
288
+ content: [{ type: "text", text }],
289
+ details: { agents: records.map((record) => ({ agent_id: record.id, status: record.status, role: record.role, session_id: record.sessionId })) },
290
+ };
291
+ },
292
+ });
157
293
 
294
+ pi.registerTool({
295
+ name: "agents_wait",
296
+ label: "Wait For Agents",
297
+ description: "Block until the given background sub-agents finish (default: every unfinished one) and return their results. Optional timeout_minutes bounds the WAIT only — the agents keep running past it.",
298
+ promptSnippet: "agents_wait: block until background sub-agents finish",
299
+ parameters: Type.Object({
300
+ ids: IdsParam,
301
+ timeout_minutes: Type.Optional(Type.Number({ description: "Leave out (or 0) to wait until they finish; otherwise stop WAITING after this many minutes (the agents keep running)" })),
302
+ }),
303
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
304
+ const session = sessionOf(ctx);
305
+ const explicit = Array.isArray(params.ids) && params.ids.length > 0 && !params.ids.includes("all");
306
+ const { records: resolved, unknown } = manager.resolve(session, params.ids);
307
+ // Default: what is still out or not yet read.
308
+ const records = explicit ? resolved : resolved.filter((record) => !record.result || !record.delivered);
309
+ if (records.length === 0 && unknown.length === 0) {
310
+ return { content: [{ type: "text", text: "No background sub-agents to wait for." }], details: { results: [] } };
311
+ }
312
+ onUpdate?.({ content: [{ type: "text", text: `agents_wait: waiting for ${records.filter((record) => !record.result).length} sub-agent(s)` }], details: {} });
313
+ await manager.wait(records, {
314
+ signal,
315
+ ...(typeof params.timeout_minutes === "number" && params.timeout_minutes > 0 ? { timeoutMs: params.timeout_minutes * 60_000 } : {}),
316
+ });
158
317
  return {
159
- content: [{ type: "text", text: renderChildResults(results) }],
160
- details: { results },
318
+ content: [{ type: "text", text: renderSome(records, unknown) }],
319
+ details: { results: records.filter((record) => record.result).map(resultOf) },
161
320
  };
162
321
  },
163
322
  });
164
323
 
324
+ pi.registerTool({
325
+ name: "agents_result",
326
+ label: "Agent Result",
327
+ description: "The full result of one sub-agent by id (or its status while it still runs).",
328
+ promptSnippet: "agents_result: fetch a finished sub-agent's result",
329
+ parameters: Type.Object({ id: Type.String({ description: 'Agent id from spawn_agents (e.g. "a3")' }) }),
330
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
331
+ const { records, unknown } = manager.resolve(sessionOf(ctx), [params.id]);
332
+ records.filter((record) => record.result).forEach((record) => { record.delivered = true; });
333
+ return {
334
+ content: [{ type: "text", text: renderSome(records, unknown) }],
335
+ details: { results: records.filter((record) => record.result).map(resultOf) },
336
+ };
337
+ },
338
+ });
339
+
340
+ pi.registerTool({
341
+ name: "agents_cancel",
342
+ label: "Cancel Agents",
343
+ description: 'Stop running sub-agents by id ("all" for every one of this session); returns what each had produced so far.',
344
+ promptSnippet: "agents_cancel: stop running sub-agents",
345
+ parameters: Type.Object({
346
+ ids: Type.Array(Type.String(), { description: 'Agent ids to stop, or ["all"]', minItems: 1 }),
347
+ }),
348
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
349
+ const { records, unknown } = manager.resolve(sessionOf(ctx), params.ids);
350
+ const live = records.filter((record) => !record.result);
351
+ await manager.cancel(live, "cancelled", { delivered: true });
352
+ return {
353
+ content: [{ type: "text", text: live.length ? renderSome(live, unknown) : `Nothing to cancel${unknown.length ? ` (unknown ids: ${unknown.join(", ")})` : ""}.` }],
354
+ details: { results: live.map(resultOf) },
355
+ };
356
+ },
357
+ });
358
+
359
+ pi.on("session_start", (_event: any, ctx: any) => {
360
+ sessionOf(ctx);
361
+ });
362
+
363
+ pi.on("agent_start", (_event: any, ctx: any) => {
364
+ sessionOf(ctx);
365
+ busy = true;
366
+ });
367
+
368
+ /**
369
+ * One-shot runs (print/json mode) end when the agent settles: background
370
+ * children still out would be lost. The run waits for them here and
371
+ * continues with their results (a new run nested in this settle, whose
372
+ * own settle resumes this one when everything is done).
373
+ */
374
+ let resumeOuter: (() => void) | null = null;
375
+ pi.on("agent_settled", async (_event: any, ctx: any) => {
376
+ busy = false;
377
+ const session = sessionOf(ctx);
378
+ if (!oneShot) {
379
+ // Anything that finished right as the run ended.
380
+ deliver();
381
+ return;
382
+ }
383
+ const outer = resumeOuter;
384
+ resumeOuter = null;
385
+ try {
386
+ for (;;) {
387
+ if (typeof ctx?.isIdle === "function" && !ctx.isIdle()) {
388
+ // A run started (a delivery): its settle resumes this one.
389
+ await new Promise<void>((resolve) => { resumeOuter = resolve; });
390
+ break;
391
+ }
392
+ if (!session || !manager.hasPending(session)) break;
393
+ const groups = manager.takeDeliverable(session);
394
+ if (groups.length === 0) {
395
+ await manager.changed();
396
+ continue;
397
+ }
398
+ busy = true;
399
+ const records = groups.flatMap((group) => group.records);
400
+ send(records);
401
+ if (typeof ctx?.isIdle === "function" && ctx.isIdle()) busy = false;
402
+ }
403
+ } finally {
404
+ outer?.();
405
+ }
406
+ });
407
+
408
+ pi.on("session_shutdown", async () => {
409
+ // Children the session ends under are stopped (the collector, when it
410
+ // runs, already did this before its last capture).
411
+ await manager.interruptAll().catch(() => undefined);
412
+ });
413
+
165
414
  pi.registerCommand("agents", {
166
- description: "List this session's subagents (running and finished)",
167
- handler: async (_args: string, ctx: any) => {
168
- const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
169
- const entries = registry.get(sessionId) ?? [];
170
- if (entries.length === 0) {
415
+ description: "List this session's subagents (running and finished); /agents cancel <id|all> stops running ones",
416
+ handler: async (args: string, ctx: any) => {
417
+ const session = sessionOf(ctx);
418
+ const words = String(args ?? "").trim().split(/\s+/).filter(Boolean);
419
+ if (words[0] === "cancel") {
420
+ const { records } = manager.resolve(session, words.slice(1).length ? words.slice(1) : ["all"]);
421
+ const live = records.filter((record) => !record.result);
422
+ await manager.cancel(live);
423
+ ctx?.ui?.notify?.(live.length ? `Cancelled ${live.map((record) => record.id).join(", ")}.` : "No running subagents to cancel.");
424
+ return;
425
+ }
426
+ const records = manager.list(session);
427
+ if (records.length === 0) {
171
428
  ctx?.ui?.notify?.("No subagents have been spawned in this session yet.");
172
429
  return;
173
430
  }
174
- const lines = entries.map((entry, index) => {
175
- const minutes = Math.round(((entry.durationMs ?? Date.now() - entry.startedAt) / 60_000) * 10) / 10;
176
- const task = entry.task.length > 60 ? `${entry.task.slice(0, 59)}…` : entry.task;
177
- return ` ${index + 1}. [${entry.status}] ${entry.role} (${minutes} min) — ${task}`;
178
- });
179
- ctx?.ui?.notify?.(`Subagents of this session:\n${lines.join("\n")}`);
431
+ ctx?.ui?.notify?.(renderAgentStatus(records));
180
432
  },
181
433
  });
182
434
  }
@@ -57,6 +57,7 @@ import {
57
57
  type PiEntry,
58
58
  } from "./pi-engine";
59
59
  import { sharedRefresher } from "./refresh";
60
+ import { AGENTS_HOOK, type AgentsHook } from "./agents-lib";
60
61
 
61
62
  /** info.agent of the root session's messages (a sub-agent's is its role). */
62
63
  export const ROOT_AGENT = "omnirush-cli";
@@ -403,6 +404,32 @@ export default function (pi: any) {
403
404
  return session;
404
405
  };
405
406
 
407
+ /**
408
+ * The sub-agent tree's messages since their checkpoints. Background
409
+ * sub-agents keep running past the turn that started them: each later
410
+ * settle (and the session's end) records what they did since.
411
+ */
412
+ const captureChildren = async (state: NonNullable<typeof session>, room: (bytes: number) => void): Promise<void> => {
413
+ const { id } = state;
414
+ try {
415
+ await captureChildSessions({
416
+ collector,
417
+ rootSessionId: id,
418
+ parentSessionId: id,
419
+ parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
420
+ depth: 1,
421
+ sessionDirs: sessionDirs(state.ctx),
422
+ checkpoints: await collector.childCheckpoints(id),
423
+ known: new Set(await collector.childSessionIds(id)),
424
+ visited: new Set([id]),
425
+ seenFiles: state.seenFiles,
426
+ beforeRecord: room,
427
+ });
428
+ } catch (error) {
429
+ collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
430
+ }
431
+ };
432
+
406
433
  /**
407
434
  * Everything a settled run records, in the desktop observer's order:
408
435
  * messages, sub-agents, the idle event, the snapshot, the trace and the
@@ -444,23 +471,7 @@ export default function (pi: any) {
444
471
  }
445
472
  pending += bytes;
446
473
  };
447
- try {
448
- await captureChildSessions({
449
- collector,
450
- rootSessionId: id,
451
- parentSessionId: id,
452
- parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
453
- depth: 1,
454
- sessionDirs: sessionDirs(state.ctx),
455
- checkpoints: await collector.childCheckpoints(id),
456
- known: new Set(await collector.childSessionIds(id)),
457
- visited: new Set([id]),
458
- seenFiles: state.seenFiles,
459
- beforeRecord: room,
460
- });
461
- } catch (error) {
462
- collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
463
- }
474
+ await captureChildren(state, room);
464
475
  room(newest.bytes);
465
476
  collector.recordTrace(id, "session.idle", { status });
466
477
  // The turn snapshot runs first so the artifacts it discovers are part of
@@ -485,11 +496,34 @@ export default function (pi: any) {
485
496
  * settled too, so they reach the trace.
486
497
  */
487
498
  const settleRemaining = async (): Promise<void> => {
499
+ // Background sub-agents still running are stopped first (their session
500
+ // files are complete once they exited) and marked interrupted.
501
+ const hook = (globalThis as any)[AGENTS_HOOK] as AgentsHook | undefined;
502
+ const followed = session;
503
+ const interrupted = followed?.started && hook ? await hook.interruptAll(followed.id).catch(() => []) : [];
488
504
  await settleRunning("interrupted");
489
505
  const state = session;
490
506
  if (!state?.started) return;
507
+ for (const child of interrupted) {
508
+ collector.recordTrace(state.id, "session.child_interrupted", { child_session_id: child.sessionId, agent: child.role });
509
+ }
491
510
  const lastId = messagesOf(state).at(-1)?.info.id;
492
- if (typeof lastId !== "string" || lastId === state.lastMessageId) return;
511
+ if (typeof lastId !== "string" || lastId === state.lastMessageId) {
512
+ // No new message of its own: what its sub-agents did since the last settle.
513
+ settling = settling.then(async () => {
514
+ let pending = 0;
515
+ await captureChildren(state, (bytes) => {
516
+ if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
517
+ collector.flushTrace(state.id);
518
+ pending = 0;
519
+ }
520
+ pending += bytes;
521
+ });
522
+ collector.flushTrace(state.id);
523
+ }).catch(() => undefined);
524
+ await settling;
525
+ return;
526
+ }
493
527
  settling = settling.then(() => settle(state, "idle")).catch(() => undefined);
494
528
  await settling;
495
529
  };
@@ -591,6 +625,13 @@ export default function (pi: any) {
591
625
  archive.turnFollowed(state.id);
592
626
  });
593
627
 
628
+ // A run that no prompt started (a background sub-agent's results
629
+ // delivered as a message start one): it settles like any other.
630
+ pi.on("agent_start", (_event: any, ctx: any) => {
631
+ const state = follow(ctx, false);
632
+ if (state?.started) state.running = true;
633
+ });
634
+
594
635
  pi.on("tool_execution_start", (event: any) => {
595
636
  if (session && typeof event?.toolCallId === "string") session.toolStarts.set(event.toolCallId, Date.now());
596
637
  });