pi-subagents-lite 0.2.0

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/index.ts ADDED
@@ -0,0 +1,1356 @@
1
+ /**
2
+ * index.ts — Local subagents extension entry point.
3
+ *
4
+ * Registers tools, commands, and event listeners at init time.
5
+ *
6
+ * Stealth tool registration:
7
+ * - All tools register at extension init (not runtime)
8
+ * - description: "." (single character — tells LLM nothing)
9
+ * - No promptSnippet, no promptGuidelines
10
+ * - Parameters without .description()
11
+ * - Model parameter removed from schema — injected via tool_call listener
12
+ *
13
+ * Config:
14
+ * - Loaded from ~/.pi/agent/subagents-lite.json at session_start
15
+ * - Module-level __config cache; tool_call reads from cache
16
+ * - Config mutations update cache + atomic write to disk
17
+ * - Migrates subagent-model-defaults.json on first load
18
+ *
19
+ * Commands:
20
+ * - /agents: Management menu with 5 sub-menus
21
+ *
22
+ * Events:
23
+ * - tool_call: Inject model into Agent tool calls
24
+ * - session_start: Load config, register agents, initialise manager
25
+ * - session_shutdown: Abort all, dispose manager
26
+ */
27
+
28
+ import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
29
+ import { Type } from "@sinclair/typebox";
30
+ import * as fs from "node:fs";
31
+ import * as path from "node:path";
32
+ import type {
33
+ ExtensionAPI,
34
+ ExtensionCommandContext,
35
+ ExtensionContext,
36
+ ToolCallEvent,
37
+ } from "@earendil-works/pi-coding-agent";
38
+ import type { Model } from "@earendil-works/pi-ai";
39
+ import type { SubagentsConfig } from "./model-precedence.js";
40
+ import { resolveModel } from "./model-precedence.js";
41
+ import { resolveType, getAgentConfig, registerAgents, getAvailableTypes, getAllTypes } from "./agent-types.js";
42
+ import { scanAgentFilesInDir, mergeAgents } from "./agent-discovery.js";
43
+ import { steerAgent } from "./agent-runner.js";
44
+ import type { AgentRecord, ThinkingLevel } from "./types.js";
45
+ import { ModelSelectorDialog, type ModelOption } from "./model-selector.js";
46
+ import { ResultViewer } from "./result-viewer.js";
47
+ import { AgentManager } from "./agent-manager.js";
48
+ import type { SpawnOptions as AgentManagerSpawnOptions } from "./agent-manager.js";
49
+ import { AgentWidget, formatTurns, formatMs, formatSessionTokens, getDisplayName, type AgentActivity, type UICtx } from "./ui/agent-widget.js";
50
+ import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
51
+
52
+ // ============================================================================
53
+ // Constants
54
+ // ============================================================================
55
+
56
+ const CONFIG_DIR = path.join(process.env.HOME || "", ".pi", "agent");
57
+ const CONFIG_PATH = path.join(CONFIG_DIR, "subagents-lite.json");
58
+ // ============================================================================
59
+ // Module-level state
60
+ // ============================================================================
61
+
62
+ /** Config cache — loaded at session_start, updated by /agents menu mutations. */
63
+ let __config: SubagentsConfig = {
64
+ agent: { default: null },
65
+ concurrency: { default: 4 },
66
+ };
67
+
68
+ /** Agent manager singleton — module-level, no globalThis access. */
69
+ let manager: AgentManager;
70
+
71
+ /** Live activity state per agent, keyed by agent ID. Read by AgentWidget for rendering. */
72
+ const agentActivity = new Map<string, AgentActivity>();
73
+
74
+ /** Live TUI widget showing running/completed agents above the editor. */
75
+ let widget: AgentWidget | undefined;
76
+
77
+ /** ExtensionAPI reference — stored at init for execute callbacks. */
78
+ let piInstance: ExtensionAPI;
79
+
80
+ // ============================================================================
81
+ // Nudge scheduling (200ms hold to batch completion notifications)
82
+ // ============================================================================
83
+
84
+ /** Agent IDs that were spawned as background — only these trigger a nudge on completion. */
85
+ const backgroundAgentIds = new Set<string>();
86
+
87
+ const pendingNudges = new Set<string>();
88
+ let nudgeTimer: ReturnType<typeof setTimeout> | null = null;
89
+
90
+ function scheduleNudge(agentId: string, record: AgentRecord): void {
91
+ pendingNudges.add(agentId);
92
+
93
+ if (nudgeTimer) return;
94
+
95
+ nudgeTimer = setTimeout(() => {
96
+ nudgeTimer = null;
97
+ const batch = [...pendingNudges];
98
+ pendingNudges.clear();
99
+
100
+ for (const id of batch) {
101
+ emitIndividualNudge(id, manager?.getRecord(id));
102
+ }
103
+ }, 200);
104
+ }
105
+
106
+ function emitIndividualNudge(agentId: string, record?: AgentRecord): void {
107
+ if (!record) return;
108
+
109
+ // Stats go in details only (rendered by the UI message renderer).
110
+ // Content is just the result text — the model only sees this.
111
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
112
+ const elapsedMs = record.completedAt
113
+ ? record.completedAt - record.startedAt
114
+ : 0;
115
+
116
+ const details: Record<string, unknown> = {
117
+ type: record.type,
118
+ description: record.description,
119
+ status: record.status,
120
+ outputFile: record.outputFile,
121
+ turnCount: record.turnCount ?? agentActivity.get(agentId)?.turnCount,
122
+ maxTurns: record.maxTurns,
123
+ toolUses: record.toolUses,
124
+ tokens: totalTokens,
125
+ contextPercent: getSessionContextPercent(record.session),
126
+ durationMs: elapsedMs,
127
+ compactions: record.compactionCount,
128
+ };
129
+
130
+ // Deliver the result directly to the session so the model sees it.
131
+ piInstance.sendMessage(
132
+ {
133
+ customType: "subagent-result",
134
+ content: record.result ?? "",
135
+ details,
136
+ display: true,
137
+ },
138
+ {
139
+ deliverAs: "steer",
140
+ triggerTurn: true,
141
+ },
142
+ );
143
+ }
144
+
145
+ // ============================================================================
146
+ // Tool result helpers
147
+ // ============================================================================
148
+
149
+ /** Shortcut for a successful tool result. */
150
+ function successResult(text: string, details?: Record<string, unknown>) {
151
+ return { content: [{ type: "text", text }], details };
152
+ }
153
+
154
+ /** Shortcut for an error tool result. */
155
+ function errorResult(text: string, details?: Record<string, unknown>) {
156
+ return { content: [{ type: "text", text }], isError: true as const, details };
157
+ }
158
+
159
+ // ============================================================================
160
+ // Helpers
161
+ // ============================================================================
162
+
163
+ /**
164
+ * Parse a "provider/model-id" string into { provider, modelId }.
165
+ * Returns null if the format is invalid.
166
+ */
167
+ function parseModelKey(modelStr: string): { provider: string; modelId: string } | null {
168
+ const slashIdx = modelStr.indexOf("/");
169
+ if (slashIdx <= 0) return null;
170
+ return { provider: modelStr.slice(0, slashIdx), modelId: modelStr.slice(slashIdx + 1) };
171
+ }
172
+
173
+ /**
174
+ * Build ModelOption[] from raw "provider/model-id" strings.
175
+ * Includes "(inherits parent)" as the first option.
176
+ */
177
+ function buildModelOptions(rawOptions: string[]): ModelOption[] {
178
+ const items: ModelOption[] = [
179
+ { value: "(inherits parent)", label: "(inherits parent)", provider: "" },
180
+ ];
181
+
182
+ for (const opt of rawOptions) {
183
+ const parsed = parseModelKey(opt);
184
+ if (!parsed) continue;
185
+ items.push({ value: opt, label: parsed.modelId, provider: parsed.provider });
186
+ }
187
+ return items;
188
+ }
189
+
190
+ // ============================================================================
191
+ // Config persistence (atomic writes)
192
+ // ============================================================================
193
+
194
+ function loadConfig(): SubagentsConfig {
195
+ try {
196
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
197
+ return JSON.parse(raw) as SubagentsConfig;
198
+ } catch {
199
+ // File doesn't exist or is invalid — return defaults
200
+ }
201
+
202
+ return {
203
+ agent: { default: null },
204
+ concurrency: { default: 4 },
205
+ };
206
+ }
207
+
208
+ function saveConfigAtomic(config: SubagentsConfig): void {
209
+ const tmpPath = CONFIG_PATH + ".tmp";
210
+ try {
211
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
212
+ fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
213
+ fs.renameSync(tmpPath, CONFIG_PATH);
214
+ } catch (err) {
215
+ console.error(`[subagents] Failed to save config: ${err}`);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Show the ModelSelectorDialog and return the chosen model string, or null.
221
+ */
222
+ async function promptModelSelection(
223
+ ctx: ExtensionCommandContext,
224
+ modelOptions: string[],
225
+ currentValue: string,
226
+ ): Promise<string | null> {
227
+ return ctx.ui.custom<string | null>(
228
+ (tui, theme, _kb, done) => {
229
+ const opts = buildModelOptions(modelOptions);
230
+ return new ModelSelectorDialog(opts, currentValue, {
231
+ onSelect: (m) => done(m),
232
+ onCancel: () => done(null),
233
+ }, theme);
234
+ }, // no overlay — renders inline below editor, matching pi's model selector look and feel
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Show a select menu and dispatch the chosen action.
240
+ * Pattern used by model settings, concurrency settings, and running agents menus.
241
+ */
242
+ async function runMenu(
243
+ ctx: ExtensionCommandContext,
244
+ title: string,
245
+ items: string[],
246
+ actions: Array<() => Promise<void>>,
247
+ ): Promise<void> {
248
+ const choice = await ctx.ui.select(title, items);
249
+ if (choice === undefined) return;
250
+ const idx = items.indexOf(choice);
251
+ if (idx >= 0 && idx < actions.length) {
252
+ await actions[idx]();
253
+ }
254
+ }
255
+
256
+ // ============================================================================
257
+ // /agents command handler
258
+ // ============================================================================
259
+
260
+ async function showAgentsMainMenu(
261
+ ctx: ExtensionCommandContext,
262
+ modelOptions: string[],
263
+ ): Promise<void> {
264
+ const menuItems = [
265
+ "1. Model settings — Set global default and per-type model overrides",
266
+ "2. Concurrency settings — Set per-model slot limits",
267
+ "3. Running agents — List running/queued agents",
268
+ "4. Agent types — List available agent types and their configs",
269
+ "5. Agent briefing — Send agent types/capabilities info to LLM (Optional, if having issues)",
270
+ "",
271
+ "Press Escape to close",
272
+ ];
273
+
274
+ // Loop so sub-menus navigate back to root; only Escape at root closes
275
+ while (true) {
276
+ const choice = await ctx.ui.select("Subagents Management", menuItems);
277
+ if (choice === undefined || choice === "Press Escape to close") return;
278
+
279
+ if (choice.startsWith("1.")) {
280
+ await showModelSettingsMenu(ctx, modelOptions);
281
+ } else if (choice.startsWith("2.")) {
282
+ await showConcurrencySettingsMenu(ctx, modelOptions);
283
+ } else if (choice.startsWith("3.")) {
284
+ await showRunningAgentsMenu(ctx);
285
+ } else if (choice.startsWith("4.")) {
286
+ await showAgentTypes(ctx);
287
+ } else if (choice.startsWith("5.")) {
288
+ await handleAgentBriefing(ctx);
289
+ }
290
+ }
291
+ }
292
+
293
+ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void> {
294
+ const types = getAvailableTypes();
295
+ const agents = types.map((t) => ({ name: t, config: getAgentConfig(t) }));
296
+
297
+ const lines: string[] = [
298
+ "# Agent Types and Capabilities\n",
299
+ "The following agent types are available. Use the `agent` parameter to select one.\n",
300
+ ];
301
+
302
+ for (const { name, config } of agents) {
303
+ if (!config) continue;
304
+ lines.push(`## ${config.displayName ?? name}`);
305
+ lines.push(config.description);
306
+ lines.push("");
307
+
308
+ if (config.builtinToolNames) {
309
+ lines.push(`**Tools:** ${config.builtinToolNames.join(", ")}`);
310
+ }
311
+ if (config.model) {
312
+ lines.push(`**Default model:** ${config.model}`);
313
+ }
314
+ if (config.maxTurns) {
315
+ lines.push(`**Max turns:** ${config.maxTurns}`);
316
+ }
317
+ lines.push("");
318
+ }
319
+
320
+ // Parameter descriptions
321
+ lines.push("## Agent Tool Parameters\n");
322
+ lines.push("| Parameter | Description |");
323
+ lines.push("|-----------|-------------|");
324
+ lines.push("| `prompt` | The task for the agent (required) |");
325
+ lines.push("| `description` | One-line summary of what the agent should do (required) |");
326
+ lines.push("| `agent` | Which agent type to use (default: general-purpose) |");
327
+ lines.push("| `thinking` | Optional thinking mode override (e.g., `high`, `medium`, `low`, `off`) |");
328
+ lines.push("| `run_in_background` | When `true`, result is auto-delivered — do NOT poll. Continue working while waiting. |");
329
+ lines.push("| `resume` | Agent ID to resume from; when set, `prompt` is appended to the previous conversation |");
330
+ lines.push("");
331
+
332
+ // Usage guidelines
333
+ lines.push("## Usage Guidelines\n");
334
+ lines.push("- Agents start fresh with their config — they do NOT inherit the parent conversation");
335
+ lines.push("- For parallel tasks, spawn multiple `run_in_background: true` agents in one turn");
336
+ lines.push(" → Results are auto-delivered — do NOT poll, the result will arrive when ready");
337
+ lines.push("- Use `resume` to continue an incomplete agent's conversation");
338
+ piInstance.sendUserMessage(lines.join("\n"));
339
+ ctx.ui.notify("Agent briefing sent to LLM", "info");
340
+ }
341
+
342
+ async function showModelSettingsMenu(
343
+ ctx: ExtensionCommandContext,
344
+ modelOptions: string[],
345
+ ): Promise<void> {
346
+ // Loop so actions stay in this menu; only Back/Escape leaves
347
+ while (true) {
348
+ const items: string[] = [];
349
+ const actions: Array<() => Promise<void>> = [];
350
+
351
+ // Global default
352
+ const globalLabel = __config.agent.default
353
+ ? `Global default model · ${__config.agent.default}`
354
+ : "Global default model · (inherits parent)";
355
+ items.push(globalLabel);
356
+ actions.push(async () => {
357
+ const chosen = await promptModelSelection(
358
+ ctx, modelOptions, __config.agent.default ?? "(inherits parent)",
359
+ );
360
+ if (chosen === null) return;
361
+
362
+ const updated = { ...__config };
363
+ updated.agent = { ...updated.agent };
364
+ updated.agent.default = chosen === "(inherits parent)" ? null : chosen;
365
+ __config = updated;
366
+ saveConfigAtomic(updated);
367
+ ctx.ui.notify(
368
+ chosen === "(inherits parent)"
369
+ ? "Global default cleared — agents inherit parent model"
370
+ : `Global default model set to ${chosen}`,
371
+ "info",
372
+ );
373
+ });
374
+
375
+ items.push("─── per-type overrides ───");
376
+ actions.push(async () => {}); // separator
377
+
378
+ // Per-type overrides
379
+ const types = getAllTypes();
380
+ for (const typeName of types) {
381
+ const cfg = getAgentConfig(typeName);
382
+ const currentOverride = __config.agent[typeName];
383
+ const displayModel = currentOverride
384
+ ? currentOverride
385
+ : (cfg?.model ?? __config.agent.default ?? "(inherits parent)");
386
+ const frontmatterHint = currentOverride && cfg?.model ? ` → ${cfg.model}` : "";
387
+ items.push(`${typeName} · ${displayModel}${frontmatterHint}`);
388
+
389
+ actions.push(async () => {
390
+ const currentDisplay = __config.agent[typeName] ?? cfg?.model ?? __config.agent.default ?? "(inherits parent)";
391
+ const chosen = await promptModelSelection(ctx, modelOptions, currentDisplay);
392
+ if (chosen === null) return;
393
+
394
+ const updated = { ...__config };
395
+ updated.agent = { ...updated.agent };
396
+ updated.agent[typeName] = chosen === "(inherits parent)" ? null : chosen;
397
+ __config = updated;
398
+ saveConfigAtomic(updated);
399
+ ctx.ui.notify(
400
+ chosen === "(inherits parent)"
401
+ ? `${typeName} inherits parent model`
402
+ : `${typeName} model set to ${chosen}`,
403
+ "info",
404
+ );
405
+ });
406
+ }
407
+
408
+ // Clear all overrides
409
+ items.push("Clear all overrides");
410
+ actions.push(async () => {
411
+ const hasOverrides = Object.entries(__config.agent).some(
412
+ ([k, v]) => k !== "default" && v != null,
413
+ );
414
+ if (!hasOverrides && __config.agent.default === null) {
415
+ ctx.ui.notify("No overrides to clear", "info");
416
+ return;
417
+ }
418
+ const updated = { ...__config };
419
+ updated.agent = { default: __config.agent.default };
420
+ __config = updated;
421
+ saveConfigAtomic(updated);
422
+ ctx.ui.notify("All model overrides cleared", "info");
423
+ });
424
+
425
+ // Append blank spacer + "Back" as the last items
426
+ items.push("");
427
+ actions.push(async () => {});
428
+ items.push("Back");
429
+ actions.push(async () => {});
430
+
431
+ const choice = await ctx.ui.select("Model Settings", items);
432
+ if (choice === undefined || choice === "Back") return;
433
+ const idx = items.indexOf(choice);
434
+ if (idx >= 0 && idx < actions.length) {
435
+ await actions[idx]();
436
+ }
437
+ }
438
+ }
439
+
440
+ async function showConcurrencySettingsMenu(
441
+ ctx: ExtensionCommandContext,
442
+ modelOptions: string[],
443
+ ): Promise<void> {
444
+ // Loop so actions stay in this menu; only Back/Escape leaves
445
+ while (true) {
446
+ const items: string[] = [];
447
+ const actions: Array<() => Promise<void>> = [];
448
+
449
+ // Global default
450
+ items.push(`Default concurrency limit · ${__config.concurrency.default}`);
451
+ actions.push(async () => {
452
+ const input = await ctx.ui.input(
453
+ "Default concurrency limit",
454
+ String(__config.concurrency.default),
455
+ );
456
+ if (input === undefined) return;
457
+ const parsed = parseInt(input.trim(), 10);
458
+ if (isNaN(parsed) || parsed < 1) {
459
+ ctx.ui.notify("Invalid value — must be a number ≥ 1", "error");
460
+ return;
461
+ }
462
+ const updated = { ...__config };
463
+ updated.concurrency = { ...updated.concurrency, default: parsed };
464
+ __config = updated;
465
+ saveConfigAtomic(updated);
466
+ ctx.ui.notify(`Default concurrency limit set to ${parsed}`, "info");
467
+ manager?.setConcurrency({
468
+ default: __config.concurrency.default,
469
+ providers: __config.concurrency.providers ?? {},
470
+ models: __config.concurrency.models ?? {},
471
+ });
472
+ });
473
+
474
+ // Extract unique providers from model options
475
+ const providers = [...new Set(modelOptions.map((m) => m.split("/")[0]))].sort();
476
+
477
+ // Per-provider limits
478
+ const providerLimits = __config.concurrency.providers ?? {};
479
+ const configuredProviders = Object.keys(providerLimits);
480
+ if (configuredProviders.length > 0) {
481
+ items.push("─── per-provider limits ───");
482
+ actions.push(async () => {}); // separator
483
+
484
+ for (const provider of configuredProviders) {
485
+ items.push(`${provider} · ${providerLimits[provider]} slots`);
486
+ actions.push(async () => {
487
+ const input = await ctx.ui.input(
488
+ `Concurrency slots for ${provider}`,
489
+ String(providerLimits[provider]),
490
+ );
491
+ if (input === undefined) return;
492
+ const parsed = parseInt(input.trim(), 10);
493
+ if (isNaN(parsed) || parsed < 1) {
494
+ ctx.ui.notify("Invalid value — must be a number ≥ 1", "error");
495
+ return;
496
+ }
497
+ const updated = { ...__config };
498
+ updated.concurrency.providers = { ...providerLimits, [provider]: parsed };
499
+ __config = updated;
500
+ saveConfigAtomic(updated);
501
+ ctx.ui.notify(`${provider} concurrency set to ${parsed}`, "info");
502
+ manager?.setConcurrency({
503
+ default: __config.concurrency.default,
504
+ providers: __config.concurrency.providers ?? {},
505
+ models: __config.concurrency.models ?? {},
506
+ });
507
+ });
508
+ }
509
+ }
510
+
511
+ // Add per-provider limit
512
+ items.push("Add per-provider limit...");
513
+ actions.push(async () => {
514
+ const currentProviders = __config.concurrency.providers ?? {};
515
+ const provider = await ctx.ui.select("Select provider", providers);
516
+ if (provider === undefined) return;
517
+ const input = await ctx.ui.input("Concurrency slots", "1");
518
+ if (input === undefined) return;
519
+ const parsed = parseInt(input.trim(), 10);
520
+ if (isNaN(parsed) || parsed < 1) {
521
+ ctx.ui.notify("Invalid value — must be a number ≥ 1", "error");
522
+ return;
523
+ }
524
+ const updated = { ...__config };
525
+ updated.concurrency.providers = { ...currentProviders, [provider]: parsed };
526
+ __config = updated;
527
+ saveConfigAtomic(updated);
528
+ ctx.ui.notify(`${provider} concurrency set to ${parsed}`, "info");
529
+ manager?.setConcurrency({
530
+ default: __config.concurrency.default,
531
+ providers: __config.concurrency.providers ?? {},
532
+ models: __config.concurrency.models ?? {},
533
+ });
534
+ });
535
+
536
+ // Per-model limits
537
+ const models = __config.concurrency.models ?? {};
538
+ const modelKeys = Object.keys(models);
539
+ if (modelKeys.length > 0) {
540
+ items.push("─── per-model limits ───");
541
+ actions.push(async () => {}); // separator
542
+
543
+ for (const modelKey of modelKeys) {
544
+ items.push(`${modelKey} · ${models[modelKey]} slots`);
545
+ actions.push(async () => {
546
+ const input = await ctx.ui.input(
547
+ `Concurrency slots for ${modelKey}`,
548
+ String(models[modelKey]),
549
+ );
550
+ if (input === undefined) return;
551
+ const parsed = parseInt(input.trim(), 10);
552
+ if (isNaN(parsed) || parsed < 1) {
553
+ ctx.ui.notify("Invalid value — must be a number ≥ 1", "error");
554
+ return;
555
+ }
556
+ const updated = { ...__config };
557
+ updated.concurrency.models = { ...models, [modelKey]: parsed };
558
+ __config = updated;
559
+ saveConfigAtomic(updated);
560
+ ctx.ui.notify(`${modelKey} concurrency set to ${parsed}`, "info");
561
+ manager?.setConcurrency({
562
+ default: __config.concurrency.default,
563
+ providers: __config.concurrency.providers ?? {},
564
+ models: __config.concurrency.models ?? {},
565
+ });
566
+ });
567
+ }
568
+ }
569
+
570
+ // Add per-model limit
571
+ items.push("Add per-model limit...");
572
+ actions.push(async () => {
573
+ const currentModels = __config.concurrency.models ?? {};
574
+ const modelKey = await promptModelSelection(
575
+ ctx,
576
+ modelOptions,
577
+ __config.agent.default ?? "(inherits parent)",
578
+ );
579
+ if (modelKey === null) return;
580
+ const input = await ctx.ui.input("Concurrency slots", "1");
581
+ if (input === undefined) return;
582
+ const parsed = parseInt(input.trim(), 10);
583
+ if (isNaN(parsed) || parsed < 1) {
584
+ ctx.ui.notify("Invalid value — must be a number ≥ 1", "error");
585
+ return;
586
+ }
587
+ const updated = { ...__config };
588
+ updated.concurrency.models = { ...currentModels, [modelKey.trim()]: parsed };
589
+ __config = updated;
590
+ saveConfigAtomic(updated);
591
+ ctx.ui.notify(`${modelKey.trim()} concurrency set to ${parsed}`, "info");
592
+ manager?.setConcurrency({
593
+ default: __config.concurrency.default,
594
+ providers: __config.concurrency.providers ?? {},
595
+ models: __config.concurrency.models ?? {},
596
+ });
597
+ });
598
+
599
+ // Append blank spacer + "Back" as the last items
600
+ items.push("");
601
+ actions.push(async () => {});
602
+ items.push("Back");
603
+ actions.push(async () => {});
604
+
605
+ const choice = await ctx.ui.select("Concurrency Settings", items);
606
+ if (choice === undefined || choice === "Back") return;
607
+ const idx = items.indexOf(choice);
608
+ if (idx >= 0 && idx < actions.length) {
609
+ await actions[idx]();
610
+ }
611
+ }
612
+ }
613
+
614
+ async function showRunningAgentsMenu(
615
+ ctx: ExtensionCommandContext,
616
+ ): Promise<void> {
617
+ // Loop so sub-actions navigate back to this menu; only Escape closes
618
+ while (true) {
619
+ const records = manager?.listAgents() ?? [];
620
+ const running = records.filter((r) => r.status === "running" || r.status === "queued");
621
+
622
+ if (records.length === 0) {
623
+ ctx.ui.notify("No agents have been spawned this session", "info");
624
+ return;
625
+ }
626
+
627
+ const items: string[] = [];
628
+ const actions: Array<() => Promise<void>> = [];
629
+
630
+ for (const record of records) {
631
+ const elapsed = Math.round((Date.now() - record.startedAt) / 1000);
632
+ const statusIcon = record.status === "running" ? "▶" :
633
+ record.status === "completed" ? "✓" :
634
+ record.status === "queued" ? "⏳" :
635
+ record.status === "error" ? "✗" : "•";
636
+ items.push(
637
+ `${statusIcon} ${record.id.slice(0, 8)} ${record.type} ${record.status} ${elapsed}s`,
638
+ );
639
+
640
+ actions.push(async () => {
641
+ await showAgentActions(ctx, record);
642
+ });
643
+ }
644
+
645
+ if (running.length > 0) {
646
+ items.push("─── actions ───");
647
+ actions.push(async () => {}); // separator
648
+
649
+ items.push(`Stop ${running.length} running agent(s)`);
650
+ actions.push(async () => {
651
+ for (const record of running) {
652
+ manager?.abort(record.id);
653
+ }
654
+ ctx.ui.notify(`Stopped ${running.length} agent(s)`, "info");
655
+ });
656
+ }
657
+
658
+ // Append blank spacer + "Back" as the last items
659
+ items.push("");
660
+ actions.push(async () => {});
661
+ items.push("Back");
662
+ actions.push(async () => {});
663
+
664
+ const choice = await ctx.ui.select("Running Agents", items);
665
+ if (choice === undefined || choice === "Back") return;
666
+ const idx = items.indexOf(choice);
667
+ if (idx >= 0 && idx < actions.length) {
668
+ await actions[idx]();
669
+ }
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Send a steer message to a specific agent. Used by the per-agent action menu.
675
+ */
676
+ async function steerAgentById(
677
+ agentId: string,
678
+ ctx: ExtensionCommandContext,
679
+ ): Promise<void> {
680
+ const record = manager?.getRecord(agentId);
681
+ if (!record) {
682
+ ctx.ui.notify("Agent not found", "error");
683
+ return;
684
+ }
685
+
686
+ const message = await ctx.ui.input(`Steer ${record.type}`);
687
+ if (!message?.trim()) return;
688
+
689
+ try {
690
+ if (!record.session) {
691
+ if (!record.pendingSteers) {
692
+ record.pendingSteers = [];
693
+ }
694
+ record.pendingSteers.push(message.trim());
695
+ ctx.ui.notify(`Steer message queued for ${record.id.slice(0, 8)}…`, "info");
696
+ } else {
697
+ await steerAgent(record.session, message.trim());
698
+ ctx.ui.notify(`Steer sent to ${record.id.slice(0, 8)}…`, "info");
699
+ }
700
+ } catch (err) {
701
+ const msg = err instanceof Error ? err.message : String(err);
702
+ ctx.ui.notify(`Steer failed: ${msg}`, "error");
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Sub-menu with actions for a single agent. Replaces the old showAgentDetail
708
+ * notify popup — clicking an agent in the running agents menu opens actions.
709
+ */
710
+ async function showAgentActions(
711
+ ctx: ExtensionCommandContext,
712
+ record: AgentRecord,
713
+ ): Promise<void> {
714
+ const items: string[] = [];
715
+ const actions: Array<() => Promise<void>> = [];
716
+
717
+ const isRunning = record.status === "running" || record.status === "queued";
718
+ const hasResult = !!record.result && record.result.length > 0;
719
+ const hasError = !!record.error && record.error.length > 0;
720
+
721
+ if (isRunning) {
722
+ items.push("Steer");
723
+ actions.push(async () => {
724
+ await steerAgentById(record.id, ctx);
725
+ });
726
+
727
+ items.push("Stop");
728
+ actions.push(async () => {
729
+ manager?.abort(record.id);
730
+ ctx.ui.notify(`Stopped ${record.id.slice(0, 8)}`, "info");
731
+ });
732
+ }
733
+
734
+ if (hasResult) {
735
+ items.push("View result");
736
+ actions.push(async () => {
737
+ await ctx.ui.custom<void>(
738
+ (tui, theme, _kb, done) =>
739
+ new ResultViewer(
740
+ `${getDisplayName(record.type)} · ${record.id.slice(0, 8)}`,
741
+ record.result!,
742
+ { onClose: () => done() },
743
+ theme,
744
+ ),
745
+ );
746
+ });
747
+ }
748
+
749
+ if (hasError) {
750
+ items.push("View error");
751
+ actions.push(async () => {
752
+ await ctx.ui.custom<void>(
753
+ (tui, theme, _kb, done) =>
754
+ new ResultViewer(
755
+ `${getDisplayName(record.type)} · Error`,
756
+ record.error!,
757
+ { onClose: () => done() },
758
+ theme,
759
+ ),
760
+ );
761
+ });
762
+ }
763
+
764
+ if (items.length === 0) {
765
+ ctx.ui.notify(`Agent ${record.id.slice(0, 8)} — no actions available`, "info");
766
+ return;
767
+ }
768
+
769
+ // Append blank spacer + "Back" as the last items
770
+ items.push("");
771
+ actions.push(async () => {});
772
+ items.push("Back");
773
+ actions.push(async () => {});
774
+
775
+ await runMenu(ctx, `Agent ${record.id.slice(0, 8)}`, items, actions);
776
+ }
777
+
778
+ async function showAgentTypes(ctx: ExtensionCommandContext): Promise<void> {
779
+ const types = getAllTypes();
780
+ if (types.length === 0) {
781
+ ctx.ui.notify("No agent types available", "info");
782
+ return;
783
+ }
784
+
785
+ const lines: string[] = ["Available agent types:\n"];
786
+ for (const name of types) {
787
+ const cfg = getAgentConfig(name);
788
+ if (!cfg) continue;
789
+ const disabled = cfg.enabled === false ? " [DISABLED]" : "";
790
+ const model = cfg.model ? ` Model: ${cfg.model}` : "";
791
+ const tools = cfg.builtinToolNames
792
+ ? ` Tools: ${cfg.builtinToolNames.join(", ")}`
793
+ : " Tools: all built-in tools";
794
+ const source = cfg.source ? ` Source: ${cfg.source}` : "";
795
+ lines.push(` ${name}${disabled}`);
796
+ lines.push(` ${cfg.description}`);
797
+ if (model) lines.push(model);
798
+ lines.push(tools);
799
+ if (source) lines.push(source);
800
+ lines.push("");
801
+ }
802
+
803
+ ctx.ui.notify(lines.join("\n"), "info");
804
+ }
805
+
806
+ // ============================================================================
807
+ // Config loader — session_start handler logic
808
+ // ============================================================================
809
+
810
+ /**
811
+ * Ensure the manager and widget singletons exist.
812
+ * Idempotent — safe to call on every session_start.
813
+ */
814
+ function ensureManagerAndWidget(): void {
815
+ if (manager) return;
816
+
817
+ const concurrencyConfig = {
818
+ default: __config.concurrency.default,
819
+ providers: __config.concurrency.providers ?? {},
820
+ models: __config.concurrency.models ?? {},
821
+ };
822
+ manager = new AgentManager(
823
+ (record) => {
824
+ // Only nudge for background (async) agents — sync agents already returned via tool result
825
+ if (backgroundAgentIds.has(record.id)) {
826
+ scheduleNudge(record.id, record);
827
+ backgroundAgentIds.delete(record.id);
828
+ }
829
+
830
+ // Mark finished and update widget BEFORE deleting activity —
831
+ // renderFinishedLine reads activity for turn count, tokens, etc.
832
+ widget?.markFinished(record.id);
833
+ widget?.update();
834
+
835
+ // Remove from live activity tracking
836
+ agentActivity.delete(record.id);
837
+ },
838
+ concurrencyConfig,
839
+ );
840
+
841
+ // Create/replace widget tied to this manager instance
842
+ if (!widget) {
843
+ widget = new AgentWidget(manager, agentActivity);
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Scan agent files from user and project directories, merge with defaults,
849
+ * and register into the type registry.
850
+ */
851
+ async function scanAndRegisterAgents(ctx: ExtensionContext): Promise<void> {
852
+ const homeDir = process.env.HOME || "";
853
+ const userAgentDir = path.join(homeDir, ".pi", "agent", "agents");
854
+ const projectAgentDir = path.join(ctx.cwd, ".pi", "agents");
855
+
856
+ const [userAgents, projectAgents] = await Promise.all([
857
+ scanAgentFilesInDir(userAgentDir, "user"),
858
+ scanAgentFilesInDir(projectAgentDir, "project"),
859
+ ]);
860
+
861
+ const { DEFAULT_AGENTS } = await import("./default-agents.js");
862
+
863
+ // Merge with defaults
864
+ const merged = mergeAgents(DEFAULT_AGENTS, userAgents, projectAgents);
865
+
866
+ // Register into the type registry
867
+ registerAgents(merged);
868
+ }
869
+
870
+ async function loadConfigAndRegisterAgents(ctx: ExtensionContext): Promise<void> {
871
+ // Load config (with migration if needed)
872
+ __config = loadConfig();
873
+
874
+ // Ensure manager exists
875
+ ensureManagerAndWidget();
876
+
877
+ // Scan agent files and register
878
+ await scanAndRegisterAgents(ctx);
879
+ }
880
+
881
+ // ============================================================================
882
+ // Activity tracking — bridge between spawn callbacks and widget renderer
883
+ // ============================================================================
884
+
885
+ /**
886
+ * Create an AgentActivity state and spawn callbacks for tracking tool usage.
887
+ * Used by both foreground and background paths to avoid duplication.
888
+ */
889
+ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
890
+ const state: AgentActivity = {
891
+ activeTools: new Map(),
892
+ toolUses: 0,
893
+ turnCount: 1,
894
+ maxTurns,
895
+ responseText: "",
896
+ session: undefined,
897
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
898
+ };
899
+
900
+ const callbacks = {
901
+ onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
902
+ if (activity.type === "start") {
903
+ state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
904
+ } else {
905
+ for (const [key, name] of state.activeTools) {
906
+ if (name === activity.toolName) { state.activeTools.delete(key); break; }
907
+ }
908
+ state.toolUses++;
909
+ }
910
+ onStreamUpdate?.();
911
+ },
912
+ onTextDelta: (_delta: string, fullText: string) => {
913
+ state.responseText = fullText;
914
+ onStreamUpdate?.();
915
+ },
916
+ onTurnEnd: (turnCount: number) => {
917
+ state.turnCount = turnCount;
918
+ onStreamUpdate?.();
919
+ },
920
+ onSessionCreated: (session: unknown) => {
921
+ state.session = session as Parameters<typeof getSessionContextPercent>[0];
922
+ },
923
+ onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
924
+ addUsage(state.lifetimeUsage, usage);
925
+ onStreamUpdate?.();
926
+ },
927
+ };
928
+
929
+ return { state, callbacks };
930
+ }
931
+
932
+ // ============================================================================
933
+ // Tool execute handlers
934
+ // ============================================================================
935
+
936
+ // These are wired in the registerTool calls below.
937
+ // We define them as functions here for clarity.
938
+
939
+ async function executeAgentTool(
940
+ _toolCallId: string,
941
+ params: Record<string, unknown>,
942
+ _signal: AbortSignal | undefined,
943
+ _onUpdate: ((update: any) => void) | undefined,
944
+ ctx: ExtensionContext,
945
+ ): Promise<any> {
946
+ // Resolve type — default to general-purpose when not specified
947
+ const type = (params.agent as string) || "general-purpose";
948
+ const resolvedType = resolveType(type);
949
+ if (!resolvedType) {
950
+ return errorResult(`Unknown agent type: ${type}`);
951
+ }
952
+
953
+ const prompt = params.prompt as string;
954
+ const description = params.description as string;
955
+ const resume = params.resume as string | undefined;
956
+ const runInBackground = params.run_in_background as boolean | undefined;
957
+ const isolated = params.isolated as boolean | undefined;
958
+ const maxTurns = params.max_turns as number | undefined;
959
+ const thinking = params.thinking as string | undefined;
960
+
961
+ // Model is injected by tool_call listener — use it directly
962
+ const modelStr = params.model as string | undefined;
963
+
964
+ // Resolve model string to Model object
965
+ const model = resolveModelString(modelStr, ctx);
966
+
967
+ // Compute modelKey for concurrency pool lookup
968
+ const modelKey = model ? `${model.provider}/${model.id}` : undefined;
969
+
970
+ if (resume) {
971
+ return executeResumeAgent(resume, prompt);
972
+ }
973
+
974
+ const spawnOptions: AgentManagerSpawnOptions = {
975
+ description,
976
+ model,
977
+ maxTurns,
978
+ isolated,
979
+ thinkingLevel: thinking as ThinkingLevel | undefined,
980
+ modelKey,
981
+ };
982
+
983
+ if (runInBackground) {
984
+ return executeSpawnBackground(resolvedType, prompt, ctx, spawnOptions);
985
+ }
986
+
987
+ return executeSpawnForeground(resolvedType, prompt, ctx, spawnOptions);
988
+ }
989
+
990
+ // ============================================================================
991
+ // Model string resolution
992
+ // ============================================================================
993
+
994
+ /**
995
+ * Parse a "provider/model-id" string into a Model object.
996
+ * Falls back to ctx.model if the string lacks a provider or the registry
997
+ * can't find the model.
998
+ */
999
+ function resolveModelString(
1000
+ modelStr: string | undefined,
1001
+ ctx: ExtensionContext,
1002
+ ): Model<any> | undefined {
1003
+ if (!modelStr) return undefined;
1004
+
1005
+ const parsed = parseModelKey(modelStr);
1006
+ if (!parsed) return ctx.model;
1007
+
1008
+ return ctx.modelRegistry?.find(parsed.provider, parsed.modelId) ?? ctx.model;
1009
+ }
1010
+
1011
+ // ============================================================================
1012
+ // Sub-handlers for executeAgentTool
1013
+ // ============================================================================
1014
+
1015
+ async function executeResumeAgent(
1016
+ resume: string,
1017
+ prompt: string,
1018
+ ): Promise<any> {
1019
+ const record = await manager.resume(resume, prompt);
1020
+ if (!record) {
1021
+ return errorResult(`Agent not found: ${resume}`);
1022
+ }
1023
+ return successResult(record.result ?? "");
1024
+ }
1025
+
1026
+ async function executeSpawnBackground(
1027
+ resolvedType: string,
1028
+ prompt: string,
1029
+ ctx: ExtensionContext,
1030
+ spawnOptions: AgentManagerSpawnOptions,
1031
+ ): Promise<any> {
1032
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(
1033
+ spawnOptions.maxTurns,
1034
+ );
1035
+
1036
+ const agentId = manager.spawn(piInstance, ctx, resolvedType, prompt, {
1037
+ ...spawnOptions,
1038
+ isBackground: true,
1039
+ ...bgCallbacks,
1040
+ });
1041
+ backgroundAgentIds.add(agentId);
1042
+ agentActivity.set(agentId, bgState);
1043
+ widget?.ensureTimer();
1044
+ widget?.update();
1045
+
1046
+ const record = manager.getRecord(agentId);
1047
+ if (!record) {
1048
+ return errorResult("Failed to create agent");
1049
+ }
1050
+ const bgDetails: Record<string, unknown> = { type: resolvedType, description: spawnOptions.description };
1051
+ if (record.status === "queued") {
1052
+ return successResult(`[Agent queued] Concurrency limit reached. It will start automatically when a slot frees up. Do NOT poll — you will be notified when ready.
1053
+
1054
+ Agent ID: ${agentId}`, bgDetails);
1055
+ }
1056
+ return successResult(
1057
+ `[Agent started in background] Do NOT poll — the result will be delivered to you automatically when it completes. Continue with other work while waiting.\n\nAgent ID: ${agentId}`,
1058
+ bgDetails,
1059
+ );
1060
+ }
1061
+
1062
+ async function executeSpawnForeground(
1063
+ resolvedType: string,
1064
+ prompt: string,
1065
+ ctx: ExtensionContext,
1066
+ spawnOptions: AgentManagerSpawnOptions,
1067
+ ): Promise<any> {
1068
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
1069
+ spawnOptions.maxTurns,
1070
+ );
1071
+
1072
+ // Capture agent ID when session is created
1073
+ let fgId: string | undefined;
1074
+ const origOnSession = fgCallbacks.onSessionCreated;
1075
+ fgCallbacks.onSessionCreated = (session) => {
1076
+ origOnSession(session);
1077
+ for (const a of manager!.listAgents()) {
1078
+ if (a.session === session) {
1079
+ fgId = a.id;
1080
+ agentActivity.set(a.id, fgState);
1081
+ widget?.ensureTimer();
1082
+ break;
1083
+ }
1084
+ }
1085
+ };
1086
+
1087
+ const { isBackground: _isBackground, ...spawnOpts } = spawnOptions;
1088
+ const record = await manager.spawnAndWait(piInstance, ctx, resolvedType, prompt, {
1089
+ ...spawnOpts,
1090
+ ...fgCallbacks,
1091
+ });
1092
+
1093
+ // Clean up foreground agent from widget
1094
+ if (fgId) {
1095
+ agentActivity.delete(fgId);
1096
+ widget?.markFinished(fgId);
1097
+ widget?.update();
1098
+ }
1099
+
1100
+ // Build raw stats for the reply card — formatted in renderResult with theme
1101
+ const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt;
1102
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
1103
+ const stats = {
1104
+ type: resolvedType,
1105
+ turnCount: fgState.turnCount,
1106
+ maxTurns: fgState.maxTurns,
1107
+ toolUses: record.toolUses,
1108
+ tokens: totalTokens,
1109
+ contextPercent: getSessionContextPercent(fgState.session),
1110
+ durationMs: elapsedMs,
1111
+ description: spawnOptions.description,
1112
+ compactions: record.compactionCount,
1113
+ };
1114
+
1115
+ if (record.status === "error") {
1116
+ return errorResult(`Agent failed: ${record.error || "unknown error"}`, stats as any);
1117
+ }
1118
+
1119
+ return successResult(record.result ?? "", stats as any);
1120
+ }
1121
+
1122
+ // ============================================================================
1123
+ // Tool_call listener — inject model into Agent tool calls
1124
+ // ============================================================================
1125
+
1126
+ async function toolCallListener(
1127
+ event: ToolCallEvent,
1128
+ ctx: ExtensionContext,
1129
+ ): Promise<void> {
1130
+ // Only handle Agent tool calls
1131
+ if (event.toolName !== "Agent") return;
1132
+
1133
+ const input = event.input;
1134
+ const subagentType = input.agent as string | undefined;
1135
+ const agentConfig = subagentType ? getAgentConfig(subagentType) : undefined;
1136
+
1137
+ // Resolve effective model using precedence chain
1138
+ const effectiveModel = resolveModel(
1139
+ subagentType ?? "general-purpose",
1140
+ agentConfig,
1141
+ __config,
1142
+ ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "",
1143
+ );
1144
+
1145
+ if (effectiveModel) {
1146
+ input.model = effectiveModel;
1147
+ }
1148
+ }
1149
+
1150
+ // ============================================================================
1151
+ // Extension factory
1152
+ // ============================================================================
1153
+
1154
+ export default function (pi: ExtensionAPI) {
1155
+ // Store pi for execute callbacks
1156
+ piInstance = pi;
1157
+
1158
+ // ========================================================================
1159
+ // Tool registration (stealth schemas — at init time)
1160
+ // ========================================================================
1161
+
1162
+ // Agent tool — stealth schema
1163
+ pi.registerTool({
1164
+ name: "Agent",
1165
+ label: "Agent",
1166
+ description: ".",
1167
+ // No promptSnippet, no promptGuidelines
1168
+ parameters: Type.Object({
1169
+ prompt: Type.String(),
1170
+ description: Type.String(),
1171
+ agent: Type.Optional(Type.String()),
1172
+ thinking: Type.Optional(Type.String()),
1173
+ run_in_background: Type.Optional(Type.Boolean()),
1174
+ resume: Type.Optional(Type.String()),
1175
+ }),
1176
+ execute: executeAgentTool,
1177
+
1178
+ renderCall(args, theme) {
1179
+ const typeName = getDisplayName((args.agent as string) || "");
1180
+ const label = typeName || "Agent";
1181
+ return new Text("▸ " + theme.fg("accent", theme.bold(label)), 0, 0);
1182
+ },
1183
+
1184
+ renderResult(result, options, theme) {
1185
+ const { expanded } = options as { expanded?: boolean };
1186
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1187
+ const d = result.details as Record<string, unknown> | undefined;
1188
+ const isError = !!(result as any).isError;
1189
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
1190
+
1191
+ const typeName = getDisplayName((d?.type as string) || "");
1192
+ const desc = (d?.description as string) || "";
1193
+
1194
+ if (d && d.turnCount != null) {
1195
+ // Rich stats card — format with theme (matching pi-subagents style)
1196
+ const parts: string[] = [];
1197
+ if ((d.toolUses as number) > 0) {
1198
+ parts.push(`${d.toolUses}🛠 `);
1199
+ }
1200
+ if (d.turnCount != null && (d.turnCount as number) > 0) {
1201
+ parts.push(formatTurns(d.turnCount as number, d.maxTurns as number | undefined));
1202
+ }
1203
+ if ((d.tokens as number) > 0) {
1204
+ const tokenText = formatSessionTokens(
1205
+ d.tokens as number,
1206
+ d.contextPercent as number | null,
1207
+ theme,
1208
+ (d.compactions as number) ?? 0,
1209
+ );
1210
+ parts.push(tokenText);
1211
+ }
1212
+ parts.push(formatMs(d.durationMs as number));
1213
+
1214
+ const statsLine = parts.join("·");
1215
+ let lines = `${icon} ${theme.bold(typeName)}·${statsLine}\n ${theme.fg("text", desc)}`;
1216
+ if (expanded && text) {
1217
+ lines += "\n" + text.split("\n").map(l => ` ${l}`).join("\n");
1218
+ }
1219
+ return new Text(lines, 0, 0);
1220
+ }
1221
+
1222
+ // Minimal card when we have type/description but no stats (e.g. background spawn)
1223
+ if (typeName || desc) {
1224
+ let lines = `${icon}`;
1225
+ if (typeName) lines += ` ${theme.bold(typeName)}`;
1226
+ if (desc) lines += `\n ${theme.fg("text", desc)}`;
1227
+ return new Text(lines, 0, 0);
1228
+ }
1229
+
1230
+ return new Text(`${icon} ${theme.fg("dim", text)}`, 0, 0);
1231
+ },
1232
+ });
1233
+
1234
+ // ========================================================================
1235
+ // Message renderer — subagent-result (background agent completion)
1236
+ // ========================================================================
1237
+ // Renders a collapsible stats card matching the foreground Agent tool card.
1238
+ // Stats come from `details` (UI-only), content is just the result text.
1239
+
1240
+ pi.registerMessageRenderer("subagent-result", (message, options, theme) => {
1241
+ const { expanded } = options as { expanded?: boolean };
1242
+ const d = message.details as Record<string, unknown> | undefined;
1243
+ const text = (message.content as string)?.trim() || "";
1244
+
1245
+ // Build the content inside the purple card
1246
+ const inner = new Container();
1247
+
1248
+ // Title — matches default CustomMessageComponent style
1249
+ const titleText = theme.fg("customMessageLabel", `[subagent-result]`);
1250
+ inner.addChild(new Text(titleText, 0, 0));
1251
+ inner.addChild(new Spacer(1));
1252
+
1253
+ if (d && d.turnCount != null) {
1254
+ // Rich stats card — matching the foreground Agent tool renderResult
1255
+ const isError = d.status === "error" || d.status === "aborted" || d.status === "stopped";
1256
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
1257
+ const typeName = getDisplayName((d.type as string) || "");
1258
+ const desc = (d.description as string) || "";
1259
+
1260
+ const parts: string[] = [];
1261
+ if ((d.toolUses as number) > 0) {
1262
+ parts.push(`${d.toolUses}🛠 `);
1263
+ }
1264
+ if ((d.turnCount as number) > 0) {
1265
+ parts.push(formatTurns(d.turnCount as number, d.maxTurns as number | undefined));
1266
+ }
1267
+ if ((d.tokens as number) > 0) {
1268
+ const tokenText = formatSessionTokens(
1269
+ d.tokens as number,
1270
+ d.contextPercent as number | null,
1271
+ theme,
1272
+ (d.compactions as number) ?? 0,
1273
+ );
1274
+ parts.push(tokenText);
1275
+ }
1276
+ parts.push(formatMs(d.durationMs as number));
1277
+
1278
+ const statsLine = parts.join("·");
1279
+ let headerLine = `${icon} ${theme.bold(typeName)}·${statsLine}\n ${theme.fg("text", desc)}`;
1280
+ if ((d.outputFile as string)) {
1281
+ headerLine += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
1282
+ }
1283
+ inner.addChild(new Text(headerLine, 0, 0));
1284
+
1285
+ // Result text — only when expanded (collapsible)
1286
+ if (expanded && text) {
1287
+ inner.addChild(new Spacer(1));
1288
+ const resultLines = text.split("\n").map(l => ` ${l}`).join("\n");
1289
+ inner.addChild(new Text(resultLines, 0, 0));
1290
+ }
1291
+ } else {
1292
+ // Minimal card — no stats (shouldn't happen, but handle gracefully)
1293
+ const typeName = getDisplayName((d?.type as string) || "");
1294
+ const desc = (d?.description as string) || "";
1295
+ let line = `${theme.fg("success", "✓")}`;
1296
+ if (typeName) line += ` ${theme.bold(typeName)}`;
1297
+ if (desc) line += `\n ${theme.fg("text", desc)}`;
1298
+ if (d?.outputFile) {
1299
+ line += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
1300
+ }
1301
+ inner.addChild(new Text(line, 0, 0));
1302
+ }
1303
+
1304
+ // Wrap in purple card matching default CustomMessageComponent styling
1305
+ const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
1306
+ box.addChild(inner);
1307
+
1308
+ const outer = new Container();
1309
+ outer.addChild(new Spacer(1));
1310
+ outer.addChild(box);
1311
+ outer.addChild(new Spacer(1));
1312
+ return outer;
1313
+ });
1314
+
1315
+ // ========================================================================
1316
+ // Command registration
1317
+ // ========================================================================
1318
+
1319
+ pi.registerCommand("agents", {
1320
+ description: "Manage subagents: agent briefing, model settings, concurrency, running agents, agent types",
1321
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
1322
+ const modelOptions = ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`);
1323
+ await showAgentsMainMenu(ctx, modelOptions);
1324
+ },
1325
+ });
1326
+
1327
+ // ========================================================================
1328
+ // Event listeners
1329
+ // ========================================================================
1330
+
1331
+ // tool_call listener — inject model into Agent tool calls
1332
+ pi.on("tool_call", toolCallListener);
1333
+
1334
+ // Grab UI context for widget rendering on first tool execution each session,
1335
+ // and advance finished-agent linger state on each turn.
1336
+ pi.on("tool_execution_start", async (_event, ctx) => {
1337
+ widget?.setUICtx(ctx.ui as unknown as UICtx);
1338
+ widget?.onTurnStart();
1339
+ });
1340
+
1341
+ // session_start — load config, scan agents, register into registry
1342
+ pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
1343
+ agentActivity.clear();
1344
+ await loadConfigAndRegisterAgents(ctx);
1345
+ });
1346
+
1347
+ // session_shutdown — clean up
1348
+ pi.on("session_shutdown", async (_event: unknown) => {
1349
+ // Dispose widget before manager
1350
+ widget?.dispose();
1351
+ widget = undefined;
1352
+ if (manager) {
1353
+ await manager.dispose();
1354
+ }
1355
+ });
1356
+ }