@clanker-code/pi-subagents 0.10.8 → 0.11.1

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.
@@ -12,6 +12,7 @@ import { isAbsolute } from "node:path";
12
12
  import type { Model } from "@earendil-works/pi-ai";
13
13
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
15
+ import type { EventBus } from "./cross-extension-rpc.js";
15
16
  import { type AgentInvocation, type AgentRecord, type IsolationMode, MAX_RECURSIVE_DEPTH, type SubagentType, type ThinkingLevel } from "./types.js";
16
17
  import { addUsage } from "./usage.js";
17
18
  import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
@@ -102,6 +103,8 @@ interface SpawnOptions {
102
103
  onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
103
104
  /** Called when the session successfully compacts. */
104
105
  onCompaction?: (info: CompactionInfo) => void;
106
+ /** Parent's event bus — shared with child sessions so lifecycle events propagate to the parent widget. */
107
+ eventBus?: EventBus;
105
108
  }
106
109
 
107
110
  interface ResumeOptions {
@@ -126,6 +129,10 @@ export class AgentManager {
126
129
  private queue: { id: string; args: SpawnArgs }[] = [];
127
130
  /** Number of currently running background agents. */
128
131
  private runningBackground = 0;
132
+ /** Background agents by id; foreground agents must not emit background completion callbacks. */
133
+ private backgroundAgentIds = new Set<string>();
134
+ /** Background terminal callbacks already emitted; prevents abort/settle double delivery. */
135
+ private completedBackgroundCallbacks = new Set<string>();
129
136
 
130
137
  constructor(
131
138
  onComplete?: OnAgentComplete,
@@ -194,6 +201,7 @@ export class AgentManager {
194
201
  options.onOutputFileCreated?.(record.outputFile, id);
195
202
  }
196
203
  this.agents.set(id, record);
204
+ if (options.isBackground) this.backgroundAgentIds.add(id);
197
205
 
198
206
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
199
207
 
@@ -208,12 +216,22 @@ export class AgentManager {
208
216
  try {
209
217
  this.startAgent(id, record, args);
210
218
  } catch (err) {
219
+ this.backgroundAgentIds.delete(id);
211
220
  this.agents.delete(id);
212
221
  throw err;
213
222
  }
214
223
  return id;
215
224
  }
216
225
 
226
+ /** Emit background completion once, optionally releasing a running concurrency slot. */
227
+ private completeBackground(record: AgentRecord, releaseRunningSlot: boolean, drain = true): void {
228
+ if (this.completedBackgroundCallbacks.has(record.id)) return;
229
+ this.completedBackgroundCallbacks.add(record.id);
230
+ if (releaseRunningSlot) this.runningBackground = Math.max(0, this.runningBackground - 1);
231
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
232
+ if (drain) this.drainQueue();
233
+ }
234
+
217
235
  /** Actually start an agent (called immediately or from queue drain). */
218
236
  private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
219
237
  // Re-validate a caller-supplied cwd: queued spawns can start minutes after
@@ -295,6 +313,7 @@ export class AgentManager {
295
313
  },
296
314
  depth: record.depth,
297
315
  parentAgentId: record.parentAgentId,
316
+ eventBus: options.eventBus,
298
317
  onSessionCreated: (session) => {
299
318
  record.session = session;
300
319
  // Flush any steers that arrived before the session was ready
@@ -338,9 +357,7 @@ export class AgentManager {
338
357
  }
339
358
 
340
359
  if (options.isBackground) {
341
- this.runningBackground--;
342
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
343
- this.drainQueue();
360
+ this.completeBackground(record, true);
344
361
  }
345
362
  return responseText;
346
363
  })
@@ -369,9 +386,7 @@ export class AgentManager {
369
386
  }
370
387
 
371
388
  if (options.isBackground) {
372
- this.runningBackground--;
373
- this.onComplete?.(record);
374
- this.drainQueue();
389
+ this.completeBackground(record, true);
375
390
  }
376
391
  return "";
377
392
  });
