@arhen/pi-core-subagent 1.3.65 → 1.3.67

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.
package/src/manager.ts CHANGED
@@ -2,13 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync
2
2
  import { rename, rm, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join, relative, sep } from "node:path";
4
4
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
- import {
6
- type Api,
7
- type AssistantMessage,
8
- clampThinkingLevel,
9
- getSupportedThinkingLevels,
10
- type Model,
11
- } from "@earendil-works/pi-ai";
5
+ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
12
6
  import {
13
7
  type AgentSessionEvent,
14
8
  createAgentSession,
@@ -16,32 +10,39 @@ import {
16
10
  type ExtensionAPI,
17
11
  type ExtensionContext,
18
12
  getAgentDir,
19
- ModelRuntime,
20
13
  SessionManager,
21
14
  type ToolDefinition,
22
15
  } from "@earendil-works/pi-coding-agent";
23
- import type { TUI } from "@earendil-works/pi-tui";
24
16
  import { resolveAgentFile } from "./agentfile.ts";
25
17
  import { CHILD_TALK_TOOLS, type ChildHandlers, createChildTools } from "./child.ts";
26
- import {
27
- activitySnippet,
28
- describeCall,
29
- getFirstText,
30
- isTalking,
31
- makeAskNotice,
32
- makeNotice,
33
- makeTaskArtifactNotice,
34
- makeTaskNotice,
35
- SubagentsWidget,
36
- truncateText,
37
- } from "./format.ts";
18
+ import { readSubagentConfig, writeSubagentConfig } from "./config.ts";
19
+ import { LeaderDelivery } from "./delivery.ts";
20
+ import { activitySnippet, describeCall, getFirstText, truncateText } from "./format.ts";
38
21
  import { applyUpstream, resolveNeeds, runWaveScheduler } from "./graph.ts";
39
22
  import { createMailbox, type Mailbox } from "./mailbox.ts";
40
23
  import { chooseModel, resolveChildModel } from "./models.ts";
41
- import { artifactFingerprint, outcomeFingerprint, textFingerprint } from "./notification-state.ts";
24
+ import { textFingerprint } from "./notification-state.ts";
25
+ import {
26
+ aggregateUsage,
27
+ classifyFailure,
28
+ cloneRun,
29
+ emptyUsage,
30
+ failureError,
31
+ lastAssistantFailure,
32
+ updateUsageFromMessage,
33
+ } from "./outcome.ts";
34
+ import { RunWidget } from "./run-widget.ts";
35
+ import {
36
+ clampResumeThinking,
37
+ codemodeFactories,
38
+ createChildModelRuntime,
39
+ ensureUsableModel,
40
+ validateThinking,
41
+ } from "./runtime.ts";
42
42
  import type { SubagentParamsShape, TaskInput } from "./schemas.ts";
43
43
  import {
44
44
  MAX_TASKS,
45
+ type ParkedMsg,
45
46
  type PendingReply,
46
47
  type RunDetails,
47
48
  type RunMode,
@@ -50,7 +51,6 @@ import {
50
51
  type TaskSnapshot,
51
52
  type TaskStatus,
52
53
  TERMINAL,
53
- type UsageStats,
54
54
  } from "./types.ts";
55
55
  import {
56
56
  attachWorktree,
@@ -64,8 +64,9 @@ import {
64
64
  type Worktree,
65
65
  } from "./worktree.ts";
66
66
 
67
- /** Legacy callers import the resolver from here; its owner is `models.ts`. */
68
- export { resolveChildModel };
67
+ export type { ParkedMsg };
68
+ /** Legacy callers import these from here; their owners are `models.ts`, `outcome.ts`, `runtime.ts`. */
69
+ export { clampResumeThinking, classifyFailure, cloneRun, ensureUsableModel, resolveChildModel, validateThinking };
69
70
 
70
71
  export const DEFAULT_CONCURRENCY = 3;
71
72
  export const MAX_CONCURRENCY = 8;
@@ -77,51 +78,11 @@ const READONLY_TOOLS = ["read", "grep", "find", "ls", "codemode"];
77
78
  const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write", "codemode"];
78
79
  const WRITE_CAPABLE = ["bash", "edit", "write"];
79
80
  const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
80
- const WIDGET_THROTTLE_MS = 150;
81
- /** A submitted notice missing from the canonical context this long is treated as lost. */
82
- const RECEIPT_GRACE_MS = 3_000;
83
- /** Minimum wait before retrying a failed submission, so one flush cannot burn every attempt. */
84
- const RETRY_BACKOFF_MS = 1_000;
85
- /** Total submission attempts before a notice is declared lost. */
86
- const MAX_DELIVERY_ATTEMPTS = 3;
87
81
 
88
82
  function newId(prefix: string): string {
89
83
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
90
84
  }
91
85
 
92
- type ChildExtensionFactories = ConstructorParameters<typeof DefaultResourceLoader>[0]["extensionFactories"];
93
-
94
- /**
95
- * Children run with `noExtensions`, so they get none of the configured extensions — codemode is the
96
- * one exception, because it is how a child batches tool calls. `createCodemodeExtension()` is the
97
- * supported factory (pi >= 1.0); hosts that do not export it simply give children no codemode.
98
- */
99
- async function codemodeFactories(): Promise<ChildExtensionFactories> {
100
- try {
101
- const host = (await import("@earendil-works/pi-coding-agent")) as unknown as {
102
- createCodemodeExtension?: () => unknown;
103
- };
104
- const factory = host.createCodemodeExtension?.();
105
- return factory ? ([factory] as ChildExtensionFactories) : [];
106
- } catch {
107
- return [];
108
- }
109
- }
110
- function emptyUsage(): UsageStats {
111
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
112
- }
113
- function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
114
- const total = emptyUsage();
115
- for (const task of tasks) {
116
- total.input += task.usage.input;
117
- total.output += task.usage.output;
118
- total.cacheRead += task.usage.cacheRead;
119
- total.cacheWrite += task.usage.cacheWrite;
120
- total.cost += task.usage.cost;
121
- total.turns += task.usage.turns;
122
- }
123
- return total;
124
- }
125
86
  function safeRealPath(path: string): string {
126
87
  try {
127
88
  return realpathSync(path);
@@ -136,150 +97,6 @@ function getParentSessionFile(ctx: ExtensionContext): string | undefined {
136
97
  return undefined;
137
98
  }
138
99
  }
139
- export function classifyFailure(
140
- stopReason: string | undefined,
141
- errorMessage?: string,
142
- ): { status: "failed" | "aborted"; message: string } | undefined {
143
- if (!stopReason || stopReason === "stop" || stopReason === "end") return undefined;
144
- if (stopReason === "aborted") return { status: "aborted", message: errorMessage || "Subagent was aborted." };
145
- return { status: "failed", message: errorMessage || `Subagent ended with stopReason "${stopReason}".` };
146
- }
147
- function lastAssistantFailure(
148
- messages: AssistantMessage[] | undefined,
149
- ): { status: "failed" | "aborted"; message: string } | undefined {
150
- for (const message of [...(messages ?? [])].reverse()) {
151
- if (message?.role !== "assistant") continue;
152
- return classifyFailure(message.stopReason, message.errorMessage);
153
- }
154
- return undefined;
155
- }
156
- function failureError(failure: { status: "failed" | "aborted"; message: string }): Error {
157
- const error = new Error(failure.message);
158
- (error as Error & { subagentStatus?: string }).subagentStatus = failure.status;
159
- return error;
160
- }
161
- function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage): void {
162
- if (message?.role !== "assistant") return;
163
- task.usage.turns += 1;
164
- const usage = message.usage;
165
- if (!usage) return;
166
- task.usage.input += usage.input ?? 0;
167
- task.usage.output += usage.output ?? 0;
168
- task.usage.cacheRead += usage.cacheRead ?? 0;
169
- task.usage.cacheWrite += usage.cacheWrite ?? 0;
170
- task.usage.cost += usage.cost?.total ?? 0;
171
- if (message.model && !task.model) task.model = message.model;
172
- }
173
- export function cloneRun(run: RunSnapshot): RunSnapshot {
174
- return JSON.parse(JSON.stringify(run)) as RunSnapshot;
175
- }
176
- /**
177
- * A resumed task keeps its stored thinking level, clamped to what the target model accepts — a
178
- * resume that swaps model must not fail on an effort the new model does not define.
179
- */
180
- export function clampResumeThinking(
181
- model: Model<Api> | undefined,
182
- thinking: ThinkingLevel | undefined,
183
- ): ThinkingLevel | undefined {
184
- if (!thinking || !model) return thinking;
185
- return clampThinkingLevel(model, thinking) as ThinkingLevel;
186
- }
187
-
188
- const PROBE_THINKING_LEVELS: ThinkingLevel[] = ["low", "minimal", "medium", "high", "xhigh", "max"];
189
-
190
- /**
191
- * Thinking level for the usability probe: exactly what the child session will send — the clamped
192
- * requested level, or the cheapest the model accepts when none was requested. Probing without a
193
- * level makes adaptive-thinking providers reject the request (9router claude models answer
194
- * "thinking.type.disabled is not supported"), which used to make every preflight fail and fall
195
- * back to the session model.
196
- */
197
- function probeThinking(model: Model<Api>, thinking?: string): ThinkingLevel | undefined {
198
- if (!model.reasoning) return undefined;
199
- const supported = getSupportedThinkingLevels(model);
200
- if (thinking && thinking !== "off") return clampThinkingLevel(model, thinking as ThinkingLevel);
201
- // the child clamps an unsupported "off" up to its cheapest level, so probe that instead
202
- if (thinking === "off") return supported.includes("off") ? undefined : supported[0];
203
- return PROBE_THINKING_LEVELS.find((level) => supported.includes(level));
204
- }
205
-
206
- async function probeModel(
207
- ctx: ExtensionContext,
208
- model: Model<Api>,
209
- signal: AbortSignal | undefined,
210
- thinking?: string,
211
- ): Promise<string | undefined> {
212
- try {
213
- const reasoningEffort = probeThinking(model, thinking);
214
- const reply = await ctx.modelRegistry.complete(
215
- model,
216
- { messages: [{ role: "user", content: "ping", timestamp: Date.now() }] },
217
- { maxTokens: 16, signal, ...(reasoningEffort ? { reasoningEffort } : {}) },
218
- );
219
- return reply.stopReason === "error" ? (reply.errorMessage ?? "provider returned an error") : undefined;
220
- } catch (err) {
221
- return err instanceof Error ? err.message : String(err);
222
- }
223
- }
224
-
225
- export async function ensureUsableModel(
226
- ctx: ExtensionContext,
227
- model: Model<Api> | undefined,
228
- signal: AbortSignal | undefined,
229
- thinking?: string,
230
- ): Promise<{ model: Model<Api> | undefined; note?: string }> {
231
- const session = ctx.model;
232
- if (!model || !ctx.modelRegistry) return { model };
233
- if (session && model.provider === session.provider && model.id === session.id) return { model };
234
- const error = await probeModel(ctx, model, signal, thinking);
235
- if (!error) return { model };
236
- if (model.provider === "opencode-go" && /MissingSessionID|x-opencode-session/i.test(error)) {
237
- // ponytail: opencode-go rejects stateless probes but accepts AgentSession requests, which add the session header.
238
- // Upgrade path: remove this exception when modelRegistry.complete can carry AgentSession request transforms.
239
- return { model, note: `preflight unavailable (${error}); child session will validate the model` };
240
- }
241
- if (!session) throw new Error(`Model ${model.provider}/${model.id} is unusable: ${error}`);
242
- return {
243
- model: session,
244
- note: `${model.provider}/${model.id} failed preflight (${error}); using session model ${session.provider}/${session.id}`,
245
- };
246
- }
247
-
248
- async function createChildModelRuntime(ctx: ExtensionContext) {
249
- const ids = ctx.modelRegistry.getRegisteredProviderIds?.() ?? [];
250
- if (ids.length === 0) return undefined;
251
- const agentDir = getAgentDir();
252
- const runtime = await ModelRuntime.create({
253
- authPath: join(agentDir, "auth.json"),
254
- modelsPath: join(agentDir, "models.json"),
255
- });
256
- for (const id of ids) {
257
- const native = ctx.modelRegistry.getRegisteredNativeProvider?.(id);
258
- if (native) {
259
- runtime.registerNativeProvider(native);
260
- continue;
261
- }
262
- const config = ctx.modelRegistry.getRegisteredProviderConfig?.(id);
263
- if (config) runtime.registerProvider(id, config);
264
- }
265
- await runtime.refresh({ allowNetwork: false });
266
- return runtime;
267
- }
268
-
269
- export function validateThinking(model: Model<Api> | undefined, level: string | undefined): void {
270
- if (!level || level === "off") return;
271
- if (!model) return;
272
- const map = model.thinkingLevelMap;
273
- if (map && level in map && map[level as keyof typeof map] === null) {
274
- const supported = Object.keys(map).filter((k) => map[k as keyof typeof map] !== null);
275
- throw new Error(
276
- `Thinking level "${level}" is not supported by ${model.provider}/${model.id}. Supported: ${supported.length ? supported.join(" | ") : 'none — use thinking: "off"'}.`,
277
- );
278
- }
279
- if (!model.reasoning) {
280
- throw new Error(`Model ${model.provider}/${model.id} does not support thinking. Use thinking: "off".`);
281
- }
282
- }
283
100
 
284
101
  interface ResumeInput {
285
102
  sessionFile: string;
@@ -293,43 +110,6 @@ interface ChildEventState {
293
110
  childEndResolve?: () => void;
294
111
  }
295
112
 
296
- export interface ParkedMsg {
297
- kind: "ask" | "notify" | "done";
298
- taskId: string;
299
- agent: string;
300
- text: string;
301
- final?: boolean;
302
- }
303
-
304
- interface LeaderMessageReceipt {
305
- runId: string;
306
- taskIds: string[];
307
- kind: "update" | "final" | "terminal";
308
- deliverAs: "steer" | "followUp";
309
- delivered: boolean;
310
- submitted: boolean;
311
- lost: boolean;
312
- submittedAt: number;
313
- attempts: number;
314
- /** Agent runs started when this receipt was submitted; a later run is needed to prove loss. */
315
- submittedRunCount: number;
316
- }
317
-
318
- interface OutboxEntry {
319
- runId: string;
320
- taskIds: string[];
321
- kind: "update" | "final" | "terminal";
322
- deliverAs: "steer" | "followUp";
323
- attempts?: number;
324
- attemptedAt?: number;
325
- }
326
-
327
- interface TerminalNotice {
328
- body: string;
329
- fingerprint: string;
330
- delivery: "pending" | "received" | "lost";
331
- }
332
-
333
113
  export class SubagentManager {
334
114
  private runs = new Map<string, RunSnapshot>();
335
115
  private settlers = new Map<string, true>();
@@ -342,42 +122,38 @@ export class SubagentManager {
342
122
  private mailboxes: Mailbox = createMailbox();
343
123
  private liveWorktrees = new Map<string, Worktree>();
344
124
  private runControllers = new Map<string, AbortController>();
345
- private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>();
346
- private widgetRuns: RunSnapshot[] = [];
125
+ private readonly widget = new RunWidget();
126
+ private readonly delivery: LeaderDelivery;
347
127
  private eventSeq = 0;
348
128
  private persistSeq = 0;
349
129
  private persistedSeq = 0;
350
130
  private persistChain: Promise<unknown> = Promise.resolve();
351
131
  private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
352
132
  private cleared = false;
353
- private leaderMessages = new Map<string, LeaderMessageReceipt>();
354
- private outbox = new Map<string, OutboxEntry>();
355
- private parkedBodies = new Map<string, Set<string>>();
356
- private agentRunCount = 0;
357
- private terminalNotices = new Map<string, TerminalNotice>();
358
- private awaitedTaskNotices = new Map<string, string>();
359
- private pendingTaskNotices = new Map<string, { run: RunSnapshot; task: TaskSnapshot }>();
360
- private pendingRunNotices = new Map<string, RunSnapshot>();
361
- private notificationRetries = new Set<string>();
362
- private notificationFlushTimer: ReturnType<typeof setTimeout> | undefined;
363
- private notificationContext: ExtensionContext | undefined;
364
133
 
365
134
  private autoLimit = false;
366
135
 
367
136
  turnActivity = false;
368
137
 
369
138
  constructor(private readonly pi: ExtensionAPI) {
139
+ this.delivery = new LeaderDelivery({
140
+ pi,
141
+ isCleared: () => this.cleared,
142
+ findRun: (runId) => this.runs.get(runId),
143
+ isLive: (key) => this.liveChildren.has(key),
144
+ persist: (ctx) => this.persist(ctx),
145
+ emit: (type, payload) => this.emit(type, payload),
146
+ collectParked: (runId, msg) => this.collectParked(runId, msg),
147
+ });
370
148
  try {
371
- const cfg = JSON.parse(readFileSync(join(getAgentDir(), "subagents-config.json"), "utf8"));
149
+ const cfg = readSubagentConfig();
372
150
  if (typeof cfg.autoLimit === "boolean") this.autoLimit = cfg.autoLimit;
373
151
  } catch {}
374
152
  }
375
153
 
376
154
  setAutoLimit(on: boolean): boolean {
377
155
  this.autoLimit = on;
378
- void writeFile(join(getAgentDir(), "subagents-config.json"), JSON.stringify({ autoLimit: on }, null, 2)).catch(
379
- () => {},
380
- );
156
+ void writeSubagentConfig({ autoLimit: on }).catch(() => {});
381
157
  return on;
382
158
  }
383
159
 
@@ -393,13 +169,24 @@ export class SubagentManager {
393
169
  }
394
170
 
395
171
  clearWidget(ctx?: ExtensionContext): void {
396
- this.widgetRuns = [];
397
- this.widgetTui = null;
398
- if (ctx?.hasUI) {
399
- try {
400
- ctx.ui.setWidget("subagents", undefined);
401
- } catch {}
402
- }
172
+ this.widget.clear(ctx);
173
+ }
174
+
175
+ /** An agent run started; a receipt can only be declared lost after one full later run. */
176
+ noteAgentStart(): void {
177
+ this.delivery.noteAgentStart();
178
+ }
179
+ markAwaitCoverage(run: RunSnapshot, coveredTaskIds?: ReadonlySet<string>): void {
180
+ this.delivery.markAwaitCoverage(run, coveredTaskIds);
181
+ }
182
+ confirmLeaderMessages(ctx: ExtensionContext): void {
183
+ this.delivery.confirmLeaderMessages(ctx);
184
+ }
185
+ flushPendingNotifications(ctx: ExtensionContext): void {
186
+ this.delivery.flushPendingNotifications(ctx);
187
+ }
188
+ scheduleNotificationFlush(ctx: ExtensionContext, delayMs?: number): void {
189
+ this.delivery.scheduleNotificationFlush(ctx, delayMs);
403
190
  }
404
191
 
405
192
  listRuns(): RunSnapshot[] {
@@ -432,17 +219,7 @@ export class SubagentManager {
432
219
  pending.resolve("(session ended — stop work immediately)");
433
220
  }
434
221
  this.parked.clear();
435
- this.leaderMessages.clear();
436
- this.outbox.clear();
437
- this.parkedBodies.clear();
438
- this.terminalNotices.clear();
439
- this.awaitedTaskNotices.clear();
440
- this.pendingTaskNotices.clear();
441
- this.pendingRunNotices.clear();
442
- this.notificationRetries.clear();
443
- if (this.notificationFlushTimer) clearTimeout(this.notificationFlushTimer);
444
- this.notificationFlushTimer = undefined;
445
- this.notificationContext = undefined;
222
+ this.delivery.reset();
446
223
 
447
224
  this.liveWorktrees.clear();
448
225
  this.runs.clear();
@@ -452,14 +229,7 @@ export class SubagentManager {
452
229
  this.runControllers.clear();
453
230
  this.cleared = true;
454
231
  this.mailboxes = createMailbox();
455
- this.widgetTui = null;
456
- if (this.pulseTimer) {
457
- clearTimeout(this.pulseTimer);
458
- this.pulseTimer = null;
459
- }
460
- for (const t of this.widgetTimers.values()) clearTimeout(t);
461
- this.widgetTimers.clear();
462
- this.widgetRuns = [];
232
+ this.widget.dispose();
463
233
  }
464
234
 
465
235
  async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
@@ -511,7 +281,7 @@ export class SubagentManager {
511
281
  }
512
282
  if (added > 0) {
513
283
  this.emit("subagent:runs-restored", { count: added });
514
- this.scheduleWidget(this.listRuns()[0], ctx);
284
+ this.widget.schedule(this.listRuns()[0], ctx);
515
285
  }
516
286
  }
517
287
  private persist(ctx: ExtensionContext): void {
@@ -542,698 +312,6 @@ export class SubagentManager {
542
312
  this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
543
313
  }
544
314
 
545
- private finalReportMatches(task: TaskSnapshot): boolean {
546
- const report = task.finalParentReport;
547
- if (!report || report.toolCalls !== task.toolCalls) return false;
548
- const fingerprint = task.finalTextFingerprint ?? textFingerprint(task.finalText ?? "");
549
- return report.fingerprint === fingerprint;
550
- }
551
-
552
- private hasArtifactDelta(task: TaskSnapshot): boolean {
553
- return artifactFingerprint(task) !== task.finalParentReport?.artifacts;
554
- }
555
-
556
- private leaderMessageText(content: unknown): string {
557
- return typeof content === "string"
558
- ? content
559
- : Array.isArray(content)
560
- ? content
561
- .filter((part) => part?.type === "text")
562
- .map((part) => part.text ?? "")
563
- .join("\n")
564
- : "";
565
- }
566
-
567
- /**
568
- * One pass over the finalized leader projection. Returns undefined when the pinned pi build
569
- * cannot project (never treat that as loss, or every receipt would be replayed).
570
- */
571
- private projectionBody(ctx: ExtensionContext): string | undefined {
572
- try {
573
- const manager = ctx.sessionManager as unknown as {
574
- buildSessionProjection?: () => { messages: readonly { role?: string; content?: unknown }[] };
575
- };
576
- if (typeof manager.buildSessionProjection !== "function") return undefined;
577
- const messages = manager.buildSessionProjection().messages;
578
- const parts: string[] = [];
579
- for (const message of messages) {
580
- if (message.role !== "user" && message.role !== "custom") continue;
581
- parts.push(this.leaderMessageText(message.content));
582
- }
583
- return parts.join("\n");
584
- } catch {
585
- return undefined;
586
- }
587
- }
588
-
589
- /** An agent run started; a receipt can only be declared lost after one full later run. */
590
- noteAgentStart(): void {
591
- this.agentRunCount += 1;
592
- }
593
-
594
- /** True when a terminal await already rendered this task's outcome. */
595
- private coveredByAwait(runId: string, taskId: string, fingerprint: string): boolean {
596
- return this.awaitedTaskNotices.get(`${runId}:${taskId}`) === fingerprint;
597
- }
598
-
599
- /** Record what a terminal await actually rendered, so held notices are dropped instead of echoed. */
600
- markAwaitCoverage(run: RunSnapshot, coveredTaskIds?: ReadonlySet<string>): void {
601
- for (const task of run.tasks) {
602
- if (!TERMINAL.includes(task.status)) continue;
603
- if (coveredTaskIds && !coveredTaskIds.has(task.id)) continue;
604
- const scope = `${run.id}:${task.id}`;
605
- this.awaitedTaskNotices.set(scope, outcomeFingerprint(task));
606
- this.pendingTaskNotices.delete(scope);
607
- }
608
- for (const [body, entry] of [...this.outbox]) {
609
- if (entry.runId !== run.id) continue;
610
- if (!this.outboxCovered(entry)) continue;
611
- const task = run.tasks.find((t) => t.id === entry.taskIds[0]);
612
- // An update is not part of the rendered summary; only terminal/aggregate entries are safe to drop.
613
- if (entry.kind === "update") continue;
614
- if (entry.kind === "final" && (!task || !this.finalReportMatches(task))) continue;
615
- this.dropOutbox(body, entry, "awaited");
616
- }
617
- }
618
-
619
- private outboxCovered(entry: OutboxEntry): boolean {
620
- const run = this.runs.get(entry.runId);
621
- if (!run) return false;
622
- return entry.taskIds.every((taskId) => {
623
- const task = run.tasks.find((t) => t.id === taskId);
624
- if (!task || !TERMINAL.includes(task.status)) return false;
625
- return this.coveredByAwait(entry.runId, taskId, outcomeFingerprint(task));
626
- });
627
- }
628
-
629
- private dropOutbox(body: string, entry: OutboxEntry, delivery: string): void {
630
- this.outbox.delete(body);
631
- const task = this.runs.get(entry.runId)?.tasks.find((t) => t.id === entry.taskIds[0]);
632
- if (entry.kind === "final" && task?.finalParentReport?.body === body) task.finalParentReport.delivery = "awaited";
633
- if (entry.kind === "terminal") {
634
- for (const taskId of entry.taskIds) {
635
- const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
636
- if (notice?.body === body) notice.delivery = "received";
637
- }
638
- }
639
- this.emit("subagent:notification", {
640
- runId: entry.runId,
641
- taskId: entry.taskIds[0],
642
- kind: "completed",
643
- body: "",
644
- suppressed: true,
645
- delivery,
646
- });
647
- }
648
-
649
- private enqueueNotification(body: string, entry: OutboxEntry, ctx?: ExtensionContext): void {
650
- this.outbox.set(body, entry);
651
- const flushContext = ctx ?? this.notificationContext;
652
- if (flushContext) this.scheduleNotificationFlush(flushContext);
653
- }
654
-
655
- private noteTerminal(runId: string, taskId: string, body: string, fingerprint: string): void {
656
- this.terminalNotices.set(`${runId}:${taskId}`, { body, fingerprint, delivery: "pending" });
657
- }
658
-
659
- private submitLeaderReport(
660
- run: RunSnapshot,
661
- task: TaskSnapshot,
662
- message: string,
663
- level: "info" | "warning" | "error",
664
- final: boolean,
665
- ctx: ExtensionContext,
666
- ): boolean {
667
- if (this.cleared) return false;
668
- this.notificationContext = ctx;
669
- const body = `[Subagent ${task.agent} (${task.id}, ${run.id})]${final ? " Final result:" : ""}${level === "info" ? "" : ` [${level}]`} ${message}`;
670
- const previous = this.leaderMessages.get(body);
671
- if ((previous && !previous.lost) || this.outbox.has(body)) {
672
- if (final) {
673
- task.finalParentReport = {
674
- body,
675
- message,
676
- toolCalls: task.toolCalls,
677
- fingerprint: textFingerprint(message),
678
- artifacts: artifactFingerprint(task),
679
- delivery: previous?.delivered ? "message" : "pending",
680
- };
681
- }
682
- return true;
683
- }
684
- if (previous?.lost) this.leaderMessages.delete(body);
685
- const report: TaskSnapshot["finalParentReport"] = final
686
- ? {
687
- body,
688
- message,
689
- toolCalls: task.toolCalls,
690
- fingerprint: textFingerprint(message),
691
- artifacts: artifactFingerprint(task),
692
- delivery: "pending",
693
- }
694
- : undefined;
695
- if (report) task.finalParentReport = report;
696
- this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message, final });
697
- if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message, final })) {
698
- let parked = this.parkedBodies.get(run.id);
699
- if (!parked) {
700
- parked = new Set();
701
- this.parkedBodies.set(run.id, parked);
702
- }
703
- if (parked.has(body)) return true;
704
- parked.add(body);
705
- task.notifiedParent = true;
706
- if (report) report.delivery = "parked";
707
- return true;
708
- }
709
- this.enqueueNotification(body, {
710
- runId: run.id,
711
- taskIds: [task.id],
712
- kind: final ? "final" : "update",
713
- deliverAs: "followUp",
714
- });
715
- return true;
716
- }
717
-
718
- private hasPendingSuccessMessages(run: RunSnapshot): boolean {
719
- return run.tasks.some((task) => {
720
- const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
721
- if (notice?.delivery !== "pending") return false;
722
- const receipt = this.leaderMessages.get(notice.body);
723
- return receipt ? !receipt.delivered && !receipt.lost : this.outbox.has(notice.body);
724
- });
725
- }
726
-
727
- confirmLeaderMessages(ctx: ExtensionContext): void {
728
- if (this.cleared) return;
729
- const projection = this.projectionBody(ctx);
730
- let changed = false;
731
- for (const [body, receipt] of [...this.leaderMessages]) {
732
- if (receipt.delivered || receipt.lost || projection === undefined || !projection.includes(body)) continue;
733
- receipt.delivered = true;
734
- changed = true;
735
- const run = this.runs.get(receipt.runId);
736
- for (const taskId of receipt.taskIds) {
737
- const task = run?.tasks.find((t) => t.id === taskId);
738
- if (!task) continue;
739
- task.notifiedParent = true;
740
- if (task.finalParentReport?.body === body) task.finalParentReport.delivery = "message";
741
- const key = `${receipt.runId}:${taskId}`;
742
- const notice = this.terminalNotices.get(key);
743
- if (notice?.body === body) notice.delivery = "received";
744
- const pending = this.pendingTaskNotices.get(key);
745
- if (pending && this.finalReportMatches(task)) {
746
- this.pendingTaskNotices.delete(key);
747
- if (this.hasArtifactDelta(task)) this.notifyTask(pending.run, task, "completed");
748
- }
749
- }
750
- if (run && !this.hasPendingSuccessMessages(run)) this.pendingRunNotices.delete(run.id);
751
- }
752
- if (changed) this.persist(ctx);
753
- for (const [body, receipt] of this.leaderMessages) {
754
- if (!receipt.delivered && !receipt.lost) continue;
755
- const run = this.runs.get(receipt.runId);
756
- const tasks = run?.tasks.filter((task) => receipt.taskIds.includes(task.id)) ?? [];
757
- if (
758
- tasks.length > 0 &&
759
- tasks.every((task) => TERMINAL.includes(task.status) && !this.liveChildren.has(`${receipt.runId}:${task.id}`))
760
- )
761
- this.leaderMessages.delete(body);
762
- }
763
- }
764
-
765
- /**
766
- * A submitted receipt that never appears in the canonical context after a grace period is lost.
767
- * The grace period keeps the check from racing Pi's input/auth/start hooks, which run before the
768
- * message is appended to the session and can be slow while the session still reports idle.
769
- */
770
- private markLostReceipts(ctx: ExtensionContext, existing: ReadonlySet<string>): void {
771
- const now = Date.now();
772
- const projection = this.projectionBody(ctx);
773
- if (projection === undefined) return;
774
- for (const [body, receipt] of [...this.leaderMessages]) {
775
- if (receipt.delivered || receipt.lost || !existing.has(body)) continue;
776
- if (this.outbox.has(body)) continue;
777
- if (now - receipt.submittedAt < RECEIPT_GRACE_MS) continue;
778
- // A later agent run proves the submitted prompt had its turn; a message that never runs
779
- // at all is caught by the longer bound. While a prompt is still queued or in preflight
780
- // (deferred settle, slow input/auth hooks) the session can report idle without the
781
- // message being appended yet, so the short window alone must not declare loss.
782
- const laterRun = this.agentRunCount > receipt.submittedRunCount;
783
- const longWait = now - receipt.submittedAt >= RECEIPT_GRACE_MS * 2;
784
- if (!laterRun && !longWait) continue;
785
- if (projection.includes(body)) continue;
786
- if (receipt.attempts < MAX_DELIVERY_ATTEMPTS) {
787
- this.leaderMessages.delete(body);
788
- this.outbox.set(body, {
789
- runId: receipt.runId,
790
- taskIds: receipt.taskIds,
791
- kind: receipt.kind,
792
- deliverAs: receipt.deliverAs,
793
- attempts: receipt.attempts,
794
- attemptedAt: now,
795
- });
796
- continue;
797
- }
798
- receipt.lost = true;
799
- for (const taskId of receipt.taskIds) {
800
- const key = `${receipt.runId}:${taskId}`;
801
- const notice = this.terminalNotices.get(key);
802
- if (notice?.body === body) notice.delivery = "lost";
803
- const task = this.runs.get(receipt.runId)?.tasks.find((t) => t.id === taskId);
804
- if (task?.finalParentReport?.body === body) task.finalParentReport.delivery = "lost";
805
- }
806
- this.emit("subagent:notification", {
807
- runId: receipt.runId,
808
- taskId: receipt.taskIds[0],
809
- kind: "completed",
810
- body,
811
- delivery: "lost",
812
- });
813
- // A lost final report must still reach the leader: per-task hold or a direct full fallback.
814
- if (receipt.kind === "final") {
815
- const run = this.runs.get(receipt.runId);
816
- for (const taskId of receipt.taskIds) {
817
- if (this.pendingTaskNotices.has(`${receipt.runId}:${taskId}`)) continue;
818
- const task = run?.tasks.find((t) => t.id === taskId);
819
- if (run && task) this.notifyTask(run, task, "completed");
820
- }
821
- }
822
- }
823
- }
824
-
825
- private flushOutbox(): void {
826
- for (const [body, entry] of [...this.outbox]) {
827
- if (this.outboxCovered(entry)) {
828
- this.dropOutbox(body, entry, "awaited");
829
- continue;
830
- }
831
- if (entry.attemptedAt && Date.now() - entry.attemptedAt < RETRY_BACKOFF_MS) continue;
832
- this.outbox.delete(body);
833
- try {
834
- this.pi.sendUserMessage(body, { deliverAs: entry.deliverAs });
835
- this.leaderMessages.set(body, {
836
- runId: entry.runId,
837
- taskIds: entry.taskIds,
838
- kind: entry.kind,
839
- deliverAs: entry.deliverAs,
840
- delivered: false,
841
- submitted: true,
842
- lost: false,
843
- submittedAt: Date.now(),
844
- attempts: (entry.attempts ?? 0) + 1,
845
- submittedRunCount: this.agentRunCount,
846
- });
847
- if (entry.kind === "terminal") {
848
- for (const taskId of entry.taskIds) {
849
- const notice = this.terminalNotices.get(`${entry.runId}:${taskId}`);
850
- if (notice?.body === body) notice.delivery = "pending";
851
- }
852
- }
853
- this.emit("subagent:notification", {
854
- runId: entry.runId,
855
- taskId: entry.taskIds[0],
856
- kind: "completed",
857
- body,
858
- delivery: "submitted",
859
- });
860
- } catch {
861
- const attempts = (entry.attempts ?? 0) + 1;
862
- if (attempts < MAX_DELIVERY_ATTEMPTS) {
863
- this.outbox.set(body, { ...entry, attempts, attemptedAt: Date.now() });
864
- continue;
865
- }
866
- this.emit("subagent:notification", {
867
- runId: entry.runId,
868
- taskId: entry.taskIds[0],
869
- kind: "completed",
870
- body,
871
- delivery: "failed",
872
- });
873
- }
874
- }
875
- }
876
-
877
- flushPendingNotifications(ctx: ExtensionContext): void {
878
- const existing = new Set([...this.leaderMessages.keys(), ...this.outbox.keys()]);
879
- this.confirmLeaderMessages(ctx);
880
- if (this.cleared) return;
881
- let idle = false;
882
- try {
883
- idle = ctx.isIdle() && !ctx.hasPendingMessages();
884
- } catch {
885
- idle = false;
886
- }
887
- if (idle) this.markLostReceipts(ctx, existing);
888
- const outstandingReceipts = () =>
889
- [...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost);
890
- if (!this.pendingTaskNotices.size && !this.pendingRunNotices.size && !this.outbox.size && !outstandingReceipts())
891
- return;
892
- if (idle) {
893
- this.flushOutbox();
894
- for (const [key, pending] of [...this.pendingTaskNotices]) {
895
- const report = pending.task.finalParentReport;
896
- if (report?.delivery === "lost") {
897
- this.pendingTaskNotices.delete(key);
898
- pending.task.finalParentReport = undefined;
899
- this.notifyTask(pending.run, pending.task, "completed");
900
- continue;
901
- }
902
- if (report?.delivery === "pending") {
903
- const outstanding = this.leaderMessages.has(report.body) || this.outbox.has(report.body);
904
- if (outstanding) continue;
905
- this.pendingTaskNotices.delete(key);
906
- pending.task.finalParentReport = undefined;
907
- this.notifyTask(pending.run, pending.task, "completed");
908
- continue;
909
- }
910
- this.pendingTaskNotices.delete(key);
911
- if (report && this.finalReportMatches(pending.task) && this.hasArtifactDelta(pending.task))
912
- this.notifyTask(pending.run, pending.task, "completed");
913
- }
914
- for (const [key, run] of [...this.pendingRunNotices]) {
915
- const notices = run.tasks
916
- .map((task) => this.terminalNotices.get(`${run.id}:${task.id}`))
917
- .filter((notice): notice is TerminalNotice => Boolean(notice));
918
- const waiting = notices.some((notice) => {
919
- const receipt = this.leaderMessages.get(notice.body);
920
- return notice.delivery === "pending" && (!receipt || (!receipt.delivered && !receipt.lost));
921
- });
922
- if (waiting) continue;
923
- this.pendingRunNotices.delete(key);
924
- const lost = notices.some((notice) => notice.delivery === "lost");
925
- if (!lost || this.notificationRetries.has(run.id)) continue;
926
- this.notificationRetries.add(run.id);
927
- this.notifyParent(run, run.status === "aborted" ? "aborted" : run.status === "failed" ? "failed" : "completed");
928
- }
929
- this.flushOutbox();
930
- }
931
- // Anything still unresolved (including a retry backoff) must be revisited without unrelated activity.
932
- this.scheduleNotificationFlush(ctx, RETRY_BACKOFF_MS);
933
- }
934
-
935
- scheduleNotificationFlush(ctx: ExtensionContext, delayMs = 100): void {
936
- if (
937
- this.notificationFlushTimer ||
938
- this.cleared ||
939
- (!this.pendingTaskNotices.size &&
940
- !this.pendingRunNotices.size &&
941
- !this.outbox.size &&
942
- ![...this.leaderMessages.values()].some((receipt) => !receipt.delivered && !receipt.lost))
943
- )
944
- return;
945
- this.notificationFlushTimer = setTimeout(() => {
946
- this.notificationFlushTimer = undefined;
947
- this.flushPendingNotifications(ctx);
948
- }, delayMs);
949
- this.notificationFlushTimer.unref?.();
950
- }
951
-
952
- private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
953
- const key = `${run.id}:${task.id}`;
954
- const outcome = outcomeFingerprint(task);
955
- const final = kind === "completed" && this.finalReportMatches(task);
956
- const report = task.finalParentReport;
957
- if (final && report?.delivery === "pending") {
958
- this.pendingTaskNotices.set(key, { run, task });
959
- if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
960
- this.emit("subagent:notification", {
961
- runId: run.id,
962
- taskId: task.id,
963
- kind,
964
- body: "",
965
- suppressed: true,
966
- delivery: "pending",
967
- });
968
- return;
969
- }
970
- if (
971
- final &&
972
- report &&
973
- report.delivery !== "lost" &&
974
- report.delivery !== "pending" &&
975
- !this.hasArtifactDelta(task)
976
- ) {
977
- this.emit("subagent:notification", {
978
- runId: run.id,
979
- taskId: task.id,
980
- kind,
981
- body: "",
982
- suppressed: true,
983
- delivery: report.delivery,
984
- });
985
- return;
986
- }
987
- const covered = this.terminalNotices.get(key);
988
- if (covered?.fingerprint === outcome && covered.delivery !== "lost") {
989
- this.emit("subagent:notification", {
990
- runId: run.id,
991
- taskId: task.id,
992
- kind,
993
- body: "",
994
- suppressed: true,
995
- delivery: covered.delivery,
996
- });
997
- return;
998
- }
999
- if (this.coveredByAwait(run.id, task.id, outcome)) {
1000
- this.emit("subagent:notification", {
1001
- runId: run.id,
1002
- taskId: task.id,
1003
- kind,
1004
- body: "",
1005
- suppressed: true,
1006
- delivery: "awaited",
1007
- });
1008
- return;
1009
- }
1010
- const artifactOnly = final && report?.delivery !== "lost";
1011
- const body = artifactOnly ? makeTaskArtifactNotice(run, task) : makeTaskNotice(run, task, kind);
1012
- if (this.collectParked(run.id, { kind: "done", taskId: task.id, agent: task.agent, text: body })) {
1013
- this.noteTerminal(run.id, task.id, body, outcome);
1014
- this.terminalNotices.get(key)!.delivery = "received";
1015
- this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "parked" });
1016
- return;
1017
- }
1018
- if (kind === "failed") {
1019
- this.noteTerminal(run.id, task.id, body, outcome);
1020
- try {
1021
- this.pi.sendUserMessage(body, { deliverAs: "steer" });
1022
- this.leaderMessages.set(body, {
1023
- runId: run.id,
1024
- taskIds: [task.id],
1025
- kind: "terminal",
1026
- deliverAs: "steer",
1027
- delivered: false,
1028
- submitted: true,
1029
- lost: false,
1030
- submittedAt: Date.now(),
1031
- attempts: 0,
1032
- submittedRunCount: this.agentRunCount,
1033
- });
1034
- this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "submitted" });
1035
- if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
1036
- } catch {
1037
- this.terminalNotices.get(key)!.delivery = "lost";
1038
- this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "failed" });
1039
- }
1040
- return;
1041
- }
1042
- this.noteTerminal(run.id, task.id, body, outcome);
1043
- this.enqueueNotification(body, {
1044
- runId: run.id,
1045
- taskIds: [task.id],
1046
- kind: "terminal",
1047
- deliverAs: "followUp",
1048
- });
1049
- this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body, delivery: "held" });
1050
- }
1051
-
1052
- private notifyParent(
1053
- run: RunSnapshot,
1054
- kind: "completed" | "failed" | "aborted" | "asked",
1055
- extra?: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
1056
- ): void {
1057
- if (kind !== "asked" && run.completionAwaited) return;
1058
- let tasks = run.tasks;
1059
- if (kind !== "asked") {
1060
- tasks = tasks.filter((task) => {
1061
- const key = `${run.id}:${task.id}`;
1062
- const outcome = outcomeFingerprint(task);
1063
- const covered = this.terminalNotices.get(key);
1064
- if (covered?.fingerprint === outcome && covered.delivery !== "lost") return false;
1065
- if (this.pendingTaskNotices.has(key)) return false;
1066
- if (this.coveredByAwait(run.id, task.id, outcome)) return false;
1067
- if (
1068
- kind === "completed" &&
1069
- task.finalParentReport?.delivery !== "lost" &&
1070
- this.finalReportMatches(task) &&
1071
- !this.hasArtifactDelta(task)
1072
- )
1073
- return false;
1074
- return true;
1075
- });
1076
- if (tasks.length === 0) {
1077
- if (!this.notificationRetries.has(run.id) && this.hasPendingSuccessMessages(run)) {
1078
- this.pendingRunNotices.set(run.id, run);
1079
- if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
1080
- }
1081
- this.emit("subagent:notification", { runId: run.id, kind, body: "", suppressed: true });
1082
- return;
1083
- }
1084
- // Aggregate-only mode has no per-task hold: keep a run-level watch so a pending report
1085
- // that later goes lost still produces a fallback.
1086
- if (
1087
- !this.notificationRetries.has(run.id) &&
1088
- !this.pendingRunNotices.has(run.id) &&
1089
- run.tasks.some((task) => task.finalParentReport?.delivery === "pending")
1090
- ) {
1091
- this.pendingRunNotices.set(run.id, run);
1092
- if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
1093
- }
1094
- }
1095
- if (kind === "asked") {
1096
- const body = makeAskNotice(run, extra ?? {});
1097
- try {
1098
- this.pi.sendUserMessage(body, { deliverAs: "steer" });
1099
- this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body, delivery: "submitted" });
1100
- } catch {
1101
- this.emit("subagent:notification", { runId: run.id, taskId: extra?.taskId, kind, body, delivery: "failed" });
1102
- }
1103
- return;
1104
- }
1105
- const body = makeNotice(run, kind, tasks);
1106
- const deliverAs = kind === "failed" ? "steer" : "followUp";
1107
- for (const task of tasks) this.noteTerminal(run.id, task.id, body, outcomeFingerprint(task));
1108
- if (deliverAs === "steer") {
1109
- try {
1110
- this.pi.sendUserMessage(body, { deliverAs });
1111
- this.leaderMessages.set(body, {
1112
- runId: run.id,
1113
- taskIds: tasks.map((t) => t.id),
1114
- kind: "terminal",
1115
- deliverAs,
1116
- delivered: false,
1117
- submitted: true,
1118
- lost: false,
1119
- submittedAt: Date.now(),
1120
- attempts: 0,
1121
- submittedRunCount: this.agentRunCount,
1122
- });
1123
- if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext, RECEIPT_GRACE_MS);
1124
- } catch {
1125
- for (const task of tasks) {
1126
- const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
1127
- if (notice?.body === body) notice.delivery = "lost";
1128
- }
1129
- }
1130
- this.emit("subagent:notification", { runId: run.id, kind, body, delivery: "submitted" });
1131
- this.holdRunFallback(run, tasks);
1132
- return;
1133
- }
1134
- this.enqueueNotification(body, {
1135
- runId: run.id,
1136
- taskIds: tasks.map((t) => t.id),
1137
- kind: "terminal",
1138
- deliverAs,
1139
- });
1140
- this.emit("subagent:notification", { runId: run.id, kind, body, delivery: "held" });
1141
- this.holdRunFallback(run, tasks);
1142
- }
1143
-
1144
- /** Keep one bounded retry for aggregates whose per-task receipts never arrived. */
1145
- private holdRunFallback(run: RunSnapshot, tasks: TaskSnapshot[]): void {
1146
- if (this.notificationRetries.has(run.id)) return;
1147
- const covered = tasks.filter((task) => {
1148
- const notice = this.terminalNotices.get(`${run.id}:${task.id}`);
1149
- return Boolean(notice && notice.body !== "");
1150
- });
1151
- if (covered.length === 0) return;
1152
- this.pendingRunNotices.set(run.id, run);
1153
- if (this.notificationContext) this.scheduleNotificationFlush(this.notificationContext);
1154
- }
1155
-
1156
- private widgetTui: TUI | null = null;
1157
-
1158
- /** Settled runs are history (subagent_status/result still reach them) — the widget shows live work only. */
1159
- private pruneSettledWidget(): void {
1160
- this.widgetRuns = this.widgetRuns.filter((r) => !TERMINAL.includes(r.status));
1161
- }
1162
- private upsertWidgetRun(run: RunSnapshot | undefined): void {
1163
- this.pruneSettledWidget();
1164
- if (!run || TERMINAL.includes(run.status)) return;
1165
- const idx = this.widgetRuns.findIndex((r) => r.id === run.id);
1166
- if (idx >= 0) this.widgetRuns[idx] = run;
1167
- else this.widgetRuns.push(run);
1168
- }
1169
- private scheduleWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext): void {
1170
- this.upsertWidgetRun(run);
1171
- if (!run || this.widgetTimers.has(run.id)) return;
1172
- this.widgetTimers.set(
1173
- run.id,
1174
- setTimeout(() => {
1175
- this.widgetTimers.delete(run.id);
1176
- if (ctx?.hasUI) {
1177
- this.ensureWidget(ctx);
1178
- this.widgetTui?.requestRender();
1179
- }
1180
- this.maybePulse(ctx);
1181
- }, WIDGET_THROTTLE_MS),
1182
- );
1183
- }
1184
-
1185
- private pulseTimer: ReturnType<typeof setTimeout> | null = null;
1186
- private maybePulse(ctx?: ExtensionContext): void {
1187
- if (this.pulseTimer || !this.widgetTui) return;
1188
- const talking = this.widgetRuns.some((r) => r.tasks.some(isTalking));
1189
- if (!talking) return;
1190
- this.pulseTimer = setTimeout(() => {
1191
- this.pulseTimer = null;
1192
- this.widgetTui?.requestRender();
1193
- this.maybePulse(ctx);
1194
- }, 700);
1195
- }
1196
- private flushWidget(run: RunSnapshot | undefined, ctx?: ExtensionContext, onUpdate?: (partial: any) => void): void {
1197
- if (run) {
1198
- const t = this.widgetTimers.get(run.id);
1199
- if (t) {
1200
- clearTimeout(t);
1201
- this.widgetTimers.delete(run.id);
1202
- }
1203
- }
1204
- this.pruneSettledWidget();
1205
- if (this.widgetRuns.length === 0) {
1206
- if (this.widgetTui) this.clearWidget(ctx);
1207
- } else {
1208
- if (ctx?.hasUI) {
1209
- this.ensureWidget(ctx);
1210
- this.widgetTui?.requestRender();
1211
- }
1212
- this.maybePulse(ctx);
1213
- }
1214
- if (!run) return;
1215
-
1216
- onUpdate?.({
1217
- content: [
1218
- {
1219
- type: "text",
1220
- text: `${run.tasks.filter((t) => TERMINAL.includes(t.status)).length}/${run.tasks.length} done · ${run.status}`,
1221
- },
1222
- ],
1223
- });
1224
- }
1225
- private ensureWidget(ctx: ExtensionContext): void {
1226
- if (this.widgetTui !== null || !ctx.hasUI) return;
1227
- ctx.ui.setWidget(
1228
- "subagents",
1229
- (tui, theme) => {
1230
- this.widgetTui = tui;
1231
- return new SubagentsWidget(() => [...this.widgetRuns], theme);
1232
- },
1233
- { placement: "aboveEditor" },
1234
- );
1235
- }
1236
-
1237
315
  private updateRun(run: RunSnapshot, ctx?: ExtensionContext, _onUpdate?: (partial: any) => void): void {
1238
316
  run.aggregateUsage = aggregateUsage(run.tasks);
1239
317
  this.runs.set(run.id, run);
@@ -1242,7 +320,7 @@ export class SubagentManager {
1242
320
  status: run.status,
1243
321
  live: run.tasks.filter((t) => !TERMINAL.includes(t.status)).length,
1244
322
  });
