pi-subagents-lite 1.3.0 → 1.4.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.
Files changed (53) hide show
  1. package/README.md +184 -235
  2. package/package.json +1 -1
  3. package/src/{agent-discovery.ts → agents/agent-discovery.ts} +8 -5
  4. package/src/{agent-manager.ts → agents/agent-manager.ts} +34 -74
  5. package/src/{agent-runner.ts → agents/agent-runner.ts} +115 -173
  6. package/src/{agent-status.ts → agents/agent-status.ts} +4 -4
  7. package/src/agents/agent-types.ts +339 -0
  8. package/src/{default-agents.ts → agents/default-agents.ts} +2 -5
  9. package/src/{output-file.ts → agents/output-file.ts} +68 -1
  10. package/src/{tool-execution.ts → agents/tool-execution.ts} +60 -222
  11. package/src/agents/types.ts +54 -0
  12. package/src/{usage.ts → agents/usage.ts} +7 -0
  13. package/src/{config-io.ts → config/config-io.ts} +20 -3
  14. package/src/config/config-store.ts +472 -0
  15. package/src/config/types.ts +26 -0
  16. package/src/events.ts +185 -0
  17. package/src/index.ts +8 -281
  18. package/src/{model-precedence.ts → models/model-precedence.ts} +33 -0
  19. package/src/{model-selector.ts → models/model-selector.ts} +1 -1
  20. package/src/{context.ts → prompt/context.ts} +1 -1
  21. package/src/prompt/prompts.ts +180 -0
  22. package/src/prompt/skill-loader.ts +195 -0
  23. package/src/registration.ts +101 -0
  24. package/src/shell.ts +101 -0
  25. package/src/spawn/spawn-coordinator.ts +232 -0
  26. package/src/status-note.ts +10 -0
  27. package/src/types.ts +47 -71
  28. package/src/ui/agent-widget.ts +61 -49
  29. package/src/{format.ts → ui/format.ts} +64 -26
  30. package/src/ui/menu/helpers.ts +93 -0
  31. package/src/ui/menu/menu-concurrency.ts +192 -0
  32. package/src/ui/menu/menu-debug.ts +125 -0
  33. package/src/ui/menu/menu-model-settings.ts +208 -0
  34. package/src/ui/menu/menu-running-agents.ts +224 -0
  35. package/src/ui/menu/menu-spawn-options.ts +87 -0
  36. package/src/ui/menu/menu-spawn-wizard.ts +418 -0
  37. package/src/ui/menu/menu-system-prompt.ts +109 -0
  38. package/src/ui/menu/menu-widget-settings.ts +130 -0
  39. package/src/ui/menu/menus.ts +101 -0
  40. package/src/ui/menu/submenus/confirm.ts +47 -0
  41. package/src/ui/menu/submenus/model-select.ts +70 -0
  42. package/src/ui/menu/submenus/numeric-input.ts +98 -0
  43. package/src/ui/menu/wrappers/settings-list.ts +205 -0
  44. package/src/{renderer.ts → ui/renderer.ts} +7 -6
  45. package/src/{result-viewer.ts → ui/result-viewer.ts} +7 -2
  46. package/src/ui/types.ts +11 -0
  47. package/src/agent-types.ts +0 -184
  48. package/src/config-mutator.ts +0 -183
  49. package/src/menus.ts +0 -1333
  50. package/src/prompts.ts +0 -94
  51. package/src/skill-loader.ts +0 -178
  52. package/src/state.ts +0 -83
  53. /package/src/{worktree-validator.ts → spawn/worktree-validator.ts} +0 -0
