omnirush 0.8.4 → 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.
@@ -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,28 @@ 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
+ agentStatusSummary,
44
+ type AgentRunner,
45
+ type AgentsHook,
46
+ type AgentRecord,
47
+ childInactivityMs,
48
+ renderAgentStatus,
30
49
  renderChildResults,
50
+ renderDelivery,
51
+ modelSelectionFromContext,
31
52
  runChildAgent,
32
53
  type ChildResult,
33
54
  type ChildTask,
34
55
  } from "./agents-lib";
35
56
 
57
+ /** A timeout_minutes below this is taken for a placeholder, not a limit. */
58
+ const MIN_TIMEOUT_MINUTES = 1;
59
+
60
+ /** customType of the message that brings background results back. */
61
+ export const DELIVERY_TYPE = "omnirush-agents-result";
62
+
36
63
  const SpawnAgentsParams = Type.Object({
37
64
  tasks: Type.Array(
38
65
  Type.Object({
@@ -41,33 +68,127 @@ const SpawnAgentsParams = Type.Object({
41
68
  }),
42
69
  task: Type.String({ description: "Self-contained task description for the child agent (it sees ONLY this, not your conversation)" }),
43
70
  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',
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',
45
72
  })),
46
73
  }),
47
74
  { description: "Tasks to delegate; they all run in parallel", minItems: 1 },
48
75
  ),
76
+ wait: Type.Optional(Type.Boolean({
77
+ 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)",
78
+ })),
79
+ notify: Type.Optional(StringEnum(["batch", "each"] as const, {
80
+ description: 'Background dispatch only: deliver one message when the whole batch finished ("batch", default) or one per child as each finishes ("each")',
81
+ })),
49
82
  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,
83
+ 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
84
  })),
54
85
  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,
86
+ 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
87
  })),
58
88
  });
59
89
 