1245
- this.scheduleWidget(run, ctx);
323
+ this.widget.schedule(run, ctx);
1246
324
  }
1247
325
  private updateTask(
1248
326
  run: RunSnapshot,
@@ -1257,7 +335,7 @@ export class SubagentManager {
1257
335
  }
1258
336
 
1259
337
  private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
1260
- this.notificationContext = ctx;
338
+ this.delivery.notificationContext = ctx;
1261
339
  return {
1262
340
  onAskParent: async (_taskId, question, urgent) => {
1263
341
  if (TERMINAL.includes(task.status)) {
@@ -1266,7 +344,7 @@ export class SubagentManager {
1266
344
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
1267
345
 
1268
346
  if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
1269
- this.notifyParent(run, "asked", { taskId: task.id, agent: task.agent, question, urgent });
347
+ this.delivery.notifyParent(run, "asked", { taskId: task.id, agent: task.agent, question, urgent });
1270
348
  }
1271
349
 
1272
350
  const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
@@ -1278,9 +356,9 @@ export class SubagentManager {
1278
356
  return reply;
1279
357
  },
1280
358
  onNotifyParent: (_taskId, message, level, final = false) =>
1281
- this.submitLeaderReport(run, task, message, level, final, ctx),
359
+ this.delivery.submitLeaderReport(run, task, message, level, final, ctx),
1282
360
  onSendMessage: (_taskId, to, text, final = false) => {
1283
- if (to === "leader") return this.submitLeaderReport(run, task, text, "info", final, ctx);
361
+ if (to === "leader") return this.delivery.submitLeaderReport(run, task, text, "info", final, ctx);
1284
362
 
1285
363
  return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
1286
364
  },
@@ -1350,7 +428,7 @@ export class SubagentManager {
1350
428
  onUpdate,
1351
429
  );
1352
430
  } else if (event.type === "tool_execution_end") {
1353
- this.scheduleWidget(run, ctx);
431
+ this.widget.schedule(run, ctx);
1354
432
  } else if (event.type === "message_end") {
1355
433
  const message = event.message as AssistantMessage;
1356
434
  if (message?.role === "assistant") {
@@ -1908,7 +986,7 @@ export class SubagentManager {
1908
986
  );
1909
987
  if (task.status === "completed") outputs.set(task.id, task.finalText ?? "");
1910
988
  if (run.notifyPerTask && TERMINAL.includes(task.status)) {
1911
- this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
989
+ this.delivery.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
1912
990
  }
1913
991
  },
1914
992
  );
@@ -1946,10 +1024,10 @@ export class SubagentManager {
1946
1024
  const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
1947
1025
  run.status = aborted ? "aborted" : failed ? "failed" : "completed";
1948
1026
  run.endedAt = Date.now();
1949
- this.flushWidget(run, ctx, onUpdate);
1027
+ this.widget.flush(run, ctx, onUpdate);
1950
1028
 
1951
1029
  const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
1952
- if (live) this.scheduleWidget(live, ctx);
1030
+ if (live) this.widget.schedule(live, ctx);
1953
1031
 
1954
1032
  if (this.settlers.has(run.id)) {
1955
1033
  this.emit("subagent:run-completed", {
@@ -1987,7 +1065,7 @@ export class SubagentManager {
1987
1065
  const { run, inputs } = this.createRun(params, ctx);
1988
1066
  void this.executeTasks(run, inputs, ctx, undefined, undefined)
1989
1067
  .then(() => {
1990
- this.notifyParent(
1068
+ this.delivery.notifyParent(
1991
1069
  run,
1992
1070
  run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed",
1993
1071
  );
@@ -2006,7 +1084,7 @@ export class SubagentManager {
2006
1084
  this.runControllers.delete(run.id);
2007
1085
  for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
2008
1086
  this.emit("subagent:run-completed", { runId: run.id, status: "failed", run: cloneRun(run) });
2009
- this.notifyParent(run, "failed");
1087
+ this.delivery.notifyParent(run, "failed");
2010
1088
  this.persist(ctx);
2011
1089
  });
2012
1090
  return { run: cloneRun(run) };
@@ -2084,35 +1162,7 @@ export class SubagentManager {
2084
1162
  changedFiles: undefined,
2085
1163
  worktreeError: undefined,
2086
1164
  });
2087
- const scope = `${run.id}:${task.id}`;
2088
- this.terminalNotices.delete(scope);
2089
- this.pendingTaskNotices.delete(scope);
2090
- this.pendingRunNotices.delete(run.id);
2091
- this.notificationRetries.delete(run.id);
2092
- for (const [body, receipt] of this.leaderMessages) {
2093
- if (receipt.runId !== run.id || !receipt.taskIds.includes(task.id)) continue;
2094
- if (receipt.taskIds.length === 1) {
2095
- this.leaderMessages.delete(body);
2096
- continue;
2097
- }
2098
- // A shared aggregate stays for its remaining tasks; the resumed task's slice is published
2099
- // again by the run's next settle.
2100
- receipt.taskIds = receipt.taskIds.filter((id) => id !== task.id);
2101
- }
2102
- for (const [body, entry] of this.outbox) {
2103
- if (entry.runId !== run.id || !entry.taskIds.includes(task.id)) continue;
2104
- if (entry.taskIds.length === 1) {
2105
- this.outbox.delete(body);
2106
- continue;
2107
- }
2108
- this.outbox.delete(body);
2109
- for (const otherId of entry.taskIds) {
2110
- if (otherId === task.id) continue;
2111
- const notice = this.terminalNotices.get(`${run.id}:${otherId}`);
2112
- if (notice) notice.delivery = "lost";
2113
- }
2114
- }
2115
- this.awaitedTaskNotices.delete(scope);
1165
+ this.delivery.forgetTask(run, task);
2116
1166
  run.status = "running";
2117
1167
  run.endedAt = undefined;
2118
1168
  run.awaited = false;
@@ -2135,7 +1185,7 @@ export class SubagentManager {
2135
1185
  }
2136
1186
  })
2137
1187
  .then(() => {
2138
- if (run.notifyPerTask) this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
1188
+ if (run.notifyPerTask) this.delivery.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
2139
1189
  this.finishRunIfSettled(run, ctx);
2140
1190
  });
2141
1191
  return { ok: true, task, note: thinkingNote };
@@ -2147,7 +1197,7 @@ export class SubagentManager {
2147
1197
  const aborted = run.tasks.some((t) => t.status === "aborted");
2148
1198
  run.status = aborted ? "aborted" : failed ? "failed" : "completed";
2149
1199
  run.endedAt = Date.now();
2150
- this.flushWidget(run, ctx);
1200
+ this.widget.flush(run, ctx);
2151
1201
  this.emit("subagent:run-completed", {
2152
1202
  runId: run.id,
2153
1203
  status: run.status,
@@ -2158,7 +1208,10 @@ export class SubagentManager {
2158
1208
  this.runControllers.delete(run.id);
2159
1209
  for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
2160
1210
  this.persist(ctx);
2161
- this.notifyParent(run, run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed");
1211
+ this.delivery.notifyParent(
1212
+ run,
1213
+ run.status === "completed" ? "completed" : run.status === "aborted" ? "aborted" : "failed",
1214
+ );
2162
1215
  }
2163
1216
 
2164
1217
  steerTask(runId: string, taskId: string | undefined, message: string): boolean {
@@ -2182,7 +1235,7 @@ export class SubagentManager {
2182
1235
  this.pendingReplies.delete(`${runId}:${taskId}`);
2183
1236
  this.liveChildren.get(`${runId}:${taskId}`)?.abort();
2184
1237
  this.mailboxes.close(`${runId}:${taskId}`);
2185
- if (ctx) this.flushWidget(run, ctx);
1238
+ if (ctx) this.widget.flush(run, ctx);
2186
1239
  this.emit("subagent:task-aborted", { runId, taskId });
2187
1240
  return true;
2188
1241
  }