pi-subagents-lite 0.2.0 → 0.3.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.
package/src/index.ts CHANGED
@@ -14,7 +14,6 @@
14
14
  * - Loaded from ~/.pi/agent/subagents-lite.json at session_start
15
15
  * - Module-level __config cache; tool_call reads from cache
16
16
  * - Config mutations update cache + atomic write to disk
17
- * - Migrates subagent-model-defaults.json on first load
18
17
  *
19
18
  * Commands:
20
19
  * - /agents: Management menu with 5 sub-menus
@@ -27,781 +26,49 @@
27
26
 
28
27
  import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
29
28
  import { Type } from "@sinclair/typebox";
30
- import * as fs from "node:fs";
31
29
  import * as path from "node:path";
32
30
  import type {
33
31
  ExtensionAPI,
34
32
  ExtensionCommandContext,
35
33
  ExtensionContext,
36
- ToolCallEvent,
37
34
  } 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";
35
+ import type { SessionModelOverrides, SubagentsConfig } from "./model-precedence.js";
36
+ import { DEFAULT_AGENTS } from "./default-agents.js";
37
+ import { registerAgents, getAvailableTypes } from "./agent-types.js";
42
38
  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
39
  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";
40
+ import { AgentWidget, buildStatsParts, formatMs, getDisplayName, type AgentActivity, type UICtx } from "./ui/agent-widget.js";
41
+ import { showAgentsMainMenu } from "./menus.js";
42
+ import { loadConfig } from "./config-io.js";
43
+ import { executeAgentTool, toolCallListener, backgroundAgentIds, scheduleNudge } from "./tool-execution.js";
44
+ import { executeStopAgentTool } from "./stop-agent-tool.js";
51
45
 
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
46
  // ============================================================================
59
47
  // Module-level state
60
48
  // ============================================================================
61
49
 
50
+ /** Session-only model overrides — not persisted, cleared on session_start. */
51
+ export let sessionOverrides: SessionModelOverrides = { default: null };
52
+
62
53
  /** Config cache — loaded at session_start, updated by /agents menu mutations. */