@@ -393,7 +408,7 @@ export class AgentManager {
393
408
  record.status = "error";
394
409
  record.error = err instanceof Error ? err.message : String(err);
395
410
  record.completedAt = Date.now();
396
- this.onComplete?.(record);
411
+ this.completeBackground(record, false, false);
397
412
  }
398
413
  }
399
414
  }
@@ -434,6 +449,8 @@ export class AgentManager {
434
449
  record.error = undefined;
435
450
  record.resultConsumed = false;
436
451
  record.abortController = new AbortController();
452
+ this.backgroundAgentIds.add(id);
453
+ this.completedBackgroundCallbacks.delete(id);
437
454
  this.runningBackground++;
438
455
  this.onStart?.(record);
439
456
 
@@ -463,18 +480,14 @@ export class AgentManager {
463
480
  record.result = responseText;
464
481
  record.completedAt = Date.now();
465
482
  detach();
466
- this.runningBackground--;
467
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
468
- this.drainQueue();
483
+ this.completeBackground(record, true);
469
484
  return responseText;
470
485
  }).catch((err) => {
471
486
  if (record.status !== "stopped") record.status = "error";
472
487
  record.error = err instanceof Error ? err.message : String(err);
473
488
  record.completedAt = Date.now();
474
489
  detach();
475
- this.runningBackground--;
476
- try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
477
- this.drainQueue();
490
+ this.completeBackground(record, true);
478
491
  return "";
479
492
  });
480
493
 
@@ -501,6 +514,7 @@ export class AgentManager {
501
514
  this.queue = this.queue.filter(q => q.id !== id);
502
515
  record.status = "stopped";
503
516
  record.completedAt = Date.now();
517
+ this.completeBackground(record, false);
504
518
  return true;
505
519
  }
506
520
 
@@ -508,6 +522,7 @@ export class AgentManager {
508
522
  record.abortController?.abort();
509
523
  record.status = "stopped";
510
524
  record.completedAt = Date.now();
525
+ if (this.backgroundAgentIds.has(id)) this.completeBackground(record, true);
511
526
  return true;
512
527
  }
513
528
 