60
- interface AgentRegistryEntry {
61
- role: string;
62
- task: string;
63
- status: "running" | ChildResult["status"];
64
- startedAt: number;
65
- durationMs: number | null;
90
+ const IdsParam = Type.Optional(Type.Array(Type.String(), {
91
+ description: 'Agent ids from spawn_agents (e.g. "a3"); omit (or "all") for every agent of this session',
92
+ }));
93
+
94
+ function resultOf(record: AgentRecord): ChildResult & { agent_id: string } {
95
+ return { ...(record.result as ChildResult), agent_id: record.id };
66
96
  }
67
97
 
68
- export default function (pi: ExtensionAPI) {
69
- /** session id -> subagent records (this process only). */
70
- const registry = new Map<string, AgentRegistryEntry[]>();
98
+ /** Results of finished records plus a status line for the others. */
99
+ function renderSome(records: AgentRecord[], unknown: string[] = []): string {
100
+ const finished = records.filter((record) => record.result);
101
+ const open = records.filter((record) => !record.result);
102
+ const parts: string[] = [];
103
+ if (finished.length > 0) parts.push(renderChildResults(finished.map((record) => record.result as ChildResult), finished.map((record) => record.id)));
104
+ if (open.length > 0) parts.push(`Open sub-agents:\n${renderAgentStatus(open)}`);
105
+ if (unknown.length > 0) parts.push(`Unknown agent ids: ${unknown.join(", ")}`);
106
+ return parts.join("\n\n") || "No matching sub-agents.";
107
+ }
108
+
109
+ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {}) {
110
+ 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 = "";
115
+ let lastContext: any = null;
116
+ 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
+
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
+
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
+ const manager = new AgentManager({
158
+ run: options.run ?? ((task, options) => runChildAgent(task, {
159
+ cwd: options.cwd ?? cwd,
160
+ parentSessionId: options.parentSessionId,
161
+ sessionId: options.sessionId,
162
+ signal: options.signal,
163
+ onActivity: options.onActivity,
164
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
165
+ })),
166
+ // After the settle's own bookkeeping (waiters took theirs).
167
+ onDeliverable: () => queueMicrotask(deliver),
168
+ onChanged: scheduleStatus,
169
+ });
170
+
171
+ const hook: AgentsHook = {
172
+ async interruptAll(parentSessionId?: string) {
173
+ const stopped = await manager.interruptAll(parentSessionId);
174
+ return stopped.map((record) => ({ sessionId: record.sessionId, id: record.id, role: record.role }));
175
+ },
176
+ };
177
+ (globalThis as any)[AGENTS_HOOK] = hook;
178
+
179
+ const sessionOf = (ctx: any): string => {
180
+ const id = String(ctx?.sessionManager?.getSessionId?.() ?? "");
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;
188
+ if (ctx?.cwd) cwd = String(ctx.cwd);
189
+ if (ctx?.mode === "print" || ctx?.mode === "json") oneShot = true;
190
+ return id;
191
+ };
71
192
 
72
193
  pi.registerTool({
73
194
  name: "spawn_agents",
@@ -76,107 +197,286 @@ export default function (pi: ExtensionAPI) {
76
197
  "Delegate tasks to parallel subagents with isolated context windows.",
77
198
  "Each child is a headless agent run in this workspace with a role preset:",
78
199
  "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).`,
200
+ "Children run in parallel — every task at once unless max_parallel throttles it — and have no time limit: they run until done",
201
+ `(timeout_minutes sets one explicitly${watchdogMinutes > 0 ? `; a child that prints nothing at all for ${watchdogMinutes}+ min is stopped as hung` : ""}).`,
202
+ "Default (wait true) blocks until all children finish and returns their results.",
203
+ "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.",
204
+ "Manage background agents with agents_status, agents_wait, agents_result and agents_cancel.",
80
205
  '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
206
  "Every task description must be SELF-CONTAINED: the child cannot see this conversation.",
82
207
  "Use for parallelizable work: broad code surveys, independent research questions, independent implementation chunks.",
83
208
  ].join(" "),
84
- promptSnippet: "spawn_agents: delegate parallel tasks to role-preset subagents",
209
+ promptSnippet: "spawn_agents: delegate parallel tasks to role-preset subagents (blocking, or wait:false for background work)",
85
210
  promptGuidelines: [
86
211
  "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.",
212
+ "Short fan-outs whose results you need right away (quick searches, lookups): the default blocking call.",
213
+ "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
214
  "Do not use spawn_agents for a single quick action — doing it yourself is cheaper.",
88
215
  ],
89
216
  parameters: SpawnAgentsParams,
90
217
 
91
218
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
219
+ const parentSessionId = sessionOf(ctx);
92
220
  const tasks: ChildTask[] = [];
93
221
  for (const raw of params.tasks ?? []) {
94
222
  if (!raw || typeof raw.task !== "string" || !raw.task.trim()) {
95
223
  throw new Error("invalid tasks: every entry needs a non-empty task string");
96
224
  }
97
- 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
- }
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() : "";
101
228
  tasks.push({
102
229
  role: raw.role,
103
230
  task: raw.task.trim(),
104
- ...(model ? { model } : {}),
231
+ ...((requestedModel || inheritedModel) ? { model: requestedModel || inheritedModel } : {}),
105
232
  });
106
233
  }
107
234
  if (tasks.length === 0) throw new Error("no tasks given");
108
- const parentSessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
109
235
  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
236
+ const workdir = ctx?.cwd ? String(ctx.cwd) : process.cwd();
237
+ // Only a deliberate limit counts: models fill optional numbers with
238
+ // their minimum (a 0.1-minute "limit" killed every child in 6 s).
239
+ const timeoutMs = typeof params.timeout_minutes === "number" && params.timeout_minutes >= MIN_TIMEOUT_MINUTES
240
+ ? params.timeout_minutes * 60_000
113
241
  : undefined;
114
- // No cap: every task runs at once unless the caller throttles.
115
- 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
116
246
  ? Math.floor(params.max_parallel)
117
- : tasks.length;
118
-
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
- });
247
+ : undefined;
248
+ const background = params.wait === false;
132
249
 
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,
250
+ const dispatched = manager.dispatch(parentSessionId, tasks, {
251
+ background,
138
252
  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,
253
+ notify: params.notify === "each" ? "each" : "batch",
254
+ ...(background ? {} : { signal }),
255
+ onSettled: (record) => {
256
+ if (background) return;
257
+ onUpdate?.({
258
+ content: [{ type: "text", text: `${agentStatusSummary(dispatched.records)} (latest: ${record.id} ${record.role})` }],
259
+ details: { results: dispatched.records.filter((candidate) => candidate.result).map(resultOf) },
145
260
  });
146
- const mark = marks[index];
147
- mark.status = result.status;
148
- mark.durationMs = result.durationMs;
149
- settled.push(result);
261
+ },
262
+ onChanged: () => {
263
+ if (background) return;
150
264
  onUpdate?.({
151
- content: [{ type: "text", text: `spawn_agents: ${settled.length}/${tasks.length} settled (latest: ${task.role})` }],
152
- details: { results: [...settled] },
265
+ content: [{ type: "text", text: agentStatusSummary(manager.list(parentSessionId)) }],
266
+ details: { results: manager.list(parentSessionId).filter((candidate) => candidate.result).map(resultOf) },
153
267
  });
154
- return result;
155
268
  },
156
- );
269
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
270
+ cwd: workdir,
271
+ });
272
+
273
+ 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}`);
275
+ return {
276
+ content: [{
277
+ type: "text",
278
+ text: [
279
+ `spawn_agents: dispatched ${dispatched.records.length} background sub-agent${dispatched.records.length === 1 ? "" : "s"} (batch ${dispatched.batch}):`,
280
+ ...lines,
281
+ "",
282
+ params.notify === "each"
283
+ ? "Keep working: each result arrives as a message in this conversation when that agent finishes."
284
+ : "Keep working: the results arrive as one message in this conversation when all of them finished.",
285
+ "agents_status shows progress; agents_wait blocks for them; agents_cancel stops them.",
286
+ ].join("\n"),
287
+ }],
288
+ details: {
289
+ background: true,
290
+ 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
+ })),
300
+ },
301
+ };
302
+ }
303
+
304
+ await dispatched.done;
305
+ const records = dispatched.records;
306
+ return {
307
+ content: [{ type: "text", text: renderChildResults(records.map((record) => record.result as ChildResult), records.map((record) => record.id)) }],
308
+ details: { results: records.map(resultOf) },
309
+ };
310
+ },
311
+ });
312
+
313
+ pi.registerTool({
314
+ name: "agents_status",
315
+ label: "Agents Status",
316
+ 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.",
317
+ promptSnippet: "agents_status: list sub-agents and their progress",
318
+ parameters: Type.Object({ ids: IdsParam }),
319
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
320
+ const { records, unknown } = manager.resolve(sessionOf(ctx), params.ids);
321
+ const text = renderAgentStatus(records) + (unknown.length ? `\nUnknown agent ids: ${unknown.join(", ")}` : "");
322
+ return {
323
+ content: [{ type: "text", text }],
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
+ },
338
+ };
339
+ },
340
+ });
341
+
342
+ pi.registerTool({
343
+ name: "agents_wait",
344
+ label: "Wait For Agents",
345
+ 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.",
346
+ promptSnippet: "agents_wait: block until background sub-agents finish",
347
+ parameters: Type.Object({
348
+ ids: IdsParam,
349
+ 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)" })),
350
+ }),
351
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
352
+ const session = sessionOf(ctx);
353
+ const explicit = Array.isArray(params.ids) && params.ids.length > 0 && !params.ids.includes("all");
354
+ const { records: resolved, unknown } = manager.resolve(session, params.ids);
355
+ // Default: what is still out or not yet read.
356
+ const records = explicit ? resolved : resolved.filter((record) => !record.result || !record.delivered);
357
+ if (records.length === 0 && unknown.length === 0) {
358
+ return { content: [{ type: "text", text: "No background sub-agents to wait for." }], details: { results: [] } };
359
+ }
360
+ onUpdate?.({ content: [{ type: "text", text: `agents_wait: waiting for ${records.filter((record) => !record.result).length} sub-agent(s)` }], details: {} });
361
+ await manager.wait(records, {
362
+ signal,
363
+ ...(typeof params.timeout_minutes === "number" && params.timeout_minutes > 0 ? { timeoutMs: params.timeout_minutes * 60_000 } : {}),
364
+ });
365
+ return {
366
+ content: [{ type: "text", text: renderSome(records, unknown) }],
367
+ details: { results: records.filter((record) => record.result).map(resultOf) },
368
+ };
369
+ },
370
+ });
157
371
 
372
+ pi.registerTool({
373
+ name: "agents_result",
374
+ label: "Agent Result",
375
+ description: "The full result of one sub-agent by id (or its status while it still runs).",
376
+ promptSnippet: "agents_result: fetch a finished sub-agent's result",
377
+ parameters: Type.Object({ id: Type.String({ description: 'Agent id from spawn_agents (e.g. "a3")' }) }),
378
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
379
+ const { records, unknown } = manager.resolve(sessionOf(ctx), [params.id]);
380
+ records.filter((record) => record.result).forEach((record) => { record.delivered = true; });
158
381
  return {
159
- content: [{ type: "text", text: renderChildResults(results) }],
160
- details: { results },
382
+ content: [{ type: "text", text: renderSome(records, unknown) }],
383
+ details: { results: records.filter((record) => record.result).map(resultOf) },
161
384
  };
162
385
  },
163
386
  });
164
387
 
388
+ pi.registerTool({
389
+ name: "agents_cancel",
390
+ label: "Cancel Agents",
391
+ description: 'Stop running sub-agents by id ("all" for every one of this session); returns what each had produced so far.',
392
+ promptSnippet: "agents_cancel: stop running sub-agents",
393
+ parameters: Type.Object({
394
+ ids: Type.Array(Type.String(), { description: 'Agent ids to stop, or ["all"]', minItems: 1 }),
395
+ }),
396
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
397
+ const { records, unknown } = manager.resolve(sessionOf(ctx), params.ids);
398
+ const live = records.filter((record) => !record.result);
399
+ await manager.cancel(live, "cancelled", { delivered: true });
400
+ return {
401
+ content: [{ type: "text", text: live.length ? renderSome(live, unknown) : `Nothing to cancel${unknown.length ? ` (unknown ids: ${unknown.join(", ")})` : ""}.` }],
402
+ details: { results: live.map(resultOf) },
403
+ };
404
+ },
405
+ });
406
+
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
+ pi.on("session_shutdown", async () => {
457
+ // Children the session ends under are stopped (the collector, when it
458
+ // runs, already did this before its last capture).
459
+ await manager.interruptAll().catch(() => undefined);
460
+ });
461
+
165
462
  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) {
463
+ description: "List this session's subagents (running and finished); /agents cancel <id|all> stops running ones",
464
+ handler: async (args: string, ctx: any) => {
465
+ const session = sessionOf(ctx);
466
+ const words = String(args ?? "").trim().split(/\s+/).filter(Boolean);
467
+ if (words[0] === "cancel") {
468
+ const { records } = manager.resolve(session, words.slice(1).length ? words.slice(1) : ["all"]);
469
+ const live = records.filter((record) => !record.result);
470
+ await manager.cancel(live);
471
+ ctx?.ui?.notify?.(live.length ? `Cancelled ${live.map((record) => record.id).join(", ")}.` : "No running subagents to cancel.");
472
+ return;
473
+ }
474
+ const records = manager.list(session);
475
+ if (records.length === 0) {
171
476
  ctx?.ui?.notify?.("No subagents have been spawned in this session yet.");
172
477
  return;
173
478
  }
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")}`);
479
+ ctx?.ui?.notify?.(renderAgentStatus(records));
180
480
  },
181
481
  });
182
482
  }