63
- let __config: SubagentsConfig = {
64
- agent: { default: null },
54
+ export let __config: SubagentsConfig = {
55
+ agent: { default: null, forceBackground: false },
65
56
  concurrency: { default: 4 },
66
57
  };
67
58
 
68
59
  /** Agent manager singleton — module-level, no globalThis access. */
69
- let manager: AgentManager;
60
+ export let manager: AgentManager;
70
61
 
71
- /** Live activity state per agent, keyed by agent ID. Read by AgentWidget for rendering. */
72
- const agentActivity = new Map<string, AgentActivity>();
62
+ /** Live activity state per agent, keyed by agent ID. Read by AgentWidget and tool-execution. */
63
+ export const agentActivity = new Map<string, AgentActivity>();
73
64
 
74
- /** Live TUI widget showing running/completed agents above the editor. */
75
- let widget: AgentWidget | undefined;
65
+ /** Live TUI widget showing running/completed agents above the editor. Used by tool-execution. */
66
+ export let widget: AgentWidget | undefined;
76
67
 
77
68
  /** 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
- }
69
+ export let piInstance: ExtensionAPI;
172
70
 
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
71
 
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
72
 
806
73
  // ============================================================================
807
74
  // Config loader — session_start handler logic
@@ -814,16 +81,11 @@ async function showAgentTypes(ctx: ExtensionCommandContext): Promise<void> {
814
81
  function ensureManagerAndWidget(): void {
815
82
  if (manager) return;
816
83
 
817
- const concurrencyConfig = {
818
- default: __config.concurrency.default,
819
- providers: __config.concurrency.providers ?? {},
820
- models: __config.concurrency.models ?? {},
821
- };
822
84
  manager = new AgentManager(
823
85
  (record) => {
824
86
  // Only nudge for background (async) agents — sync agents already returned via tool result
825
87
  if (backgroundAgentIds.has(record.id)) {
826
- scheduleNudge(record.id, record);
88
+ scheduleNudge(record.id);
827
89
  backgroundAgentIds.delete(record.id);
828
90
  }
829
91
 
@@ -835,7 +97,7 @@ function ensureManagerAndWidget(): void {
835
97
  // Remove from live activity tracking
836
98
  agentActivity.delete(record.id);
837
99
  },
838
- concurrencyConfig,
100
+ __config.concurrency,
839
101
  );
840
102
 
841
103
  // Create/replace widget tied to this manager instance
@@ -858,8 +120,6 @@ async function scanAndRegisterAgents(ctx: ExtensionContext): Promise<void> {
858
120
  scanAgentFilesInDir(projectAgentDir, "project"),
859
121
  ]);
860
122
 
861
- const { DEFAULT_AGENTS } = await import("./default-agents.js");
862
-
863
123
  // Merge with defaults
864
124
  const merged = mergeAgents(DEFAULT_AGENTS, userAgents, projectAgents);
865
125
 
@@ -868,308 +128,51 @@ async function scanAndRegisterAgents(ctx: ExtensionContext): Promise<void> {
868
128
  }
869
129
 
870
130
  async function loadConfigAndRegisterAgents(ctx: ExtensionContext): Promise<void> {
871
- // Load config (with migration if needed)
872
131
  __config = loadConfig();
873
-
874
- // Ensure manager exists
875
132
  ensureManagerAndWidget();
876
-
877
- // Scan agent files and register
878
133
  await scanAndRegisterAgents(ctx);
879
134
  }
880
135
 
881
136
  // ============================================================================
882
- // Activity trackingbridge 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
137
+ // UI helpersstats card rendering (shared by renderResult and message renderer)
934
138
  // ============================================================================
935
139
 
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);
140
+ /** Build the stats line for an agent result card. Used by both renderers. */
141
+ function buildStatsLine(d: Record<string, unknown>, theme: any): string {
142
+ const parts = buildStatsParts({
143
+ toolUses: (d.toolUses as number) ?? 0,
144
+ turnCount: d.turnCount as number | undefined,
145
+ maxTurns: d.maxTurns as number | undefined,
146
+ tokens: (d.tokens as number) ?? 0,
147
+ contextPercent: d.contextPercent as number | null,
148
+ compactions: (d.compactions as number) ?? 0,
149
+ }, theme);
150
+ parts.push(formatMs(d.durationMs as number));
151
+ return parts.join("·");
988
152
  }
989
153
 
990
154
  // ============================================================================
991
- // Model string resolution
155
+ // Agent tool registration helper — dynamic enum for agent types
992
156
  // ============================================================================
993
157
 
994
158
  /**
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.
159
+ * Register (or re-register) the Agent tool with current agent types.
160
+ * At init time only defaults exist; call again from session_start after
161
+ * user/project agents are loaded to update the enum.
998
162
  */
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
163
+ function registerAgentTool(pi: ExtensionAPI): void {
164
+ const types = getAvailableTypes();
165
+ const agentParam = types.length > 0
166
+ ? Type.Optional(Type.Union(types.map(t => Type.Literal(t))))
167
+ : Type.Optional(Type.String());
1163
168
  pi.registerTool({
1164
169
  name: "Agent",
1165
170
  label: "Agent",
1166
171
  description: ".",
1167
- // No promptSnippet, no promptGuidelines
1168
172
  parameters: Type.Object({
1169
173
  prompt: Type.String(),
1170
174
  description: Type.String(),
1171
- agent: Type.Optional(Type.String()),
1172
- thinking: Type.Optional(Type.String()),
175
+ agent: agentParam,
1173
176
  run_in_background: Type.Optional(Type.Boolean()),
1174
177
  resume: Type.Optional(Type.String()),
1175
178
  }),
@@ -1178,7 +181,18 @@ export default function (pi: ExtensionAPI) {
1178
181
  renderCall(args, theme) {
1179
182
  const typeName = getDisplayName((args.agent as string) || "");
1180
183
  const label = typeName || "Agent";
1181
- return new Text("▸ " + theme.fg("accent", theme.bold(label)), 0, 0);
184
+ let text = `▸ ${theme.fg("accent", theme.bold(label))}`;
185
+
186
+ // Show model in parens when it differs from the parent model
187
+ // _modelOverride is injected by toolCallListener when the resolved
188
+ // model differs from the session's parent model
189
+ const a = args as Record<string, unknown>;
190
+ const modelOverride = a._modelOverride as string | undefined;
191
+ if (modelOverride) {
192
+ text += ` (${modelOverride})`;
193
+ }
194
+
195
+ return new Text(text, 0, 0);
1182
196
  },
1183
197
 
1184
198
  renderResult(result, options, theme) {
@@ -1187,113 +201,95 @@ export default function (pi: ExtensionAPI) {
1187
201
  const d = result.details as Record<string, unknown> | undefined;
1188
202
  const isError = !!(result as any).isError;
1189
203
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
1190
-
1191
- const typeName = getDisplayName((d?.type as string) || "");
1192
204
  const desc = (d?.description as string) || "";
1193
205
 
1194
206
  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)}`;
207
+ const statsLine = buildStatsLine(d, theme);
208
+ let lines = `${icon} ${statsLine}\n ${theme.fg("text", desc)}`;
1216
209
  if (expanded && text) {
1217
210
  lines += "\n" + text.split("\n").map(l => ` ${l}`).join("\n");
1218
211
  }
1219
212
  return new Text(lines, 0, 0);
1220
213
  }
1221
214
 
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);
215
+ // Minimal card type name already shown by renderCall
216
+ // For background spawns (no stats), use space placeholder — agent isn't done yet
217
+ const isBackground = text.includes("running in background") || text.includes("queued");
218
+ const prefix = isBackground ? " " : `${icon} `;
219
+ if (desc) {
220
+ return new Text(`${prefix}${theme.fg("text", desc)}`, 0, 0);
1228
221
  }
1229
222
 
1230
- return new Text(`${icon} ${theme.fg("dim", text)}`, 0, 0);
223
+ return new Text(`${prefix}${theme.fg("dim", text)}`, 0, 0);
1231
224
  },
1232
225
  });
226
+ }
227
+
228
+ // ============================================================================
229
+ // Extension factory
230
+ // ============================================================================
231
+
232
+ export default function (pi: ExtensionAPI) {
233
+ // Store pi for execute callbacks
234
+ piInstance = pi;
1233
235
 
1234
236
  // ========================================================================
1235
- // Message renderersubagent-result (background agent completion)
237
+ // Tool registration (stealth schemas at init time)
1236
238
  // ========================================================================
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
239
 
240
+ // Agent tool — stealth schema with dynamic agent type enum
241
+ registerAgentTool(pi);
242
+
243
+ // StopAgent tool — stealth schema, stop a running agent by ID
244
+ pi.registerTool({
245
+ name: "StopAgent",
246
+ label: "StopAgent",
247
+ description: ".",
248
+ parameters: Type.Object({
249
+ agent_id: Type.String(),
250
+ }),
251
+ execute: executeStopAgentTool,
252
+ });
253
+
254
+ // Message renderer — subagent-result (background agent completion)
1240
255
  pi.registerMessageRenderer("subagent-result", (message, options, theme) => {
1241
256
  const { expanded } = options as { expanded?: boolean };
1242
257
  const d = message.details as Record<string, unknown> | undefined;
1243
258
  const text = (message.content as string)?.trim() || "";
1244
259
 
1245
- // Build the content inside the purple card
1246
260
  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));
261
+ inner.addChild(new Text(theme.fg("customMessageLabel", "Subagent Result"), 0, 0));
1251
262
  inner.addChild(new Spacer(1));
1252
263
 
1253
264
  if (d && d.turnCount != null) {
1254
- // Rich stats card — matching the foreground Agent tool renderResult
1255
265
  const isError = d.status === "error" || d.status === "aborted" || d.status === "stopped";
1256
266
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
1257
267
  const typeName = getDisplayName((d.type as string) || "");
268
+ const modelName = d.modelName as string | undefined;
1258
269
  const desc = (d.description as string) || "";
1259
270
 
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)}`;
271
+ const namePart = modelName ? `${theme.bold(typeName)} (${modelName})` : theme.bold(typeName);
272
+ const statsLine = buildStatsLine(d, theme);
273
+ let headerLine = `${icon} ${namePart}·${statsLine}\n ${theme.fg("text", desc)}`;
1280
274
  if ((d.outputFile as string)) {
1281
275
  headerLine += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
1282
276
  }
1283
277
  inner.addChild(new Text(headerLine, 0, 0));
1284
278
 
1285
- // Result text — only when expanded (collapsible)
1286
279
  if (expanded && text) {
1287
280
  inner.addChild(new Spacer(1));
1288
281
  const resultLines = text.split("\n").map(l => ` ${l}`).join("\n");
1289
282
  inner.addChild(new Text(resultLines, 0, 0));
1290
283
  }
1291
284
  } else {
1292
- // Minimal card — no stats (shouldn't happen, but handle gracefully)
1293
285
  const typeName = getDisplayName((d?.type as string) || "");
286
+ const modelName = d?.modelName as string | undefined;
1294
287
  const desc = (d?.description as string) || "";
1295
288
  let line = `${theme.fg("success", "✓")}`;
1296
- if (typeName) line += ` ${theme.bold(typeName)}`;
289
+ if (typeName) {
290
+ const namePart = modelName ? `${theme.bold(typeName)} (${modelName})` : theme.bold(typeName);
291
+ line += ` ${namePart}`;
292
+ }
1297
293
  if (desc) line += `\n ${theme.fg("text", desc)}`;
1298
294
  if (d?.outputFile) {
1299
295
  line += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
@@ -1301,7 +297,6 @@ export default function (pi: ExtensionAPI) {
1301
297
  inner.addChild(new Text(line, 0, 0));
1302
298
  }
1303
299
 
1304
- // Wrap in purple card matching default CustomMessageComponent styling
1305
300
  const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
1306
301
  box.addChild(inner);
1307
302
 
@@ -1312,10 +307,7 @@ export default function (pi: ExtensionAPI) {
1312
307
  return outer;
1313
308
  });
1314
309
 
1315
- // ========================================================================
1316
310
  // Command registration
1317
- // ========================================================================
1318
-
1319
311
  pi.registerCommand("agents", {
1320
312
  description: "Manage subagents: agent briefing, model settings, concurrency, running agents, agent types",
1321
313
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
@@ -1324,29 +316,25 @@ export default function (pi: ExtensionAPI) {
1324
316
  },
1325
317
  });
1326
318
 
1327
- // ========================================================================
1328
319
  // Event listeners
1329
- // ========================================================================
1330
-
1331
- // tool_call listener — inject model into Agent tool calls
1332
320
  pi.on("tool_call", toolCallListener);
1333
321
 
1334
- // Grab UI context for widget rendering on first tool execution each session,
1335
- // and advance finished-agent linger state on each turn.
1336
322
  pi.on("tool_execution_start", async (_event, ctx) => {
1337
323
  widget?.setUICtx(ctx.ui as unknown as UICtx);
1338
324
  widget?.onTurnStart();
1339
325
  });
1340
326
 
1341
- // session_start — load config, scan agents, register into registry
327
+ // session_start — load config, scan agents, register into registry,
328
+ // then re-register Agent tool with dynamic agent type enum
1342
329
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
330
+ sessionOverrides = { default: null };
1343
331
  agentActivity.clear();
1344
332
  await loadConfigAndRegisterAgents(ctx);
333
+ // Re-register with updated agent type list (now includes user/project agents)
334
+ registerAgentTool(pi);
1345
335
  });
1346
336
 
1347
- // session_shutdown — clean up
1348
337
  pi.on("session_shutdown", async (_event: unknown) => {
1349
- // Dispose widget before manager
1350
338
  widget?.dispose();
1351
339
  widget = undefined;
1352
340
  if (manager) {