package/src/menus.ts DELETED
@@ -1,1333 +0,0 @@
1
- /**
2
- * menus.ts — /agents command menu system.
3
- *
4
- * All menu-related functions extracted from index.ts.
5
- * Imports shared state (config, manager, piInstance) from state.ts.
6
- */
7
-
8
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
- import { getAgentConfig, getAvailableTypes, getAllTypes, resolveType, discoverNewAgents } from "./agent-types.js";
10
- import type { AgentRecord, ThinkingLevel } from "./types.js";
11
- import { SHORT_ID_LENGTH, CONFIG_AGENT_NON_MODEL_KEYS } from "./types.js";
12
- import type { SpawnOptions } from "./agent-manager.js";
13
- import { ModelSelectorDialog, type ModelOption } from "./model-selector.js";
14
- import { ResultViewer, type ResultViewerStats } from "./result-viewer.js";
15
- import { getDisplayName } from "./format.js";
16
- import { buildSnapshotMarkdown } from "./context.js";
17
-
18
- import { parseModelKey, findModelInRegistry } from "./utils.js";
19
- import {
20
- __config,
21
- sessionOverrides,
22
- piInstance,
23
- sessionCtx,
24
- agentActivity,
25
- getManager,
26
- getWidget,
27
- } from "./state.js";
28
- import { resolveModel } from "./model-precedence.js";
29
- import { createActivityTracker, backgroundAgentIds } from "./tool-execution.js";
30
- import {
31
- setModelOverride,
32
- setDefaultModel,
33
- clearModelOverride,
34
- clearAllModelOverrides,
35
- setForceBackground,
36
- setShowCost,
37
- setGraceTurns,
38
- setWidgetCompact,
39
- setWidgetMaxLines,
40
- setWidgetMaxLinesCompact,
41
- setWidgetShortcut,
42
- setAgent,
43
- setConcurrencyDefault,
44
- setConcurrencyProvider,
45
- setConcurrencyModel,
46
- removeConcurrencyProvider,
47
- removeConcurrencyModel,
48
- resetConcurrency,
49
- } from "./config-mutator.js";
50
-
51
- // ============================================================================
52
- // Helpers
53
- // ============================================================================
54
-
55
- /**
56
- * Build ModelOption[] from raw "provider/model-id" strings.
57
- * Includes "(inherits parent)" as the first option.
58
- */
59
- function buildModelOptions(rawOptions: string[]): ModelOption[] {
60
- const items: ModelOption[] = [
61
- { value: "(inherits parent)", label: "(inherits parent)", provider: "" },
62
- ];
63
-
64
- for (const opt of rawOptions) {
65
- const parsed = parseModelKey(opt);
66
- if (!parsed) continue;
67
- items.push({ value: opt, label: parsed.modelId, provider: parsed.provider });
68
- }
69
- return items;
70
- }
71
-
72
- /**
73
- * Show the ModelSelectorDialog and return the chosen model string, or null.
74
- */
75
- async function promptModelSelection(
76
- ctx: ExtensionCommandContext,
77
- modelOptions: string[],
78
- currentValue: string,
79
- ): Promise<string | null> {
80
- return ctx.ui.custom<string | null>(
81
- (_tui, theme, _kb, done) => {
82
- const opts = buildModelOptions(modelOptions);
83
- return new ModelSelectorDialog(opts, currentValue, {
84
- onSelect: (m) => done(m),
85
- onCancel: () => done(null),
86
- }, theme);
87
- }, // no overlay — renders inline below editor, matching pi's model selector look and feel
88
- );
89
- }
90
-
91
- /**
92
- * Prompt user to choose between session-only or permanent persistence.
93
- * When showClear is true, also offers "Clear".
94
- * Returns "session", "permanent", "clear", or null if cancelled.
95
- */
96
- async function promptOverrideMode(
97
- ctx: ExtensionCommandContext,
98
- showClear: boolean = false,
99
- ): Promise<"session" | "permanent" | "clear" | null> {
100
- const choices: string[] = [
101
- "Set for this session (not saved)",
102
- "Set permanently (saved to config)",
103
- ];
104
- if (showClear) {
105
- choices.push("Clear");
106
- }
107
- const choice = await ctx.ui.select("Save mode", choices);
108
- if (choice === undefined) return null;
109
- if (choice.startsWith("Set for this session")) return "session";
110
- if (choice.startsWith("Set permanently")) return "permanent";
111
- return "clear";
112
- }
113
-
114
- /**
115
- * Prompt for a model selection and apply it as an override.
116
- * "(inherits parent)" clears the override (sets to null).
117
- * The caller is responsible for persistence (saveConfigAtomic).
118
- */
119
- async function applyModelOverride(
120
- ctx: ExtensionCommandContext,
121
- modelOptions: string[],
122
- label: string,
123
- currentValue: string,
124
- apply: (chosen: string | null) => void,
125
- ): Promise<void> {
126
- const chosen = await promptModelSelection(ctx, modelOptions, currentValue);
127
- if (chosen === null) return;
128
-
129
- const effective = chosen === "(inherits parent)" ? null : chosen;
130
- apply(effective);
131
- ctx.ui.notify(
132
- effective === null
133
- ? `${label} inherits parent model`
134
- : `${label} model set to ${effective}`,
135
- "info",
136
- );
137
- }
138
-
139
- /**
140
- * Prompt for numeric input, validate (integer ≥ min), return parsed value or undefined.
141
- * Returns undefined if the user cancels or the value is invalid.
142
- */
143
- async function parseNumericInput(
144
- ctx: ExtensionCommandContext,
145
- label: string,
146
- initialValue: string,
147
- min: number,
148
- minLabel: string,
149
- ): Promise<number | undefined> {
150
- const input = await ctx.ui.input(label, initialValue);
151
- if (input === undefined) return undefined;
152
- const parsed = parseInt(input.trim(), 10);
153
- if (isNaN(parsed) || parsed < min) {
154
- ctx.ui.notify(`Invalid value — must be a number ${minLabel}`, "error");
155
- return undefined;
156
- }
157
- return parsed;
158
- }
159
-
160
- /**
161
- * Parse a concurrency input: prompt, validate (integer ≥ 1), return parsed value or undefined.
162
- */
163
- async function parseConcurrencyInput(
164
- ctx: ExtensionCommandContext,
165
- label: string,
166
- initialValue: string,
167
- ): Promise<number | undefined> {
168
- return parseNumericInput(ctx, label, initialValue, 1, "≥ 1");
169
- }
170
-
171
- /**
172
- * Prompt for a concurrency value, validate, and apply via setter.
173
- * The setter handles save + sync internally.
174
- */
175
- async function promptConcurrencyInput(
176
- ctx: ExtensionCommandContext,
177
- label: string,
178
- currentValue: number,
179
- setter: (value: number) => void,
180
- ): Promise<void> {
181
- const parsed = await parseConcurrencyInput(ctx, label, String(currentValue));
182
- if (parsed === undefined) return;
183
- setter(parsed);
184
- ctx.ui.notify(
185
- `${label.replace("Concurrency slots for ", "")} concurrency set to ${parsed}`,
186
- "info",
187
- );
188
- }
189
-
190
- /**
191
- * Prompt to add a new concurrency limit for a named entity.
192
- * Calls the setter which handles save + sync internally.
193
- */
194
- async function promptAddConcurrencyLimit(
195
- ctx: ExtensionCommandContext,
196
- label: string,
197
- setter: (key: string, value: number) => void,
198
- ): Promise<void> {
199
- const parsed = await parseConcurrencyInput(ctx, "Concurrency slots", "1");
200
- if (parsed === undefined) return;
201
- setter(label, parsed);
202
- ctx.ui.notify(`${label} concurrency set to ${parsed}`, "info");
203
- }
204
-
205
- /**
206
- * Show a select menu once, dispatch the chosen action.
207
- * Used by the per-agent action sub-menu (single-shot, not a loop).
208
- */
209
- async function runMenu(
210
- ctx: ExtensionCommandContext,
211
- title: string,
212
- items: string[],
213
- actions: Array<() => Promise<void>>,
214
- ): Promise<void> {
215
- const choice = await ctx.ui.select(title, items);
216
- if (choice === undefined) return;
217
- const idx = items.indexOf(choice);
218
- if (idx >= 0 && idx < actions.length) {
219
- await actions[idx]();
220
- }
221
- }
222
-
223
- /**
224
- * Loop a menu until the user presses Escape or selects "Back".
225
- * Rebuilds items/actions each iteration so the display stays fresh.
226
- * Appends blank spacer + "Back" automatically.
227
- * Used by model settings, concurrency settings, and running agents menus.
228
- */
229
- async function runMenuLoop(
230
- ctx: ExtensionCommandContext,
231
- title: string,
232
- build: () => { items: string[]; actions: Array<() => Promise<void>> },
233
- ): Promise<void> {
234
- while (true) {
235
- const { items, actions } = build();
236
- items.push("");
237
- actions.push(async () => {});
238
- items.push("Back");
239
- actions.push(async () => {});
240
-
241
- const choice = await ctx.ui.select(title, items);
242
- if (choice === undefined || choice === "Back") return;
243
- const idx = items.indexOf(choice);
244
- if (idx >= 0 && idx < actions.length) {
245
- await actions[idx]();
246
- }
247
- }
248
- }
249
-
250
- // ============================================================================
251
- // Worktree picker helpers
252
- // ============================================================================
253
-
254
- /** Timeout for git worktree list command (ms). */
255
- const WORKTREE_LIST_TIMEOUT_MS = 5000;
256
-
257
- /** Max display length for a worktree path before truncation. */
258
- const WORKTREE_PATH_TRUNCATE_LEN = 60;
259
-
260
- interface WorktreeEntry {
261
- path: string;
262
- branch: string | null;
263
- isDetached: boolean;
264
- }
265
-
266
- /**
267
- * Parse `git worktree list --porcelain` output into structured entries.
268
- *
269
- * Format (one block per worktree, separated by blank lines):
270
- * worktree /path/to/worktree
271
- * HEAD <sha>
272
- * branch refs/heads/<name> (or: (detached))
273
- */
274
- function parseWorktreeList(output: string): WorktreeEntry[] {
275
- const entries: WorktreeEntry[] = [];
276
- const blocks = output.split(/\n\n+/);
277
- for (const block of blocks) {
278
- if (!block.trim()) continue;
279
- const lines = block.split("\n");
280
- let path = "";
281
- let branch: string | null = null;
282
- let isDetached = false;
283
- for (const line of lines) {
284
- if (line.startsWith("worktree ")) {
285
- path = line.slice("worktree ".length);
286
- } else if (line.startsWith("branch refs/heads/")) {
287
- branch = line.slice("branch refs/heads/".length);
288
- } else if (line === "detached") {
289
- isDetached = true;
290
- }
291
- }
292
- if (path) {
293
- entries.push({ path, branch, isDetached });
294
- }
295
- }
296
- return entries;
297
- }
298
-
299
- /** Truncate a path for display, keeping the tail. */
300
- function truncatePath(p: string): string {
301
- if (p.length <= WORKTREE_PATH_TRUNCATE_LEN) return p;
302
- return "..." + p.slice(p.length - WORKTREE_PATH_TRUNCATE_LEN + 3);
303
- }
304
-
305
- /**
306
- * Fetch worktrees via `git worktree list --porcelain`.
307
- * Returns null if git is unavailable or the command fails.
308
- */
309
- async function listWorktrees(cwd: string): Promise<WorktreeEntry[] | null> {
310
- try {
311
- const result = await piInstance.exec(
312
- "git",
313
- ["worktree", "list", "--porcelain"],
314
- { cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
315
- );
316
- if (result.code !== 0) return null;
317
- return parseWorktreeList(result.stdout);
318
- } catch {
319
- return null;
320
- }
321
- }
322
-
323
- /**
324
- * Check whether a directory is inside a git repository.
325
- * Uses `git rev-parse --git-common-dir` — the same strategy as the worktree validator.
326
- */
327
- async function isInGitRepo(cwd: string): Promise<boolean> {
328
- try {
329
- const result = await piInstance.exec(
330
- "git",
331
- ["rev-parse", "--git-common-dir"],
332
- { cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
333
- );
334
- return result.code === 0 && result.stdout.trim() !== "";
335
- } catch {
336
- return false;
337
- }
338
- }
339
-
340
- // ============================================================================
341
- // /agents command handler
342
- // ============================================================================
343
-
344
- export async function showModelSettingsMenu(
345
- ctx: ExtensionCommandContext,
346
- modelOptions: string[],
347
- ): Promise<void> {
348
- return runMenuLoop(ctx, "Model Settings", () => {
349
- const items: string[] = [];
350
- const actions: Array<() => Promise<void>> = [];
351
-
352
- // ── Session overrides section ──
353
- const hasSessionOverrides = Object.entries(sessionOverrides).some(
354
- ([, v]) => v != null,
355
- );
356
-
357
- const buildOverrideAction = (
358
- label: string,
359
- targetKey: string,
360
- currentValue: string,
361
- hasPermanentOverride: boolean = false,
362
- ) => async () => {
363
- const mode = await promptOverrideMode(ctx, hasPermanentOverride);
364
- if (mode === null) return;
365
-
366
- // Handle "clear" — remove all overrides (session + config) and save
367
- if (mode === "clear") {
368
- clearModelOverride(targetKey);
369
- if (targetKey !== "default") {
370
- delete sessionOverrides[targetKey];
371
- } else {
372
- sessionOverrides.default = null;
373
- }
374
- ctx.ui.notify(`${label} overrides cleared`, "info");
375
- return;
376
- }
377
-
378
- const isSession = mode === "session";
379
- await applyModelOverride(
380
- ctx, modelOptions, label,
381
- currentValue,
382
- isSession
383
- ? (chosen) => { sessionOverrides[targetKey] = chosen; }
384
- : (chosen) => {
385
- setModelOverride(targetKey, chosen);
386
- },
387
- );
388
- };
389
-
390
- // Global default — show session value if present
391
- const hasSessionGlobal = sessionOverrides.default != null;
392
- const globalLabel = hasSessionGlobal
393
- ? `Global default model · ${sessionOverrides.default} [session]`
394
- : __config.agent.default
395
- ? `Global default model · ${__config.agent.default}`
396
- : "Global default model · (inherits parent)";
397
- items.push(globalLabel);
398
- actions.push(buildOverrideAction(
399
- "Global default", "default",
400
- hasSessionGlobal
401
- ? sessionOverrides.default!
402
- : __config.agent.default ?? "(inherits parent)",
403
- ));
404
-
405
- // Force background toggle
406
- const forceBgLabel = __config.agent.forceBackground
407
- ? "Force background · ON"
408
- : "Force background · OFF";
409
- items.push(forceBgLabel);
410
- actions.push(async () => {
411
- setForceBackground(!__config.agent.forceBackground);
412
- ctx.ui.notify(
413
- `Force background ${__config.agent.forceBackground ? "ON" : "OFF"}`,
414
- "info",
415
- );
416
- });
417
-
418
- // Cost display toggle
419
- const showCost = __config.agent.showCost === true; // default false
420
- items.push(`Cost display · ${showCost ? "ON" : "OFF"}`);
421
- actions.push(async () => {
422
- setShowCost(!showCost);
423
- ctx.ui.notify(`Cost display ${showCost ? "OFF" : "ON"}`, "info");
424
- });
425
-
426
- // Grace turns setting
427
- const graceTurns = __config.agent.graceTurns ?? 6;
428
- items.push(`Grace turns · ${graceTurns}`);
429
- actions.push(async () => {
430
- const parsed = await parseNumericInput(ctx, "Grace turns (≥ 0)", String(graceTurns), 0, "≥ 0");
431
- if (parsed === undefined) return;
432
- setGraceTurns(parsed);
433
- ctx.ui.notify(`Grace turns set to ${parsed}`, "info");
434
- });
435
-
436
- items.push("");
437
- actions.push(async () => {});
438
- items.push("─── per-type overrides ───");
439
- actions.push(async () => {}); // separator
440
-
441
- // Per-type overrides — show only types with an explicit override (session or config)
442
- // All others inherit the global default; accessible via "Override another type..."
443
- const types = getAllTypes();
444
- const typeEntries = types.map((typeName) => {
445
- const cfg = getAgentConfig(typeName);
446
- const sessionOverride = sessionOverrides[typeName];
447
- const configOverride = __config.agent[typeName];
448
- const hasSession = sessionOverride != null;
449
- const hasConfigOverride = configOverride != null && typeof configOverride === "string";
450
- const effectiveModel = resolveModel({
451
- subagentType: typeName,
452
- agentConfig: cfg,
453
- config: __config,
454
- parentModelId: "(inherits parent)",
455
- sessionOverrides,
456
- });
457
- return { typeName, cfg, sessionOverride, configOverride, hasSession, hasConfigOverride, effectiveModel };
458
- });
459
-
460
- const overridden = typeEntries.filter(e => e.hasSession || e.hasConfigOverride);
461
- const nonOverridden = typeEntries.filter(e => !e.hasSession && !e.hasConfigOverride);
462
-
463
- if (overridden.length === 0) {
464
- items.push(" (all inherit global default)");
465
- actions.push(async () => {}); // no-op
466
- } else {
467
- overridden.sort((a, b) => a.effectiveModel.localeCompare(b.effectiveModel));
468
- const padLen = Math.max(...types.map(t => t.length));
469
- for (const { typeName, cfg, sessionOverride, configOverride, hasSession, effectiveModel } of overridden) {
470
- const frontmatterHint = !hasSession && configOverride && cfg?.model ? `${cfg.model} → ` : "";
471
- const displayModel = hasSession ? `${sessionOverride} [session]` : effectiveModel;
472
- items.push(`${typeName.padEnd(padLen)} · ${frontmatterHint}${displayModel}`);
473
-
474
- const currentValue = hasSession ? sessionOverride! : effectiveModel;
475
- actions.push(buildOverrideAction(typeName, typeName, currentValue, !!configOverride));
476
- }
477
- }
478
-
479
- // Add override for a type that currently inherits
480
- if (nonOverridden.length > 0) {
481
- items.push("Override another type...");
482
- actions.push(async () => {
483
- const typeNames = nonOverridden.map(e => e.typeName);
484
- const chosen = await ctx.ui.select("Select agent type", typeNames);
485
- if (chosen === undefined) return;
486
- const entry = nonOverridden.find(e => e.typeName === chosen)!;
487
- const action = buildOverrideAction(chosen, chosen, entry.effectiveModel, false);
488
- await action();
489
- });
490
- }
491
-
492
- // Clear session overrides
493
- if (hasSessionOverrides) {
494
- items.push("Clear session overrides");
495
- actions.push(async () => {
496
- sessionOverrides.default = null;
497
- for (const key of Object.keys(sessionOverrides)) {
498
- if (key !== "default") {
499
- delete sessionOverrides[key];
500
- }
501
- }
502
- ctx.ui.notify("Session overrides cleared", "info");
503
- });
504
- }
505
-
506
- // Clear all overrides
507
- items.push("Clear all overrides");
508
- actions.push(async () => {
509
- const hasOverrides = Object.entries(__config.agent).some(
510
- ([k, v]) => !CONFIG_AGENT_NON_MODEL_KEYS.includes(k) && v != null,
511
- );
512
- if (!hasOverrides && __config.agent.default === null) {
513
- ctx.ui.notify("No overrides to clear", "info");
514
- return;
515
- }
516
- clearAllModelOverrides();
517
- ctx.ui.notify("All model overrides cleared", "info");
518
- });
519
-
520
- return { items, actions };
521
- });
522
- }
523
-
524
- /** Map menu choice to handler. Matches by number prefix or first word. */
525
- function matchMenuChoice(
526
- choice: string,
527
- handlers: Record<string, () => Promise<void>>,
528
- ): (() => Promise<void>) | undefined {
529
- // Try number prefix first (e.g., "1." from "1. Running agents")
530
- const numMatch = choice.match(/^(\d+)/);
531
- if (numMatch) return handlers[numMatch[1]];
532
- // Fall back to first word
533
- const key = choice.split(" ")[0].toLowerCase();
534
- return handlers[key];
535
- }
536
-
537
- // ============================================================================
538
- // Spawn agent menu
539
- // ============================================================================
540
-
541
- const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
542
-
543
- /**
544
- * Show the spawn agent flow: type selection → prompt → options sub-menu → spawn.
545
- * Escape at any step aborts the flow and returns to the main menu.
546
- */
547
- export async function showSpawnAgentMenu(
548
- ctx: ExtensionCommandContext,
549
- modelOptions: string[],
550
- ): Promise<void> {
551
- // Step 1: Type selection loop (unknown type → error → retry)
552
- let selectedType: string;
553
- while (true) {
554
- const types = getAvailableTypes();
555
- if (types.length === 0) {
556
- ctx.ui.notify("No agent types available", "error");
557
- return;
558
- }
559
- const type = await ctx.ui.select("Select agent type", types);
560
- if (type === undefined) return; // Escape → main menu
561
-
562
- const config = getAgentConfig(type);
563
- if (!config) {
564
- ctx.ui.notify(`Unknown agent type: ${type}`, "error");
565
- continue; // Loop back to type selection
566
- }
567
- selectedType = type;
568
- break;
569
- }
570
-
571
- const agentConfig = getAgentConfig(selectedType)!;
572
-
573
- // Step 2: Prompt entry loop (empty prompt → error → retry)
574
- let prompt: string;
575
- while (true) {
576
- const input = await ctx.ui.input("Agent prompt");
577
- if (input === undefined) return; // Escape → main menu
578
-
579
- if (!input.trim()) {
580
- ctx.ui.notify("Prompt cannot be empty", "error");
581
- continue; // Loop back to prompt input
582
- }
583
- prompt = input.trim();
584
- break;
585
- }
586
-
587
- // Step 3: Options sub-menu with spawn action
588
- const autoDescription = prompt.length > 50 ? prompt.slice(0, 50) : prompt;
589
- let description = autoDescription;
590
-
591
- // Check if parent's cwd is inside a git repo (for worktree picker visibility)
592
- const parentCwd = sessionCtx?.cwd ?? "";
593
- const inGitRepo = parentCwd ? await isInGitRepo(parentCwd) : false;
594
-
595
- // Worktree picker state
596
- let currentWorktreePath: string | undefined;
597
- let currentWorktreeLabel = "Inherits parent cwd";
598
-
599
- // Pre-fill model from precedence chain
600
- const parentModelId = sessionCtx?.model
601
- ? `${sessionCtx.model.provider}/${sessionCtx.model.id}`
602
- : "";
603
- const effectiveModelStr = resolveModel({
604
- subagentType: selectedType,
605
- agentConfig,
606
- config: __config,
607
- parentModelId,
608
- sessionOverrides,
609
- });
610
- let currentModelStr = effectiveModelStr || ""; // "" means inherit parent
611
- let currentThinking: ThinkingLevel | undefined = agentConfig.thinking;
612
- let currentMaxTurns: number | undefined = agentConfig.maxTurns;
613
- let currentGraceTurns: number | undefined = __config.agent.graceTurns ?? 6;
614
- let currentBackground: boolean = __config.agent.forceBackground ?? false;
615
-
616
- while (true) {
617
- const displayModel = currentModelStr || "(inherits parent)";
618
- const displayThinking = currentThinking ?? "inherit";
619
- const displayMaxTurns = currentMaxTurns != null ? String(currentMaxTurns) : "unlimited";
620
- const displayGraceTurns = String(currentGraceTurns ?? 6);
621
- const displayBackground = currentBackground ? "ON" : "OFF";
622
-
623
- const items = [
624
- "Spawn",
625
- "",
626
- `Model · ${displayModel}`,
627
- `Background · ${displayBackground}`,
628
- `Thinking · ${displayThinking}`,
629
- `Max turns · ${displayMaxTurns}`,
630
- `Grace turns · ${displayGraceTurns}`,
631
- `Description · ${description}`,
632
- ];
633
-
634
- if (inGitRepo) {
635
- items.push(`Worktree · ${currentWorktreeLabel}`);
636
- };
637
-
638
- const choice = await ctx.ui.select("Spawn Options", items);
639
- if (choice === undefined) return; // Escape → main menu
640
-
641
- if (choice === "Spawn") {
642
- // Resolve model string to Model object
643
- let model: ReturnType<typeof findModelInRegistry> = undefined;
644
- let modelKey: string | undefined;
645
-
646
- if (currentModelStr) {
647
- const registry = sessionCtx?.modelRegistry ?? ctx.modelRegistry;
648
- model = findModelInRegistry(currentModelStr, registry, undefined);
649
- if (!model) {
650
- ctx.ui.notify(`Model not found: ${currentModelStr}`, "error");
651
- continue; // Return to options sub-menu
652
- }
653
- modelKey = `${model.provider}/${model.id}`;
654
- }
655
-
656
- // Discover worktree-local agent types before spawn
657
- if (currentWorktreePath) {
658
- await discoverNewAgents(`${currentWorktreePath}/.pi/agents`);
659
- }
660
- // Resolve type (may have been discovered from worktree)
661
- const resolvedType = resolveType(selectedType) ?? selectedType;
662
-
663
- const spawnOptions: SpawnOptions = {
664
- description,
665
- model,
666
- maxTurns: currentMaxTurns,
667
- thinkingLevel: currentThinking,
668
- isBackground: currentBackground,
669
- modelKey,
670
- invocation: {
671
- modelName: model?.id,
672
- thinking: currentThinking,
673
- maxTurns: currentMaxTurns,
674
- runInBackground: currentBackground,
675
- },
676
- graceTurns: currentGraceTurns,
677
- worktreePath: currentWorktreePath,
678
- worktreeLabel: currentWorktreePath ? currentWorktreeLabel : undefined,
679
- };
680
-
681
- const { state: activityState, callbacks } = createActivityTracker(currentMaxTurns);
682
-
683
- let agentId: string;
684
- try {
685
- agentId = getManager().spawn(piInstance, sessionCtx, resolvedType, prompt, {
686
- ...spawnOptions,
687
- ...callbacks,
688
- });
689
- } catch (err) {
690
- ctx.ui.notify(
691
- `Spawn failed: ${err instanceof Error ? err.message : String(err)}`,
692
- "error",
693
- );
694
- return; // Return to main menu
695
- }
696
-
697
- // Wire activity tracking for widget
698
- agentActivity.set(agentId, activityState);
699
- // Set UI context so widget can render (same as tool_execution_start handler)
700
- const widget = getWidget();
701
- if (widget) {
702
- widget.setUICtx(ctx.ui as unknown as import("./ui/agent-widget.js").UICtx);
703
- widget.ensureTimer();
704
- widget.update();
705
- }
706
-
707
- if (currentBackground) {
708
- backgroundAgentIds.add(agentId);
709
- return; // Background: return to main menu immediately
710
- }
711
-
712
- // Foreground: block until completion
713
- const fgRecord = getManager().getRecord(agentId);
714
- if (fgRecord?.execution?.promise) {
715
- await fgRecord.execution.promise;
716
- }
717
-
718
- agentActivity.delete(agentId);
719
- getWidget()?.markFinished(agentId);
720
- getWidget()?.update();
721
-
722
- return; // Return to main menu
723
- }
724
-
725
- // Handle option changes
726
- if (choice.startsWith("Description")) {
727
- const input = await ctx.ui.input("Description", description);
728
- if (input !== undefined && input.trim()) {
729
- description = input.trim();
730
- }
731
- } else if (choice.startsWith("Model")) {
732
- const chosen = await promptModelSelection(
733
- ctx, modelOptions, currentModelStr || "(inherits parent)",
734
- );
735
- if (chosen !== null) {
736
- currentModelStr = chosen === "(inherits parent)" ? "" : chosen;
737
- }
738
- } else if (choice.startsWith("Thinking")) {
739
- const allLevels = [...THINKING_LEVELS, "inherit"];
740
- const chosen = await ctx.ui.select("Thinking level", allLevels);
741
- if (chosen !== undefined) {
742
- currentThinking = chosen === "inherit" ? undefined : (chosen as ThinkingLevel);
743
- }
744
- } else if (choice.startsWith("Max turns")) {
745
- const initial = currentMaxTurns != null ? String(currentMaxTurns) : "unlimited";
746
- const input = await ctx.ui.input("Max turns (number or 'unlimited')", initial);
747
- if (input !== undefined) {
748
- const trimmed = input.trim().toLowerCase();
749
- if (trimmed === "unlimited" || trimmed === "") {
750
- currentMaxTurns = undefined;
751
- } else {
752
- const parsed = parseInt(trimmed, 10);
753
- if (isNaN(parsed) || parsed < 1) {
754
- ctx.ui.notify("Invalid value — must be a number ≥ 1 or 'unlimited'", "error");
755
- } else {
756
- currentMaxTurns = parsed;
757
- }
758
- }
759
- }
760
- } else if (choice.startsWith("Grace turns")) {
761
- const parsed = await parseNumericInput(ctx, "Grace turns (≥ 0)", String(currentGraceTurns ?? 6), 0, "≥ 0");
762
- if (parsed !== undefined) currentGraceTurns = parsed;
763
- } else if (choice.startsWith("Background")) {
764
- currentBackground = !currentBackground;
765
- } else if (choice.startsWith("Worktree") && inGitRepo) {
766
- // Open worktree picker
767
- const worktrees = await listWorktrees(parentCwd);
768
- if (!worktrees || worktrees.length === 0) {
769
- ctx.ui.notify(
770
- "No worktrees found or git worktree list unavailable",
771
- "error",
772
- );
773
- continue; // Return to options sub-menu
774
- }
775
-
776
- const pickerItems = [
777
- "Inherits parent cwd",
778
- ...worktrees.map(wt => {
779
- const branchLabel = wt.isDetached ? "detached" : (wt.branch ?? "detached");
780
- const truncPath = truncatePath(wt.path);
781
- return `${branchLabel} · ${truncPath}`;
782
- }),
783
- ];
784
-
785
- const picked = await ctx.ui.select("Select worktree", pickerItems);
786
- if (picked === undefined) continue; // Escape → return to options sub-menu
787
-
788
- if (picked === "Inherits parent cwd") {
789
- currentWorktreePath = undefined;
790
- currentWorktreeLabel = "Inherits parent cwd";
791
- } else {
792
- // Find the matching worktree by index (offset by "Inherits parent cwd")
793
- const idx = pickerItems.indexOf(picked) - 1;
794
- if (idx >= 0 && idx < worktrees.length) {
795
- const wt = worktrees[idx];
796
- currentWorktreePath = wt.path;
797
- currentWorktreeLabel = wt.branch ?? "detached";
798
- }
799
- }
800
- }
801
- }
802
- }
803
-
804
- export async function showSettingsMenu(
805
- ctx: ExtensionCommandContext,
806
- modelOptions: string[],
807
- ): Promise<void> {
808
- const menuItems = [
809
- "1. Model settings — Set global default and per-type model overrides",
810
- "2. Concurrency settings — Set per-model slot limits",
811
- "3. Widget settings — Configure widget display options",
812
- "",
813
- "Back",
814
- ];
815
-
816
- const handlers: Record<string, () => Promise<void>> = {
817
- "1": () => showModelSettingsMenu(ctx, modelOptions),
818
- "2": () => showConcurrencySettingsMenu(ctx, modelOptions),
819
- "3": () => showWidgetSettingsMenu(ctx),
820
- };
821
-
822
- while (true) {
823
- const choice = await ctx.ui.select("Settings", menuItems);
824
- if (choice === undefined || choice === "Back") return;
825
-
826
- const action = matchMenuChoice(choice, handlers);
827
- if (action) await action();
828
- }
829
- }
830
-
831
- export async function showAgentsMainMenu(
832
- ctx: ExtensionCommandContext,
833
- modelOptions: string[],
834
- ): Promise<void> {
835
- const menuItems = [
836
- "1. Running agents — List running/queued agents",
837
- "2. Spawn agent — Manually spawn a new agent",
838
- "3. Settings — Model, concurrency, and widget settings",
839
- "4. Debug — Agent types, briefing, diagnostics",
840
- "",
841
- "Press Escape to close",
842
- ];
843
-
844
- const handlers: Record<string, () => Promise<void>> = {
845
- "1": () => showRunningAgentsMenu(ctx),
846
- "2": () => showSpawnAgentMenu(ctx, modelOptions),
847
- "3": () => showSettingsMenu(ctx, modelOptions),
848
- "4": () => showDebugMenu(ctx),
849
- };
850
-
851
- // Loop so sub-menus navigate back to root; only Escape at root closes
852
- while (true) {
853
- const choice = await ctx.ui.select("Subagents Management", menuItems);
854
- if (choice === undefined || choice === "Press Escape to close") return;
855
-
856
- const action = matchMenuChoice(choice, handlers);
857
- if (action) await action();
858
- }
859
- }
860
-
861
- async function showDebugMenu(ctx: ExtensionCommandContext): Promise<void> {
862
- const menuItems = [
863
- "1. Agent types — List available agent types and their configs",
864
- "2. Agent briefing — Send agent types/capabilities info to LLM (Optional, if having issues)",
865
- ];
866
-
867
- const handlers: Record<string, () => Promise<void>> = {
868
- "1": () => showAgentTypes(ctx),
869
- "2": () => handleAgentBriefing(ctx),
870
- };
871
-
872
- while (true) {
873
- const choice = await ctx.ui.select("Debug", menuItems);
874
- if (choice === undefined) return;
875
-
876
- const action = matchMenuChoice(choice, handlers);
877
- if (action) await action();
878
- }
879
- }
880
-
881
- export async function showWidgetSettingsMenu(ctx: ExtensionCommandContext): Promise<void> {
882
- return runMenuLoop(ctx, "Widget Settings", () => {
883
- const items: string[] = [];
884
- const actions: Array<() => Promise<void>> = [];
885
-
886
- // Force compact mode toggle
887
- const isForceCompact = __config.agent.widgetCompact === true;
888
- items.push(`Force compact mode · ${isForceCompact ? "ON" : "OFF"}`);
889
- actions.push(async () => {
890
- setWidgetCompact(!isForceCompact);
891
- ctx.ui.notify(`Force compact mode ${__config.agent.widgetCompact ? "ON" : "OFF"}`, "info");
892
- });
893
-
894
- // Max lines (full mode)
895
- const maxLines = __config.agent.widgetMaxLines ?? 12;
896
- items.push(`Max lines (full) · ${maxLines}`);
897
- actions.push(async () => {
898
- const parsed = await parseNumericInput(ctx, "Max lines (full mode, ≥ 2)", String(maxLines), 2, "≥ 2");
899
- if (parsed === undefined) return;
900
- setWidgetMaxLines(parsed);
901
- ctx.ui.notify(`Max lines (full) set to ${parsed}`, "info");
902
- });
903
-
904
- // Max lines (compact mode)
905
- const maxLinesCompact = __config.agent.widgetMaxLinesCompact ?? Math.floor(maxLines / 2);
906
- items.push(`Max lines (compact) · ${maxLinesCompact}`);
907
- actions.push(async () => {
908
- const parsed = await parseNumericInput(ctx, "Max lines (compact mode, ≥ 1)", String(maxLinesCompact), 1, "≥ 1");
909
- if (parsed === undefined) return;
910
- setWidgetMaxLinesCompact(parsed);
911
- ctx.ui.notify(`Max lines (compact) set to ${parsed}`, "info");
912
- });
913
-
914
- // Ctrl+o shortcut toggle
915
- const shortcutEnabled = __config.agent.widgetShortcut === true;
916
- items.push(`Ctrl+o shortcut · ${shortcutEnabled ? "ON" : "OFF"}`);
917
- actions.push(async () => {
918
- setWidgetShortcut(!shortcutEnabled);
919
- ctx.ui.notify(`Ctrl+o shortcut ${__config.agent.widgetShortcut ? "ON" : "OFF"}`, "info");
920
- });
921
-
922
- return { items, actions };
923
- });
924
- }
925
-
926
- async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void> {
927
- const types = getAvailableTypes();
928
- const agents = types.map((t) => ({ name: t, config: getAgentConfig(t) }));
929
-
930
- const lines: string[] = [
931
- "# Agent Types and Capabilities\n",
932
- "The following agent types are available. Use the `agent` parameter to select one.\n",
933
- ];
934
-
935
- for (const { name, config } of agents) {
936
- if (!config) continue;
937
- lines.push(`## ${config.displayName ?? name}`);
938
- lines.push(config.description);
939
- lines.push("");
940
-
941
- if (config.registeredTools) {
942
- lines.push(`**Tools:** ${config.registeredTools.join(", ")}`);
943
- }
944
- if (config.model) {
945
- lines.push(`**Default model:** ${config.model}`);
946
- }
947
- if (config.maxTurns) {
948
- lines.push(`**Max turns:** ${config.maxTurns}`);
949
- }
950
- lines.push("");
951
- }
952
-
953
- // Parameter descriptions
954
- lines.push("## Agent Tool Parameters\n");
955
- lines.push("| Parameter | Description |");
956
- lines.push("|-----------|-------------|");
957
- lines.push("| `prompt` | The task for the agent (required) |");
958
- lines.push("| `description` | One-line summary of what the agent should do (required) |");
959
- lines.push("| `agent` | Which agent type to use (default: general-purpose) |");
960
- lines.push("| `thinking` | Optional thinking mode override (e.g., `off`, `minimal`, `low`, `medium`, `high`, `xhigh`) |");
961
- lines.push("| `run_in_background` | When `true`, result is auto-delivered — do NOT poll. Continue working while waiting. |");
962
- lines.push("| `worktree_path` | Optional path to a git worktree of the parent's repo. See below for details. |");
963
- lines.push("");
964
-
965
- // Usage guidelines
966
- lines.push("## Usage Guidelines\n");
967
- lines.push("- Agents start fresh with their config — they do NOT inherit the parent conversation");
968
- lines.push("- For parallel tasks, spawn multiple `run_in_background: true` agents in one turn");
969
- lines.push(" → Results are auto-delivered — do NOT poll, the result will arrive when ready");
970
- lines.push("");
971
- lines.push("## `worktree_path` Parameter\n");
972
- lines.push("Use `worktree_path` to run a subagent in a different git worktree of the parent's repository.");
973
- lines.push("");
974
- lines.push("- **Optional.** Omit to run the subagent in the parent's working directory (default behavior).");
975
- lines.push("- **Must be a path** inside a git worktree of the parent's repo, including the main checkout. Not a different repo, not a non-git directory.");
976
- lines.push("- **Relative paths** are resolved against the parent's working directory.");
977
- lines.push("- **On failure** the validator returns a specific reason (e.g., 'not a worktree of the parent's repository', 'path does not exist') — use this to self-correct.");
978
- lines.push("- **Agent type discovery:** The worktree's `.pi/agents/` directory is scanned for agent types when this param is set, so worktree-local types become available to that spawn.");
979
- piInstance.sendUserMessage(lines.join("\n"));
980
- ctx.ui.notify("Agent briefing sent to LLM", "info");
981
- }
982
-
983
- /**
984
- * Build a sub-menu for a single per-provider or per-model entry:
985
- * "Edit limit" to change the value, or "Remove limit" to delete it.
986
- * Callers pass setter callbacks that handle save + sync internally.
987
- */
988
- async function editOrRemoveConcurrencyEntry(
989
- ctx: ExtensionCommandContext,
990
- label: string,
991
- entityType: "provider" | "model",
992
- entityKey: string,
993
- currentValue: number,
994
- setEntry: (key: string, value: number) => void,
995
- removeEntry: () => void,
996
- ): Promise<void> {
997
- await runMenu(ctx, `${entityKey} concurrency`, [
998
- "Edit limit",
999
- "Remove limit",
1000
- ], [
1001
- async () => {
1002
- await promptConcurrencyInput(
1003
- ctx, entityKey, currentValue,
1004
- (value) => setEntry(entityKey, value),
1005
- );
1006
- },
1007
- async () => {
1008
- removeEntry();
1009
- ctx.ui.notify(
1010
- `Removed per-${entityType} limit for ${entityKey}`,
1011
- "info",
1012
- );
1013
- },
1014
- ]);
1015
- }
1016
-
1017
- export async function showConcurrencySettingsMenu(
1018
- ctx: ExtensionCommandContext,
1019
- modelOptions: string[],
1020
- ): Promise<void> {
1021
- const providers = [...new Set(modelOptions.map((m) => m.split("/")[0]))].sort();
1022
-
1023
- return runMenuLoop(ctx, "Concurrency Settings", () => {
1024
- const items: string[] = [];
1025
- const actions: Array<() => Promise<void>> = [];
1026
-
1027
- // Global default
1028
- items.push(`Default concurrency limit · ${__config.concurrency.default}`);
1029
- actions.push(async () => {
1030
- await promptConcurrencyInput(
1031
- ctx, "Default limit", __config.concurrency.default,
1032
- (value) => setConcurrencyDefault(value),
1033
- );
1034
- });
1035
-
1036
- // Reset all to defaults
1037
- items.push("Reset all to defaults");
1038
- actions.push(async () => {
1039
- resetConcurrency();
1040
- ctx.ui.notify("Concurrency reset to defaults", "info");
1041
- });
1042
-
1043
- // ── Per-provider limits ──
1044
- const providerLimits = __config.concurrency.providers ?? {};
1045
- const configuredProviders = Object.keys(providerLimits);
1046
- if (configuredProviders.length > 0) {
1047
- items.push("");
1048
- actions.push(async () => {});
1049
- items.push("─── per-provider limits ───");
1050
- actions.push(async () => {}); // separator
1051
-
1052
- for (const provider of configuredProviders) {
1053
- const limit = providerLimits[provider];
1054
- items.push(`${provider} · ${limit} slots`);
1055
- actions.push(async () => {
1056
- await editOrRemoveConcurrencyEntry(
1057
- ctx,
1058
- `Concurrency slots for ${provider}`,
1059
- "provider",
1060
- provider,
1061
- limit,
1062
- (key, value) => setConcurrencyProvider(key, value),
1063
- () => removeConcurrencyProvider(provider),
1064
- );
1065
- });
1066
- }
1067
- }
1068
-
1069
- // Add per-provider limit
1070
- items.push("Add per-provider limit...");
1071
- actions.push(async () => {
1072
- const provider = await ctx.ui.select("Select provider", providers);
1073
- if (provider === undefined) return;
1074
- await promptAddConcurrencyLimit(
1075
- ctx, provider,
1076
- (key, value) => setConcurrencyProvider(key, value),
1077
- );
1078
- });
1079
-
1080
- // ── Per-model limits ──
1081
- const models = __config.concurrency.models ?? {};
1082
- const modelKeys = Object.keys(models);
1083
- if (modelKeys.length > 0) {
1084
- items.push("");
1085
- actions.push(async () => {});
1086
- items.push("─── per-model limits ───");
1087
- actions.push(async () => {}); // separator
1088
-
1089
- for (const modelKey of modelKeys) {
1090
- const limit = models[modelKey];
1091
- items.push(`${modelKey} · ${limit} slots`);
1092
- actions.push(async () => {
1093
- await editOrRemoveConcurrencyEntry(
1094
- ctx,
1095
- `Concurrency slots for ${modelKey}`,
1096
- "model",
1097
- modelKey,
1098
- limit,
1099
- (key, value) => setConcurrencyModel(key, value),
1100
- () => removeConcurrencyModel(modelKey),
1101
- );
1102
- });
1103
- }
1104
- }
1105
-
1106
- // Add per-model limit
1107
- items.push("Add per-model limit...");
1108
- actions.push(async () => {
1109
- const modelKey = await promptModelSelection(
1110
- ctx, modelOptions, __config.agent.default ?? "(inherits parent)",
1111
- );
1112
- if (modelKey === null) return;
1113
- await promptAddConcurrencyLimit(
1114
- ctx, modelKey.trim(),
1115
- (key, value) => setConcurrencyModel(key, value),
1116
- );
1117
- });
1118
-
1119
- return { items, actions };
1120
- });
1121
- }
1122
-
1123
- async function showRunningAgentsMenu(
1124
- ctx: ExtensionCommandContext,
1125
- ): Promise<void> {
1126
- const records = getManager()?.listAgents() ?? [];
1127
- if (records.length === 0) {
1128
- ctx.ui.notify("No agents have been spawned this session", "info");
1129
- return;
1130
- }
1131
-
1132
- return runMenuLoop(ctx, "Running Agents", () => {
1133
- const records = getManager()?.listAgents() ?? [];
1134
- const running = records.filter((r) => r.lifecycle.status === "running" || r.lifecycle.status === "queued");
1135
-
1136
- const items: string[] = [];
1137
- const actions: Array<() => Promise<void>> = [];
1138
-
1139
- for (const record of records) {
1140
- const elapsed = Math.round((Date.now() - record.lifecycle.startedAt) / 1000);
1141
- const statusIcon = record.lifecycle.status === "running" ? "▶" :
1142
- record.lifecycle.status === "completed" ? "✓" :
1143
- record.lifecycle.status === "queued" ? "⏳" :
1144
- record.lifecycle.status === "error" ? "✗" : "•";
1145
- const headline = record.display.description
1146
- ? (record.display.description.length > 50 ? record.display.description.slice(0, 47) + "..." : record.display.description)
1147
- : "";
1148
- const suffix = headline ? ` — ${headline}` : "";
1149
- items.push(
1150
- `${statusIcon} ${record.id.slice(0, SHORT_ID_LENGTH)} ${record.display.type} ${record.lifecycle.status} ${elapsed}s${suffix}`,
1151
- );
1152
-
1153
- actions.push(async () => {
1154
- await showAgentActions(ctx, record);
1155
- });
1156
- }
1157
-
1158
- if (running.length > 0) {
1159
- items.push("");
1160
- actions.push(async () => {});
1161
- items.push("─── actions ───");
1162
- actions.push(async () => {}); // separator
1163
-
1164
- items.push(`Stop ${running.length} running agent(s)`);
1165
- actions.push(async () => {
1166
- for (const record of running) {
1167
- getManager()?.abort(record.id);
1168
- }
1169
- ctx.ui.notify(`Stopped ${running.length} agent(s)`, "info");
1170
- });
1171
- }
1172
-
1173
- return { items, actions };
1174
- });
1175
- }
1176
-
1177
- /**
1178
- * Show a ResultViewer for an agent's result, error, or snapshot.
1179
- * @param kind — "result", "error", or "snapshot" — used for the title suffix
1180
- */
1181
- async function showResultViewer(
1182
- ctx: ExtensionCommandContext,
1183
- record: AgentRecord,
1184
- kind: "result" | "error" | "snapshot",
1185
- text: string,
1186
- ): Promise<void> {
1187
- const titleSuffix = kind === "result"
1188
- ? record.id.slice(0, SHORT_ID_LENGTH)
1189
- : kind === "snapshot"
1190
- ? `snapshot \u00b7 ${record.id.slice(0, SHORT_ID_LENGTH)}`
1191
- : "Error";
1192
- const stats: ResultViewerStats = {
1193
- lifetimeUsage: record.stats.lifetimeUsage,
1194
- turnCount: record.stats.turnCount,
1195
- durationMs: (record.lifecycle.completedAt ?? Date.now()) - record.lifecycle.startedAt,
1196
- };
1197
- const refreshCallback =
1198
- kind === "snapshot" && record.execution.session
1199
- ? () => buildSnapshotMarkdown(record.execution.session!.messages)
1200
- : undefined;
1201
-
1202
- await ctx.ui.custom<void>(
1203
- (tui, theme, _kb, done) =>
1204
- new ResultViewer(
1205
- `${getDisplayName(record.display.type)} · ${titleSuffix}`,
1206
- text,
1207
- { onClose: () => done(), onRefresh: refreshCallback },
1208
- theme,
1209
- tui.terminal.rows,
1210
- stats,
1211
- ),
1212
- );
1213
- }
1214
-
1215
- /**
1216
- * Send a steer message to a specific agent. Used by the per-agent action menu.
1217
- */
1218
- async function steerAgentById(
1219
- agentId: string,
1220
- ctx: ExtensionCommandContext,
1221
- ): Promise<void> {
1222
- const record = getManager()?.getRecord(agentId);
1223
- if (!record) {
1224
- ctx.ui.notify("Agent not found", "error");
1225
- return;
1226
- }
1227
-
1228
- const message = await ctx.ui.input(`Steer ${record.display.type}`);
1229
- if (!message?.trim()) return;
1230
-
1231
- const sent = await getManager().steer(agentId, message.trim());
1232
- if (sent) {
1233
- ctx.ui.notify(`Steer sent to ${record.id.slice(0, SHORT_ID_LENGTH)}…`, "info");
1234
- } else {
1235
- ctx.ui.notify(`Steer failed for ${record.id.slice(0, SHORT_ID_LENGTH)}`, "error");
1236
- }
1237
- }
1238
-
1239
- /**
1240
- * Sub-menu with actions for a single agent. Replaces the old showAgentDetail
1241
- * notify popup — clicking an agent in the running agents menu opens actions.
1242
- */
1243
- export async function showAgentActions(
1244
- ctx: ExtensionCommandContext,
1245
- record: AgentRecord,
1246
- ): Promise<void> {
1247
- const items: string[] = [];
1248
- const actions: Array<() => Promise<void>> = [];
1249
-
1250
- const isRunning = record.lifecycle.status === "running" || record.lifecycle.status === "queued";
1251
- const hasSession = !!record.execution.session;
1252
- const hasResult = !!record.result && record.result.length > 0;
1253
- const hasError = !!record.error && record.error.length > 0;
1254
-
1255
- // View actions first
1256
- if (record.lifecycle.status === "running" && hasSession) {
1257
- items.push("View snapshot");
1258
- actions.push(async () => {
1259
- const messages = record.execution.session!.messages;
1260
- const markdown = buildSnapshotMarkdown(messages);
1261
- await showResultViewer(ctx, record, "snapshot", markdown);
1262
- });
1263
- }
1264
-
1265
- if (hasResult) {
1266
- items.push("View result");
1267
- actions.push(async () => {
1268
- await showResultViewer(ctx, record, "result", record.result!);
1269
- });
1270
- }
1271
-
1272
- if (hasError) {
1273
- items.push("View error");
1274
- actions.push(async () => {
1275
- await showResultViewer(ctx, record, "error", record.error!);
1276
- });
1277
- }
1278
-
1279
- // Then control actions
1280
- if (isRunning) {
1281
- items.push("Steer");
1282
- actions.push(async () => {
1283
- await steerAgentById(record.id, ctx);
1284
- });
1285
-
1286
- items.push("Stop");
1287
- actions.push(async () => {
1288
- getManager()?.abort(record.id);
1289
- ctx.ui.notify(`Stopped ${record.id.slice(0, SHORT_ID_LENGTH)}`, "info");
1290
- });
1291
- }
1292
-
1293
- if (items.length === 0) {
1294
- ctx.ui.notify(`Agent ${record.id.slice(0, SHORT_ID_LENGTH)} — no actions available`, "info");
1295
- return;
1296
- }
1297
-
1298
- // Append blank spacer + "Back" as the last items
1299
- items.push("");
1300
- actions.push(async () => {});
1301
- items.push("Back");
1302
- actions.push(async () => {});
1303
-
1304
- await runMenu(ctx, `Agent ${record.id.slice(0, SHORT_ID_LENGTH)}`, items, actions);
1305
- }
1306
-
1307
- async function showAgentTypes(ctx: ExtensionCommandContext): Promise<void> {
1308
- const types = getAllTypes();
1309
- if (types.length === 0) {
1310
- ctx.ui.notify("No agent types available", "info");
1311
- return;
1312
- }
1313
-
1314
- const lines: string[] = ["Available agent types:\n"];
1315
- for (const name of types) {
1316
- const cfg = getAgentConfig(name);
1317
- if (!cfg) continue;
1318
- const hidden = cfg.hidden === true ? " [HIDDEN]" : "";
1319
- const model = cfg.model ? ` Model: ${cfg.model}` : "";
1320
- const tools = cfg.registeredTools
1321
- ? ` Tools: ${cfg.registeredTools.join(", ")}`
1322
- : " Tools: all built-in tools";
1323
- const source = cfg.source ? ` Source: ${cfg.source}` : "";
1324
- lines.push(` ${name}${hidden}`);
1325
- lines.push(` ${cfg.description}`);
1326
- if (model) lines.push(model);
1327
- lines.push(tools);
1328
- if (source) lines.push(source);
1329
- lines.push("");
1330
- }
1331
-
1332
- ctx.ui.notify(lines.join("\n"), "info");
1333
- }