@bacnh85/pi-subagent 0.3.1 → 0.5.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.
@@ -15,17 +15,18 @@
15
15
 
16
16
  import * as path from "node:path";
17
17
  import type { Model } from "@earendil-works/pi-ai";
18
- import { getModel } from "@earendil-works/pi-ai/compat";
19
18
  import { StringEnum } from "@earendil-works/pi-ai";
20
19
  import {
21
20
  AuthStorage,
22
21
  CONFIG_DIR_NAME,
22
+ DynamicBorder,
23
23
  type ExtensionAPI,
24
+ type ExtensionContext,
24
25
  getAgentDir,
25
26
  getMarkdownTheme,
26
27
  ModelRegistry,
27
28
  } from "@earendil-works/pi-coding-agent";
28
- import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
29
+ import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
29
30
  import { Type } from "typebox";
30
31
 
31
32
  import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
@@ -42,6 +43,9 @@ import {
42
43
  formatUsageStats,
43
44
  renderSingleResult,
44
45
  } from "./render.ts";
46
+ import { type SubagentThread, threadStore } from "./threads.ts";
47
+ import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
48
+ import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
45
49
 
46
50
  // ---------------------------------------------------------------------------
47
51
  // Constants
@@ -51,6 +55,8 @@ const MAX_PARALLEL_TASKS = 8;
51
55
  const MAX_CONCURRENCY = 4;
52
56
  const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB per parallel task
53
57
 
58
+ import { resolveModel } from "./model.ts";
59
+
54
60
  // ---------------------------------------------------------------------------
55
61
  // Helpers
56
62
  // ---------------------------------------------------------------------------
@@ -66,35 +72,6 @@ function truncateParallelOutput(output: string): string {
66
72
  return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
67
73
  }
68
74
 
69
- interface ResolvedModel {
70
- model: Model | null;
71
- attempted: string[];
72
- }
73
-
74
- function resolveModel(
75
- modelName: string | undefined,
76
- parentModel: Model | undefined,
77
- ): ResolvedModel {
78
- const attempted: string[] = [];
79
- if (modelName) {
80
- // Try as provider/id first, then fall back to anthropic/id
81
- const parts = modelName.split("/");
82
- if (parts.length === 2) {
83
- attempted.push(modelName);
84
- const found = getModel(parts[0], parts[1]) ?? null;
85
- if (found) return { model: found, attempted };
86
- } else {
87
- // Assume Anthropic shorthand
88
- attempted.push(`anthropic/${modelName}`);
89
- const found = getModel("anthropic", modelName) ?? null;
90
- if (found) return { model: found, attempted };
91
- }
92
- } else if (parentModel) {
93
- attempted.push(`${parentModel.provider}/${parentModel.id}`);
94
- return { model: parentModel, attempted };
95
- }
96
- return { model: null, attempted };
97
- }
98
75
 
99
76
  // ---------------------------------------------------------------------------
100
77
  // Tool parameter schema
@@ -140,6 +117,7 @@ const SubagentParams = Type.Object({
140
117
  ),
141
118
  cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
142
119
  timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds for all sub-agents (overridden by per-task/step timeouts)" })),
120
+ instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
143
121
  abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
144
122
  });
145
123
 