@@ -515,9 +530,24 @@ export class AgentManager {
515
530
  private removeRecord(id: string, record: AgentRecord): void {
516
531
  record.session?.dispose?.();
517
532
  record.session = undefined;
533
+ this.backgroundAgentIds.delete(id);
534
+ this.completedBackgroundCallbacks.delete(id);
518
535
  this.agents.delete(id);
519
536
  }
520
537
 
538
+ /** Remove selected terminal records. Running and queued records are never removed. */
539
+ clearRecords(ids: string[]): string[] {
540
+ const removed: string[] = [];
541
+ for (const id of ids) {
542
+ const record = this.agents.get(id);
543
+ if (!record) continue;
544
+ if (record.status === "running" || record.status === "queued") continue;
545
+ this.removeRecord(id, record);
546
+ removed.push(id);
547
+ }
548
+ return removed;
549
+ }
550
+
521
551
  private cleanup() {
522
552
  const cutoff = Date.now() - 10 * 60_000;
523
553
  for (const [id, record] of this.agents) {
@@ -554,6 +584,7 @@ export class AgentManager {
554
584
  if (record) {
555
585
  record.status = "stopped";
556
586
  record.completedAt = Date.now();
587
+ if (this.backgroundAgentIds.has(record.id)) this.completedBackgroundCallbacks.add(record.id);
557
588
  count++;
558
589
  }
559
590
  }
@@ -564,9 +595,11 @@ export class AgentManager {
564
595
  record.abortController?.abort();
565
596
  record.status = "stopped";
566
597
  record.completedAt = Date.now();
598
+ if (this.backgroundAgentIds.has(record.id)) this.completedBackgroundCallbacks.add(record.id);
567
599
  count++;
568
600
  }
569
601
  }
602
+ this.runningBackground = 0;
570
603
  return count;
571
604
  }
572
605
 
@@ -593,6 +626,8 @@ export class AgentManager {
593
626
  record.session?.dispose();
594
627
  }
595
628
  this.agents.clear();
629
+ this.backgroundAgentIds.clear();
630
+ this.completedBackgroundCallbacks.clear();
596
631
  // Prune any orphaned git worktrees (crash recovery)
597
632
  try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
598
633
  // Also prune repos that caller-supplied cwds created worktrees in — a clean
@@ -11,6 +11,7 @@ import {
11
11
  type AgentSession,
12
12
  type AgentSessionEvent,
13
13
  createAgentSession,
14
+ createEventBus,
14
15
  DefaultResourceLoader,
15
16
  type ExtensionAPI,
16
17
  getAgentDir,
@@ -19,6 +20,7 @@ import {
19
20
  } from "@earendil-works/pi-coding-agent";
20
21
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
21
22
  import { buildParentContext, extractText } from "./context.js";
23
+ import type { EventBus } from "./cross-extension-rpc.js";
22
24
  import { DEFAULT_AGENTS } from "./default-agents.js";
23
25
  import { detectEnv } from "./env.js";
24
26
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
@@ -36,6 +38,8 @@ export const SUBAGENT_TOOL_NAMES = {
36
38
  AGENT: "Agent",
37
39
  GET_RESULT: "get_subagent_result",
38
40
  STEER: "steer_subagent",
41
+ LIST_SUBAGENTS: "list_subagents",
42
+ CLEAR_SUBAGENTS: "clear_subagents",
39
43
  LIST_MODELS: "list_models",
40
44
  } as const;
41
45
 
@@ -53,6 +57,40 @@ const RECURSIVE_TOOL_NAMES: string[] = [
53
57
  ];
54
58
 
55
59
  const EXTENSION_DEPTH_KEY = Symbol.for("pi-subagents:extension-depth");
60
+
61
+ /** Lifecycle event names that should propagate from child to parent sessions. */
62
+ const FORWARDABLE_EVENTS = new Set([
63
+ "subagents:created",
64
+ "subagents:started",
65
+ "subagents:completed",
66
+ "subagents:failed",
67
+ "subagents:compacted",
68
+ ]);
69
+
70
+ /**
71
+ * Create a forwarding event bus for a child session.
72
+ * The child gets its own local bus for emit/on, but lifecycle events
73
+ * (subagents:*) are also forwarded to the parent bus so the parent widget
74
+ * can display depth 2+ agents.
75
+ */
76
+ export function createForwardingEventBus(parentBus: EventBus): EventBus {
77
+ // Use the parent's EventBus factory to create a properly isolated local bus
78
+ const localBus = createEventBus();
79
+ return {
80
+ on(event, handler) {
81
+ // Subscribe to local bus only — child doesn't see parent/sibling events
82
+ return localBus.on(event, handler);
83
+ },
84
+ emit(event, data) {
85
+ // Always emit on local bus for child's own listeners
86
+ localBus.emit(event, data);
87
+ // Forward lifecycle events to parent bus for parent widget visibility
88
+ if (FORWARDABLE_EVENTS.has(event)) {
89
+ parentBus.emit(event, data);
90
+ }
91
+ },
92
+ };
93
+ }
56
94
  const AUTO_EXPOSE_EXTENSION_NAMES = new Set(["pi-c2c"]);
57
95
  let extensionDepthLoadChain: Promise<void> = Promise.resolve();
58
96
  const packageNameCache = new Map<string, string[]>();
@@ -69,7 +107,12 @@ function getLoadingExtensionAgentId(): string | undefined {
69
107
  return value && typeof value.agentId === "string" ? value.agentId : undefined;
70
108
  }
71
109
 
72
- async function withLoadingExtensionDepth<T>(depth: number, agentId: string | undefined, fn: () => Promise<T>): Promise<T> {
110
+ function getLoadingExtensionParentAgentId(): string | undefined {
111
+ const value = (globalThis as any)[EXTENSION_DEPTH_KEY];
112
+ return value && typeof value.parentAgentId === "string" ? value.parentAgentId : undefined;
113
+ }
114
+
115
+ async function withLoadingExtensionDepth<T>(depth: number, agentId: string | undefined, parentAgentId: string | undefined, fn: () => Promise<T>): Promise<T> {
73
116
  const previous = extensionDepthLoadChain;
74
117
  let release!: () => void;
75
118
  extensionDepthLoadChain = new Promise<void>((resolve) => {
@@ -80,7 +123,7 @@ async function withLoadingExtensionDepth<T>(depth: number, agentId: string | und
80
123
  try {
81
124
  const g = globalThis as any;
82
125
  const prev = g[EXTENSION_DEPTH_KEY];
83
- g[EXTENSION_DEPTH_KEY] = { depth, agentId };
126
+ g[EXTENSION_DEPTH_KEY] = { depth, agentId, parentAgentId };
84
127
  try {
85
128
  return await fn();
86
129
  } finally {
@@ -100,6 +143,10 @@ export function getCurrentExtensionAgentId(): string | undefined {
100
143
  return getLoadingExtensionAgentId();
101
144
  }
102
145
 
146
+ export function getCurrentExtensionParentAgentId(): string | undefined {
147
+ return getLoadingExtensionParentAgentId();
148
+ }
149
+
103
150
  /**
104
151
  * Canonical name of an extension for `extensions: [...]` allowlist matching.
105
152
  * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
@@ -354,6 +401,9 @@ export interface RunOptions {
354
401
  depth?: number;
355
402
  /** Parent subagent id when spawned recursively from another subagent. */
356
403
  parentAgentId?: string;
404
+ /** Parent's event bus — shared with the child session so lifecycle events
405
+ * (subagents:created, subagents:started, etc.) propagate to the parent widget. */
406
+ eventBus?: EventBus;
357
407
  }
358
408
 
359
409
  export interface RunResult {
@@ -544,6 +594,11 @@ export async function runAgent(
544
594
  };
545
595
  };
546
596
 
597
+ // Create a forwarding event bus so the child session's lifecycle events
598
+ // (subagents:created, subagents:started, etc.) propagate to the parent's
599
+ // event bus — making depth 2+ agents visible in the parent widget.
600
+ const childEventBus = options.eventBus ? createForwardingEventBus(options.eventBus) : undefined;
601
+
547
602
  const loader = new DefaultResourceLoader({
548
603
  cwd: configCwd,
549
604
  agentDir,
@@ -556,8 +611,9 @@ export async function runAgent(
556
611
  noContextFiles: true,
557
612
  systemPromptOverride: () => systemPrompt,
558
613
  appendSystemPromptOverride: () => [],
614
+ eventBus: childEventBus,
559
615
  });
560
- await withLoadingExtensionDepth(depth, options.agentId, () => loader.reload());
616
+ await withLoadingExtensionDepth(depth, options.agentId, options.parentAgentId, () => loader.reload());
561
617
 
562
618
  // Plain entries in `tools:` are expected to be built-in names (extension tools
563
619
  // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
@@ -44,7 +44,13 @@ const buildCompactTypeListText = () =>
44
44
 
45
45
  export interface AgentToolDescriptionOptions {
46
46
  mode: ToolDescriptionMode;
47
- extensionDepth: number;
47
+ /**
48
+ * Depth at which the NEXT spawned subagent will run.
49
+ * This is `extensionDepth + 1` — the agent's own depth plus one.
50
+ * Displayed as "Current recursive depth" in the tool description so the
51
+ * LLM sees the depth of the agent it is about to create, not its own depth.
52
+ */
53
+ nextSubagentDepth: number;
48
54
  schedulingEnabled: boolean;
49
55
  }
50
56
 
@@ -56,7 +62,7 @@ export function buildScheduleGuideline(schedulingEnabled: boolean): string {
56
62
 
57
63
  export function buildAgentToolDescription(options: AgentToolDescriptionOptions): string {
58
64
  const scheduleGuideline = buildScheduleGuideline(options.schedulingEnabled);
59
- const recursiveGuideline = `Recursive agents are allowed through depth ${MAX_RECURSIVE_DEPTH}. Current recursive depth: ${options.extensionDepth}/${MAX_RECURSIVE_DEPTH}.`;
65
+ const recursiveGuideline = `Recursive agents are allowed through depth ${MAX_RECURSIVE_DEPTH}. Current recursive depth: ${options.nextSubagentDepth}/${MAX_RECURSIVE_DEPTH}.`;
60
66
 
61
67
  const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
62
68
  ${buildCompactTypeListText()}
@@ -67,7 +73,7 @@ Notes:
67
73
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
68
74
  - Parallel work: one message, multiple Agent calls; they all run in the background. You are notified when agents finish — never poll or sleep.
69
75
  - Background by default: when you have useful independent work, launch it and continue. Doing nothing while an agent runs is worse than letting background work proceed.
70
- - Recursive agents: current depth ${options.extensionDepth}/${MAX_RECURSIVE_DEPTH}; you may spawn subagents until depth ${MAX_RECURSIVE_DEPTH}.
76
+ - Recursive agents: current depth ${options.nextSubagentDepth}/${MAX_RECURSIVE_DEPTH}; you may spawn subagents until depth ${MAX_RECURSIVE_DEPTH}.
71
77
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
72
78
  - resume continues a previous agent by ID; steer_subagent messages a running one.
73
79
  - list_models enumerates the model registry the \`model:\` param accepts — call it before picking a model explicitly.
@@ -125,7 +131,7 @@ Terse command-style prompts produce shallow, generic work.
125
131
  compactTypeList: buildCompactTypeListText,
126
132
  agentDir: getAgentDir,
127
133
  scheduleGuideline: () => scheduleGuideline,
128
- currentDepth: () => String(options.extensionDepth),
134
+ currentDepth: () => String(options.nextSubagentDepth),
129
135
  maxDepth: () => String(MAX_RECURSIVE_DEPTH),
130
136
  recursiveGuideline: () => recursiveGuideline,
131
137
  };
@@ -36,6 +36,10 @@ export interface RpcDeps {
36
36
  pi: unknown; // passed through to manager.spawn
37
37
  getCtx: () => unknown | undefined; // returns current ExtensionContext
38
38
  manager: SpawnCapable;
39
+ /** Default recursive depth for RPC-spawned subagents in this session. */
40
+ depth?: number;
41
+ /** Parent subagent id for RPC-spawned subagents in this session. */
42
+ parentAgentId?: string;
39
43
  }
40
44
 
41
45
  export interface RpcHandle {
@@ -108,7 +112,16 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
108
112
  normalizedOptions = { ...normalizedOptions, model: resolved };
109
113
  }
110
114
 
111
- return { id: manager.spawn(pi, ctx, type, prompt, normalizedOptions) };
115
+ const spawnOptions = {
116
+ ...normalizedOptions,
117
+ eventBus: events,
118
+ depth: normalizedOptions.depth ?? deps.depth,
119
+ parentAgentId: normalizedOptions.parentAgentId ?? deps.parentAgentId,
120
+ };
121
+ if (spawnOptions.depth === undefined) delete spawnOptions.depth;
122
+ if (spawnOptions.parentAgentId === undefined) delete spawnOptions.parentAgentId;
123
+
124
+ return { id: manager.spawn(pi, ctx, type, prompt, spawnOptions) };
112
125
  },
113
126
  );
114
127
 
@@ -0,0 +1,291 @@
1
+ /**
2
+ * dashboard-ui.ts — Register dashboard UI modules for subagent visibility.
3
+ *
4
+ * Provides three integration points with pi-agent-dashboard:
5
+ * 1. Footer-segment decorator showing running/completed agent counts
6
+ * 2. Management-modal module with a table view of all subagent history
7
+ * 3. Round-trip event handlers for data fetch, abort, and steer actions
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import type { AgentManager } from "./agent-manager.js";
12
+ import { formatMs, getDisplayName } from "./ui/agent-widget.js";
13
+ import { getLifetimeTotal } from "./usage.js";
14
+
15
+ // Dashboard shared types (inlined to avoid adding a dependency)
16
+ interface DecoratorDescriptor {
17
+ kind: "footer-segment" | "agent-metric" | "breadcrumb" | "gate" | "toast";
18
+ namespace: string;
19
+ id: string;
20
+ payload: Record<string, unknown>;
21
+ }
22
+
23
+ interface ExtensionUiModule {
24
+ kind: "management-modal";
25
+ id: string;
26
+ command: string;
27
+ title: string;
28
+ description?: string;
29
+ icon?: string;
30
+ category?: string;
31
+ view: {
32
+ kind: "table" | "grid" | "form";
33
+ dataEvent?: string;
34
+ rowKey?: string;
35
+ fields?: Array<{
36
+ key: string;
37
+ label: string;
38
+ kind: string;
39
+ width?: string | number;
40
+ }>;
41
+ rowActions?: Array<{
42
+ id: string;
43
+ label: string;
44
+ icon?: string;
45
+ variant?: "primary" | "secondary" | "danger";
46
+ event: string;
47
+ confirm?: string;
48
+ }>;
49
+ emptyState?: string;
50
+ actions?: Array<{
51
+ id: string;
52
+ label: string;
53
+ icon?: string;
54
+ variant?: "primary" | "secondary" | "danger";
55
+ event: string;
56
+ }>;
57
+ };
58
+ }
59
+
60
+ type ModuleProbe = { modules: Array<ExtensionUiModule | DecoratorDescriptor> };
61
+
62
+ const NAMESPACE = "subagents";
63
+ const MODULE_ID = "subagents-overview";
64
+ const DATA_EVENT = "subagents:rows";
65
+ const INVALIDATE_DEBOUNCE_MS = 500;
66
+
67
+ /**
68
+ * Build a row for the management-modal table from an AgentRecord.
69
+ */
70
+ function buildAgentRow(record: any) {
71
+ const durationMs = record.completedAt
72
+ ? record.completedAt - record.startedAt
73
+ : Date.now() - record.startedAt;
74
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
75
+
76
+ return {
77
+ id: record.id,
78
+ type: getDisplayName(record.type),
79
+ description: record.description ?? "",
80
+ model: record.invocation?.modelName ?? "—",
81
+ status: record.status,
82
+ toolUses: record.toolUses ?? 0,
83
+ tokens: totalTokens > 0 ? formatTokenCount(totalTokens) : "—",
84
+ duration: formatMs(durationMs),
85
+ outputFile: record.outputFile ?? "",
86
+ startedAt: record.startedAt,
87
+ };
88
+ }
89
+
90
+ function formatTokenCount(n: number): string {
91
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
92
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
93
+ return String(n);
94
+ }
95
+
96
+ /**
97
+ * Register all dashboard UI integration points.
98
+ * Call once during extension setup when pi.events is available.
99
+ */
100
+ export function registerDashboardModules(pi: ExtensionAPI, manager: AgentManager): void {
101
+ if (!pi.events) return;
102
+
103
+ let invalidateTimer: ReturnType<typeof setTimeout> | undefined;
104
+
105
+ function scheduleInvalidate() {
106
+ if (invalidateTimer) return;
107
+ invalidateTimer = setTimeout(() => {
108
+ invalidateTimer = undefined;
109
+ pi.events.emit("ui:invalidate", {});
110
+ }, INVALIDATE_DEBOUNCE_MS);
111
+ }
112
+
113
+ // ── 1. Module Discovery (ui:list-modules) ──────────────────────────
114
+ // Guard against duplicate pushes: the bridge may call refreshUiModules
115
+ // multiple times per probe cycle when multiple sessions each register
116
+ // their own ui:invalidate listener. Check if our modules are already
117
+ // present before pushing.
118
+ pi.events.on("ui:list-modules", ((probe: ModuleProbe) => {
119
+ const alreadyContributed = probe.modules.some(
120
+ (m: any) => m.kind === "management-modal" && m.id === MODULE_ID,
121
+ );
122
+ if (alreadyContributed) return;
123
+
124
+ const agents = manager.listAgents();
125
+ const running = agents.filter(a => a.status === "running").length;
126
+ const completed = agents.filter(a => a.status === "completed").length;
127
+ const total = agents.length;
128
+
129
+ // Footer-segment: running/completed counts
130
+ const parts: string[] = [];
131
+ if (running > 0) parts.push(`● ${running} running`);
132
+ if (completed > 0) parts.push(`✓ ${completed} done`);
133
+ if (total === 0) parts.push("No agents");
134
+
135
+ probe.modules.push({
136
+ kind: "footer-segment",
137
+ namespace: NAMESPACE,
138
+ id: "agent-counts",
139
+ payload: {
140
+ text: parts.join(" · "),
141
+ tooltip: `${total} total agents (${running} running, ${completed} completed)`,
142
+ icon: "mdiRobot",
143
+ },
144
+ } as DecoratorDescriptor);
145
+
146
+ // Management-modal: subagent overview table
147
+ probe.modules.push({
148
+ kind: "management-modal",
149
+ id: MODULE_ID,
150
+ command: "/subagents",
151
+ title: "Subagents",
152
+ description: "View and manage background subagents",
153
+ icon: "mdiRobotOutline",
154
+ category: "subagents",
155
+ view: {
156
+ kind: "table",
157
+ dataEvent: DATA_EVENT,
158
+ rowKey: "id",
159
+ fields: [
160
+ { key: "id", label: "ID", kind: "text", width: 120 },
161
+ { key: "type", label: "Type", kind: "text", width: 100 },
162
+ { key: "description", label: "Description", kind: "text" },
163
+ { key: "model", label: "Model", kind: "text", width: 80 },
164
+ { key: "status", label: "Status", kind: "text", width: 90 },
165
+ { key: "toolUses", label: "Tools", kind: "number", width: 60 },
166
+ { key: "tokens", label: "Tokens", kind: "text", width: 80 },
167
+ { key: "duration", label: "Duration", kind: "text", width: 80 },
168
+ ],
169
+ rowActions: [
170
+ {
171
+ id: "view-result",
172
+ label: "View Result",
173
+ icon: "mdiEye",
174
+ variant: "primary",
175
+ event: "subagents:ui:view-result",
176
+ },
177
+ {
178
+ id: "abort",
179
+ label: "Abort",
180
+ icon: "mdiStop",
181
+ variant: "danger",
182
+ event: "subagents:ui:abort",
183
+ confirm: "Abort this running agent?",
184
+ },
185
+ {
186
+ id: "steer",
187
+ label: "Steer",
188
+ icon: "mdiMessageArrowRight",
189
+ variant: "secondary",
190
+ event: "subagents:ui:steer",
191
+ },
192
+ ],
193
+ emptyState: "No subagents have been spawned in this session.",
194
+ actions: [
195
+ {
196
+ id: "refresh",
197
+ label: "Refresh",
198
+ icon: "mdiRefresh",
199
+ variant: "secondary",
200
+ event: "subagents:ui:refresh",
201
+ },
202
+ ],
203
+ },
204
+ } as ExtensionUiModule);
205
+ }) as any);
206
+
207
+ // ── 2. Data Fetch Handler ──────────────────────────────────────────
208
+ pi.events.on(DATA_EVENT, ((data: any) => {
209
+ const agents = manager.listAgents();
210
+ data.items = agents.map(buildAgentRow);
211
+ }) as any);
212
+
213
+ // ── 3. Action Handlers ─────────────────────────────────────────────
214
+
215
+ // Refresh: just invalidate to re-probe + re-fetch
216
+ pi.events.on("subagents:ui:refresh", (() => {
217
+ scheduleInvalidate();
218
+ }) as any);
219
+
220
+ // View Result: return the agent's result as table rows so the modal
221
+ // displays it. The bridge's synchronous fast path calls `_reply(items)`
222
+ // when `data.items` is populated by the handler — do NOT call
223
+ // `scheduleInvalidate()` here as the subsequent re-probe would
224
+ // overwrite the returned rows with the original table data.
225
+ pi.events.on("subagents:ui:view-result", ((data: any) => {
226
+ // Bridge spreads msg.params into data; row identity is at data.row.id.
227
+ const agentId = data.row?.id ?? data.id;
228
+ if (!agentId) return;
229
+ const record = manager.getRecord(agentId);
230
+ if (!record) return;
231
+
232
+ const resultText = record.result?.trim() || "No output yet.";
233
+ const preview = resultText.length > 2000
234
+ ? resultText.slice(0, 2000) + "\n…(truncated)"
235
+ : resultText;
236
+
237
+ // Populate data.items — the bridge's synchronous fast path forwards
238
+ // this as a `ui_data_list` message back to the dashboard.
239
+ data.items = [{
240
+ id: record.id,
241
+ type: getDisplayName(record.type),
242
+ description: record.description,
243
+ status: record.status,
244
+ result: preview,
245
+ outputFile: record.outputFile ?? "",
246
+ }];
247
+ }) as any);
248
+
249
+ // Abort: stop the running agent via the manager's abort() method
250
+ // which properly cancels the AbortController and cleans up state.
251
+ pi.events.on("subagents:ui:abort", ((data: any) => {
252
+ const agentId = data.row?.id ?? data.id;
253
+ if (!agentId) return;
254
+ manager.abort(agentId);
255
+ scheduleInvalidate();
256
+ }) as any);
257
+
258
+ // Steer: send a steering message to a running agent's session.
259
+ // The management-modal row action carries the row identity; we steer
260
+ // with a default "Continue" nudge. A future form view could accept
261
+ // custom text.
262
+ pi.events.on("subagents:ui:steer", ((data: any) => {
263
+ const agentId = data.row?.id ?? data.id;
264
+ if (!agentId) return;
265
+ const record = manager.getRecord(agentId);
266
+ if (!record) return;
267
+
268
+ if (record.status === "running" && record.session) {
269
+ // Session is live — steer immediately
270
+ record.session.steer("Continue").catch(() => {});
271
+ } else if (record.status === "queued") {
272
+ // Session not yet created — queue the steer for flush on start
273
+ if (!record.pendingSteers) record.pendingSteers = [];
274
+ record.pendingSteers.push("Continue");
275
+ }
276
+ scheduleInvalidate();
277
+ }) as any);
278
+
279
+ // ── 4. Invalidate on agent lifecycle events ────────────────────────
280
+ const lifecycleEvents = [
281
+ "subagents:created",
282
+ "subagents:started",
283
+ "subagents:completed",
284
+ "subagents:failed",
285
+ "subagents:compacted",
286
+ ];
287
+
288
+ for (const event of lifecycleEvents) {
289
+ pi.events.on(event, (() => scheduleInvalidate()) as any);
290
+ }
291
+ }