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