@@ -159,9 +137,13 @@ interface SubagentDetails {
159
137
  // ---------------------------------------------------------------------------
160
138
 
161
139
  export default function (pi: ExtensionAPI) {
162
- // Invalidate agent cache on reload so edited agent files take effect
163
- pi.on("session_start", (event) => {
140
+ let currentCtx: ExtensionContext | undefined;
141
+
142
+ // Invalidate agent cache + clear thread store on session replacement.
143
+ pi.on("session_start", (event, ctx) => {
144
+ currentCtx = ctx;
164
145
  if (event.reason === "reload") invalidateAgentCache();
146
+ threadStore.clear();
165
147
  });
166
148
 
167
149
  // Proactively steer agents toward sub-agent delegation when users mention it
@@ -176,22 +158,43 @@ export default function (pi: ExtensionAPI) {
176
158
  }
177
159
  });
178
160
 
179
- // Inject available agent list into system prompt on every session
180
- pi.on("before_agent_start", async (event, ctx) => {
181
- const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
182
- if (discovery.agents.length > 0) {
183
- const names = discovery.agents.map(a => a.name).join(", ");
184
- return {
185
- systemPrompt:
186
- event.systemPrompt +
187
- `\n\nAvailable sub-agents: ${names}. Use /subagent for details.`,
188
- };
161
+ // Resolve bundled agents directory relative to this extension file
162
+ const bundledAgentsDir = path.resolve(__dirname, "../agents");
163
+
164
+ // Public one-request/one-response service used by pi-review.
165
+ pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
166
+ const request = raw as SubagentRunRequest;
167
+ const ctx = currentCtx;
168
+ if (!ctx || !request?.id || typeof request.respond !== "function") return;
169
+ if (request.accept && !request.accept()) return;
170
+ const agent = discoverAgents(ctx.cwd, "user", bundledAgentsDir).agents.find((item) => item.name === request.agent);
171
+ if (!agent) {
172
+ request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
173
+ return;
189
174
  }
175
+ const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single" });
176
+ void runNamedAgent({
177
+ agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
178
+ task: request.task,
179
+ cwd: request.cwd ?? ctx.cwd,
180
+ ctx,
181
+ timeout: request.timeout,
182
+ instructions: request.instructions,
183
+ signal: request.signal,
184
+ onMessage: (result) => threadStore.updateThread(thread.id, { result }),
185
+ }).then((result) => {
186
+ threadStore.updateThread(thread.id, {
187
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
188
+ result,
189
+ });
190
+ if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
191
+ else request.respond({ id: request.id, ok: true, result });
192
+ }, (error) => {
193
+ threadStore.updateThread(thread.id, { status: "failed" });
194
+ request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
195
+ });
190
196
  });
191
197
 
192
- // Resolve bundled agents directory relative to this extension file
193
- const bundledAgentsDir = path.resolve(__dirname, "agents");
194
-
195
198
  // /subagent command — list available agents
196
199
  pi.registerCommand("subagent", {
197
200
  description: "List available sub-agents, reload agent definitions, or show agent details",
@@ -242,6 +245,7 @@ export default function (pi: ExtensionAPI) {
242
245
  `Agent: ${agent.name} (${agent.source})`,
243
246
  `Description: ${agent.description}`,
244
247
  `Model: ${agent.model || "inherits from parent"}`,
248
+ `Thinking: ${agent.thinking || "off"}`,
245
249
  `Tools: ${agent.tools?.join(", ") || "all default"}`,
246
250
  `Source file: ${agent.filePath}`,
247
251
  "",
@@ -322,12 +326,8 @@ export default function (pi: ExtensionAPI) {
322
326
  };
323
327
  }
324
328
 
325
- // Confirm project-local agents
326
- if (
327
- (agentScope === "project" || agentScope === "both") &&
328
- confirmProjectAgents &&
329
- ctx.hasUI
330
- ) {
329
+ // Handle project-local agent confirmation
330
+ if (agentScope === "project" || agentScope === "both") {
331
331
  const requestedAgentNames = new Set<string>();
332
332
  if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
333
333
  if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
@@ -337,16 +337,30 @@ export default function (pi: ExtensionAPI) {
337
337
  .map((name) => agents.find((a) => a.name === name))
338
338
  .filter((a): a is AgentConfig => a?.source === "project");
339
339
 
340
- if (projectAgentsRequested.length > 0) {
341
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
342
- const dir = discovery.projectAgentsDir ?? "(unknown)";
343
- const ok = await ctx.ui.confirm(
344
- "Run project-local agents?",
345
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
346
- );
347
- if (!ok) {
340
+ if (projectAgentsRequested.length > 0 && confirmProjectAgents) {
341
+ if (ctx.hasUI) {
342
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
343
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
344
+ const ok = await ctx.ui.confirm(
345
+ "Run project-local agents?",
346
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
347
+ );
348
+ if (!ok) {
349
+ return {
350
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
351
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
352
+ };
353
+ }
354
+ } else {
355
+ // ponytail: fail closed in headless sessions — project agent
356
+ // prompts and tools run without user oversight.
348
357
  return {
349
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
358
+ content: [{
359
+ type: "text",
360
+ text: "Cannot run project-local agents without UI confirmation. "
361
+ + "Set confirmProjectAgents: false to allow in headless sessions, "
362
+ + "or use agentScope: 'user' to skip project agents.",
363
+ }],
350
364
  details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
351
365
  };
352
366
  }
@@ -354,14 +368,17 @@ export default function (pi: ExtensionAPI) {
354
368
  }
355
369
 
356
370
  // Shared auth/model setup for SDK sessions
357
- const authStorage = AuthStorage.create();
358
- const modelRegistry = ModelRegistry.create(authStorage);
371
+ // ponytail: reuse parent modelRegistry instead of a fresh copy — avoids
372
+ // internal API casts (storeModelHeaders) and preserves env/headers/OAuth.
373
+ const authStorage = AuthStorage.inMemory();
374
+ const modelRegistry = ctx.modelRegistry;
359
375
 
360
376
  // Helper: inject parent's API key into child auth storage
361
- async function injectApiKey(model: Model): Promise<void> {
377
+ async function injectApiKey(model: Model<any>): Promise<void> {
362
378
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
363
- if (auth.ok && auth.apiKey) {
364
- authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
379
+ if (auth.ok) {
380
+ if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
381
+ // ponytail: headers/env stay on the parent registry — no copy needed.
365
382
  }
366
383
  }
367
384
 
@@ -372,6 +389,7 @@ export default function (pi: ExtensionAPI) {
372
389
  cwd: string | undefined,
373
390
  parentSignal?: AbortSignal,
374
391
  timeoutMs?: number,
392
+ onProgress?: (partial: SubAgentResult) => void,
375
393
  ): Promise<SubAgentResult> {
376
394
  const agent = agents.find((a) => a.name === agentName);
377
395
 
@@ -388,7 +406,7 @@ export default function (pi: ExtensionAPI) {
388
406
  };
389
407
  }
390
408
 
391
- const resolved = resolveModel(agent.model, ctx.model);
409
+ const resolved = resolveModel(agent.model, ctx.model, ctx.modelRegistry);
392
410
  if (!resolved.model) {
393
411
  const tried = resolved.attempted.join(", ") || "none";
394
412
  const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
@@ -412,52 +430,40 @@ export default function (pi: ExtensionAPI) {
412
430
  let tools = agent.tools ?? defaultTools;
413
431
  tools = tools.filter((t) => t !== "subagent");
414
432
 
415
- // Build timeout + parent signal into a combined AbortSignal
416
- let combinedSignal = parentSignal;
417
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
418
- let timeoutController: AbortController | undefined;
419
- if (timeoutMs && timeoutMs > 0) {
420
- timeoutController = new AbortController();
421
- timeoutId = setTimeout(() => {
422
- timeoutController!.abort();
423
- }, timeoutMs);
424
- // Combine with parent signal if present (Node 20+ AbortSignal.any)
425
- if (parentSignal && typeof (AbortSignal as any).any === "function") {
426
- combinedSignal = (AbortSignal as any).any([parentSignal, timeoutController.signal]);
427
- } else if (parentSignal) {
428
- combinedSignal = timeoutController.signal;
429
- // Link parent to timeout: if parent aborts, also abort our timeout controller
430
- if (parentSignal.aborted) timeoutController.abort();
431
- else parentSignal.addEventListener("abort", () => timeoutController!.abort(), { once: true });
432
- } else {
433
- combinedSignal = timeoutController.signal;
433
+ const timeoutController = timeoutMs && timeoutMs > 0 ? new AbortController() : undefined;
434
+ const timeoutId = timeoutController ? setTimeout(() => timeoutController.abort(), timeoutMs) : undefined;
435
+ const signals = [parentSignal, timeoutController?.signal].filter((value): value is AbortSignal => Boolean(value));
436
+ const combinedSignal = signals.length > 1
437
+ ? typeof (AbortSignal as any).any === "function"
438
+ ? (AbortSignal as any).any(signals)
439
+ : signals[0]
440
+ : signals[0];
441
+
442
+ try {
443
+ const result = await runSubAgent({
444
+ cwd: cwd ?? ctx.cwd,
445
+ systemPrompt: params.instructions
446
+ ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, 16 * 1024)}`
447
+ : agent.systemPrompt,
448
+ task,
449
+ tools,
450
+ model: resolved.model,
451
+ authStorage,
452
+ modelRegistry,
453
+ signal: combinedSignal,
454
+ agentName,
455
+ thinkingLevel: agent.thinking,
456
+ onMessage: onProgress,
457
+ });
458
+ if (timeoutController?.signal.aborted && !parentSignal?.aborted) {
459
+ result.exitCode = 1;
460
+ result.stopReason = "timeout";
461
+ result.errorMessage ||= `Timeout after ${timeoutMs}ms`;
434
462
  }
463
+ return result;
464
+ } finally {
465
+ if (timeoutId) clearTimeout(timeoutId);
435
466
  }
436
-
437
- const result = await runSubAgent({
438
- cwd: cwd ?? ctx.cwd,
439
- systemPrompt: agent.systemPrompt,
440
- task,
441
- tools,
442
- model: resolved.model,
443
- authStorage,
444
- modelRegistry,
445
- signal: combinedSignal,
446
- agentName,
447
- });
448
-
449
- // Clean up timeout
450
- if (timeoutId) clearTimeout(timeoutId);
451
-
452
- // Detect timeout: our timeout controller fired, not the parent
453
- const timedOut = timeoutController?.signal.aborted && !parentSignal?.aborted;
454
- if (timedOut) {
455
- result.exitCode = 1;
456
- result.stopReason = "timeout";
457
- if (!result.errorMessage) result.errorMessage = `Timeout after ${timeoutMs}ms`;
458
- }
459
-
460
- return result;
461
467
  }
462
468
 
463
469
  // --- Chain mode ---
@@ -469,10 +475,21 @@ export default function (pi: ExtensionAPI) {
469
475
  const step = params.chain[i];
470
476
  const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
471
477
 
478
+ const thread = threadStore.createThread({
479
+ agentName: step.agent,
480
+ task: taskWithContext,
481
+ mode: "chain-step",
482
+ toolCallId: _toolCallId,
483
+ });
472
484
  const result = await runOne(
473
485
  step.agent, taskWithContext, step.cwd,
474
486
  signal, step.timeout ?? params.timeout,
487
+ (partial) => threadStore.updateThread(thread.id, { result: partial }),
475
488
  );
489
+ threadStore.updateThread(thread.id, {
490
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
491
+ result,
492
+ });
476
493
  results.push(result);
477
494
 
478
495
  const isError = isFailedResult(result);
@@ -539,13 +556,21 @@ export default function (pi: ExtensionAPI) {
539
556
 
540
557
  const abortOnFailure = params.abortOnFailure ?? false;
541
558
  const parallelController = new AbortController();
559
+ let abortCause: "parent" | "sibling" | undefined;
542
560
 
543
561
  // Combine parent signal with parallel abort controller
544
562
  let parallelSignal: AbortSignal = parallelController.signal;
545
563
  if (signal) {
546
564
  // Always link parent abort into parallelController so queued tasks see aborted state
547
- if (signal.aborted) parallelController.abort();
548
- else signal.addEventListener("abort", () => parallelController.abort(), { once: true });
565
+ if (signal.aborted) {
566
+ abortCause = "parent";
567
+ parallelController.abort();
568
+ } else {
569
+ signal.addEventListener("abort", () => {
570
+ if (!abortCause) abortCause = "parent";
571
+ parallelController.abort();
572
+ }, { once: true });
573
+ }
549
574
  if (typeof (AbortSignal as any).any === "function") {
550
575
  parallelSignal = (AbortSignal as any).any([signal, parallelController.signal]);
551
576
  } else {
@@ -553,6 +578,16 @@ export default function (pi: ExtensionAPI) {
553
578
  }
554
579
  }
555
580
 
581
+ // Pre-create threads for all parallel tasks
582
+ const parallelThreads = params.tasks.map((t) =>
583
+ threadStore.createThread({
584
+ agentName: t.agent,
585
+ task: t.task,
586
+ mode: "parallel-task",
587
+ toolCallId: _toolCallId,
588
+ }),
589
+ );
590
+
556
591
  const allResults: SubAgentResult[] = new Array(params.tasks.length);
557
592
  // Initialize placeholder results for streaming
558
593
  for (let i = 0; i < params.tasks.length; i++) {
@@ -596,21 +631,32 @@ export default function (pi: ExtensionAPI) {
596
631
  stderr: "",
597
632
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
598
633
  stopReason: "aborted",
599
- errorMessage: parallelController.signal.aborted
600
- ? "Cancelled: sibling task failed"
601
- : "Cancelled: parent operation aborted",
634
+ errorMessage:
635
+ abortCause === "sibling"
636
+ ? "Cancelled: sibling task failed"
637
+ : "Cancelled: parent operation aborted",
602
638
  };
603
639
  allResults[index] = skippedResult;
640
+ threadStore.updateThread(parallelThreads[index].id, {
641
+ status: "aborted",
642
+ result: skippedResult,
643
+ });
604
644
  emitParallelUpdate();
605
645
  return skippedResult;
606
646
  }
607
647
  const result = await runOne(
608
648
  t.agent, t.task, t.cwd,
609
649
  parallelSignal, t.timeout ?? params.timeout,
650
+ (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
610
651
  );
611
652
  allResults[index] = result;
653
+ threadStore.updateThread(parallelThreads[index].id, {
654
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
655
+ result,
656
+ });
612
657
  // Early-abort: if this task failed and abortOnFailure is set
613
658
  if (abortOnFailure && isFailedResult(result)) {
659
+ abortCause = "sibling";
614
660
  parallelController.abort();
615
661
  }
616
662
  emitParallelUpdate();
@@ -643,10 +689,21 @@ export default function (pi: ExtensionAPI) {
643
689
 
644
690
  // --- Single mode ---
645
691
  if (params.agent && params.task) {
692
+ const thread = threadStore.createThread({
693
+ agentName: params.agent,
694
+ task: params.task,
695
+ mode: "single",
696
+ toolCallId: _toolCallId,
697
+ });
646
698
  const result = await runOne(
647
699
  params.agent, params.task, params.cwd,
648
700
  signal, params.timeout,
701
+ (partial) => threadStore.updateThread(thread.id, { result: partial }),
649
702
  );
703
+ threadStore.updateThread(thread.id, {
704
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
705
+ result,
706
+ });
650
707
  const isError = isFailedResult(result);
651
708
 
652
709
  if (onUpdate) {
@@ -921,4 +978,241 @@ export default function (pi: ExtensionAPI) {
921
978
  return new Text(fallback?.type === "text" ? fallback.text : "(no output)", 0, 0);
922
979
  },
923
980
  });
924
- }
981
+ // /agent command — switch between subagent threads.
982
+ // When a thread is selected, the viewer replaces the main TUI (not overlay).
983
+ pi.registerCommand("agent", {
984
+ description: "Switch to a subagent thread to view its work in isolation",
985
+ handler: async (_args, ctx) => {
986
+ // Show picker overlay
987
+ const selectedId = await showAgentPicker(ctx, buildPickerItems(threadStore.getAllThreads()));
988
+ if (!selectedId) return; // Cancelled — stay in current view
989
+
990
+ // Main selected — close viewer if active, return to conversation
991
+ if (selectedId === "__main__") {
992
+ if (activeViewerDone) {
993
+ activeViewerDone();
994
+ activeViewerDone = null;
995
+ }
996
+ return;
997
+ }
998
+
999
+ // Close existing viewer (if any) before opening new one
1000
+ if (activeViewerDone) {
1001
+ activeViewerDone();
1002
+ activeViewerDone = null;
1003
+ }
1004
+
1005
+ // Show thread viewer (re-resolve against current store)
1006
+ const freshThreads = threadStore.getAllThreads();
1007
+ const idx = freshThreads.findIndex((t) => t.id === selectedId);
1008
+ if (idx === -1) {
1009
+ ctx.ui.notify("Selected subagent thread no longer exists.", "warning");
1010
+ return;
1011
+ }
1012
+
1013
+ await showThreadViewer(ctx, freshThreads, idx);
1014
+ },
1015
+ });
1016
+
1017
+ // ---------------------------------------------------------------------------
1018
+ // Module-level viewer state (so /agent can close an active viewer)
1019
+ // ---------------------------------------------------------------------------
1020
+ let activeViewerDone: (() => void) | null = null;
1021
+
1022
+ // ---------------------------------------------------------------------------
1023
+ // Picker helpers (shared between /agent handler and Ctrl+P in viewer)
1024
+ // ---------------------------------------------------------------------------
1025
+
1026
+ interface PickerItem { value: string; label: string; description: string }
1027
+
1028
+ function buildPickerItems(threads: SubagentThread[]): PickerItem[] {
1029
+ const items: PickerItem[] = [
1030
+ { value: "__main__", label: "Main [default]", description: "(current)" },
1031
+ ];
1032
+ for (const t of threads) {
1033
+ let statusIcon: string;
1034
+ switch (t.status) {
1035
+ case "running": statusIcon = "⏳"; break;
1036
+ case "completed": statusIcon = "✓"; break;
1037
+ case "failed": statusIcon = "✗"; break;
1038
+ case "aborted": statusIcon = "✗"; break;
1039
+ }
1040
+ let modeTag = "";
1041
+ if (t.mode === "parallel-task") modeTag = " [parallel]";
1042
+ else if (t.mode === "chain-step") modeTag = " [chain]";
1043
+ const label = `${statusIcon} ${t.agentName}${modeTag}`;
1044
+ const desc = t.task.length > 60 ? `${t.task.slice(0, 57)}...` : t.task;
1045
+ items.push({ value: t.id, label, description: desc });
1046
+ }
1047
+ return items;
1048
+ }
1049
+
1050
+ async function showAgentPicker(
1051
+ ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
1052
+ items: PickerItem[],
1053
+ ): Promise<string | null> {
1054
+ return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
1055
+ const container = new Container();
1056
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1057
+ container.addChild(new Text(theme.fg("accent", theme.bold("Subagents")), 1, 0));
1058
+ container.addChild(new Text(theme.fg("dim", "⌥ + ← previous, ⌥ + → next."), 1, 0));
1059
+
1060
+ const selectList = new SelectList(
1061
+ items.map((it) => ({ value: it.value, label: it.label, description: it.description })),
1062
+ Math.min(items.length + 2, 15),
1063
+ {
1064
+ selectedPrefix: (t: string) => theme.fg("accent", t),
1065
+ selectedText: (t: string) => theme.fg("accent", t),
1066
+ description: (t: string) => theme.fg("muted", t),
1067
+ scrollInfo: (t: string) => theme.fg("dim", t),
1068
+ noMatch: (t: string) => theme.fg("warning", t),
1069
+ },
1070
+ );
1071
+ selectList.onSelect = (item) => done(item.value);
1072
+ selectList.onCancel = () => done(null);
1073
+ container.addChild(selectList);
1074
+
1075
+ container.addChild(new Text(
1076
+ `${theme.fg("dim", "↑↓ navigate · enter select · esc back")}`,
1077
+ 1, 0,
1078
+ ));
1079
+
1080
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1081
+
1082
+ return {
1083
+ render: (w: number) => container.render(w),
1084
+ invalidate: () => container.invalidate(),
1085
+ handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); },
1086
+ };
1087
+ }, { overlay: true });
1088
+ }
1089
+
1090
+ // Helper: show thread viewer as overlay so editor remains visible.
1091
+ // Uses dynamic thread list + store subscriptions for live progress.
1092
+ // Ctrl+P opens picker overlay to jump to any thread.
1093
+ async function showThreadViewer(
1094
+ ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
1095
+ _threads: SubagentThread[],
1096
+ startIndex: number,
1097
+ ): Promise<void> {
1098
+ let currentIndex = startIndex;
1099
+
1100
+ // Resolve thread list dynamically
1101
+ const getThreads = () => threadStore.getAllThreads();
1102
+
1103
+ // Overlay mode: viewer appears above editor, Esc dismisses
1104
+ await ctx.ui.custom<void>((tui: any, theme: any, _kb: any, done: () => void) => {
1105
+ let unsubscribe: (() => void) | undefined;
1106
+ let closed = false;
1107
+
1108
+ const cleanup = () => {
1109
+ if (unsubscribe) {
1110
+ unsubscribe();
1111
+ unsubscribe = undefined;
1112
+ }
1113
+ };
1114
+
1115
+ const close = () => {
1116
+ if (closed) return;
1117
+ closed = true;
1118
+ cleanup();
1119
+ activeViewerDone = null;
1120
+ done();
1121
+ };
1122
+
1123
+ // Track this viewer so /agent can close it before opening a new one
1124
+ activeViewerDone = close;
1125
+
1126
+ function makeCallbacks(): ThreadViewerCallbacks {
1127
+ const list = getThreads();
1128
+ return {
1129
+ onClose: close,
1130
+ onPrev: () => {
1131
+ const current = getThreads();
1132
+ if (currentIndex > 0) {
1133
+ currentIndex--;
1134
+ viewer.setThread(current[currentIndex], makeCallbacks());
1135
+ tui.requestRender();
1136
+ }
1137
+ },
1138
+ onNext: () => {
1139
+ const current = getThreads();
1140
+ if (currentIndex < current.length - 1) {
1141
+ currentIndex++;
1142
+ viewer.setThread(current[currentIndex], makeCallbacks());
1143
+ tui.requestRender();
1144
+ }
1145
+ },
1146
+ hasPrev: currentIndex > 0,
1147
+ hasNext: currentIndex < list.length - 1,
1148
+ };
1149
+ }
1150
+
1151
+ const list = getThreads();
1152
+ if (list.length === 0 || currentIndex < 0 || currentIndex >= list.length) {
1153
+ close();
1154
+ return {
1155
+ render: (_w: number) => [],
1156
+ invalidate: () => {},
1157
+ handleInput: (_data: string) => {},
1158
+ dispose: () => {
1159
+ cleanup();
1160
+ if (activeViewerDone === close) activeViewerDone = null;
1161
+ closed = true;
1162
+ },
1163
+ };
1164
+ }
1165
+
1166
+ const viewer = new ThreadViewer(list[currentIndex], makeCallbacks(), theme);
1167
+ let pickerOpen = false;
1168
+
1169
+ // Subscribe to thread store for live updates (after viewer is created)
1170
+ unsubscribe = threadStore.subscribe(() => {
1171
+ const current = getThreads();
1172
+ if (current.length === 0) {
1173
+ close();
1174
+ return;
1175
+ }
1176
+ currentIndex = Math.min(currentIndex, current.length - 1);
1177
+ viewer.setThread(current[currentIndex], makeCallbacks());
1178
+ tui.requestRender();
1179
+ });
1180
+
1181
+ return {
1182
+ render: (w: number) => viewer.render(w),
1183
+ invalidate: () => viewer.invalidate(),
1184
+ handleInput: (data: string) => {
1185
+ // Ctrl+P opens the picker to jump between threads
1186
+ if (data === "\x10") {
1187
+ if (!pickerOpen) {
1188
+ pickerOpen = true;
1189
+ openThreadPicker().finally(() => { pickerOpen = false; });
1190
+ }
1191
+ return;
1192
+ }
1193
+ viewer.handleInput(data);
1194
+ tui.requestRender();
1195
+ },
1196
+ dispose: () => {
1197
+ cleanup();
1198
+ if (activeViewerDone === close) activeViewerDone = null;
1199
+ closed = true;
1200
+ },
1201
+ };
1202
+
1203
+ // Opens picker overlay on top of viewer to jump to any thread
1204
+ async function openThreadPicker() {
1205
+ const items = buildPickerItems(getThreads());
1206
+ const selectedId = await showAgentPicker(ctx, items);
1207
+ if (!selectedId) return;
1208
+ if (selectedId === "__main__") { close(); return; }
1209
+ const idx = getThreads().findIndex((t) => t.id === selectedId);
1210
+ if (idx >= 0) {
1211
+ currentIndex = idx;
1212
+ viewer.setThread(getThreads()[currentIndex], makeCallbacks());
1213
+ tui.requestRender();
1214
+ }
1215
+ }
1216
+ }, { overlay: true, overlayOptions: { maxHeight: "70%" } }); // Overlay: editor stays visible below
1217
+ }
1218
+ }