@nmzpy/pi-ember-stack 0.1.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.
@@ -0,0 +1,1218 @@
1
+ /**
2
+ * pi-subagent — Minimal-overhead sub-agent extension for pi.
3
+ *
4
+ * Provides a `subagent` tool that delegates tasks to specialized agents
5
+ * running in isolated in-process SDK sessions. Supports three modes:
6
+ *
7
+ * - Single: { agent: "scout", task: "find auth code" }
8
+ * - Parallel: { tasks: [{ agent: "scout", task: "..." }, ...] }
9
+ * - Chain: { chain: [{ agent: "scout", task: "..." }, ...] }
10
+ *
11
+ * Compared to process-spawning, this saves ~4-11K tokens per sub-agent
12
+ * by using the pi SDK directly with a minimal system prompt, no AGENTS.md,
13
+ * no extensions, no skills, no thinking, and no compaction.
14
+ */
15
+
16
+ import * as path from "node:path";
17
+ import type { Model } from "@earendil-works/pi-ai";
18
+ import { StringEnum } from "@earendil-works/pi-ai";
19
+ import {
20
+ AuthStorage,
21
+ CONFIG_DIR_NAME,
22
+ DynamicBorder,
23
+ type ExtensionAPI,
24
+ type ExtensionContext,
25
+ getAgentDir,
26
+ getMarkdownTheme,
27
+ ModelRegistry,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
30
+ import { Type } from "typebox";
31
+
32
+ import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
33
+ import {
34
+ type SubAgentResult,
35
+ getFinalOutput,
36
+ getResultOutput,
37
+ isFailedResult,
38
+ mapWithConcurrencyLimit,
39
+ runSubAgent,
40
+ } from "./runner.ts";
41
+ import {
42
+ aggregateUsage,
43
+ formatUsageStats,
44
+ renderSingleResult,
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";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Constants
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const MAX_PARALLEL_TASKS = 8;
55
+ const MAX_CONCURRENCY = 4;
56
+ const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB per parallel task
57
+
58
+ import { resolveModel } from "./model.ts";
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Helpers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function truncateParallelOutput(output: string): string {
65
+ const byteLength = Buffer.byteLength(output, "utf8");
66
+ if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
67
+
68
+ let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
69
+ while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
70
+ truncated = truncated.slice(0, -1);
71
+ }
72
+ return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
73
+ }
74
+
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Tool parameter schema
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const TaskItem = Type.Object({
81
+ agent: Type.String({ description: "Name of the agent to invoke" }),
82
+ task: Type.String({ description: "Task to delegate to the agent" }),
83
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
84
+ timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this task" })),
85
+ });
86
+
87
+ const ChainItem = Type.Object({
88
+ agent: Type.String({ description: "Name of the agent to invoke" }),
89
+ task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
90
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
91
+ timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this step" })),
92
+ });
93
+
94
+ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
95
+ description:
96
+ 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
97
+ default: "user",
98
+ });
99
+
100
+ const SubagentParams = Type.Object({
101
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
102
+ task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
103
+ tasks: Type.Optional(
104
+ Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" }),
105
+ ),
106
+ chain: Type.Optional(
107
+ Type.Array(ChainItem, {
108
+ description: "Array of {agent, task} for sequential execution with {previous}",
109
+ }),
110
+ ),
111
+ agentScope: Type.Optional(AgentScopeSchema),
112
+ confirmProjectAgents: Type.Optional(
113
+ Type.Boolean({
114
+ description: "Prompt before running project-local agents. Default: true.",
115
+ default: true,
116
+ }),
117
+ ),
118
+ cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
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)" })),
121
+ abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Details type
126
+ // ---------------------------------------------------------------------------
127
+
128
+ interface SubagentDetails {
129
+ mode: "single" | "parallel" | "chain";
130
+ agentScope: AgentScope;
131
+ projectAgentsDir: string | null;
132
+ results: SubAgentResult[];
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Extension entry point
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export default function (pi: ExtensionAPI) {
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;
145
+ if (event.reason === "reload") invalidateAgentCache();
146
+ threadStore.clear();
147
+ });
148
+
149
+ // Proactively steer agents toward sub-agent delegation when users mention it
150
+ pi.on("before_agent_start", async (event) => {
151
+ const prompt = event.prompt.toLowerCase();
152
+ if (/\b(delegate to|use a subagent|run in parallel|spawn an agent|scout|review this|chain|worker agent)\b/.test(prompt)) {
153
+ return {
154
+ systemPrompt:
155
+ event.systemPrompt +
156
+ "\n\nThe subagent tool is available for delegating tasks to specialized agents with isolated context. Use /subagent to list available agents. Bundled: scout (fast recon), reviewer (code review), worker (implementation), general-purpose (fallback). Modes: single, parallel (max 8), chain.",
157
+ };
158
+ }
159
+ });
160
+
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;
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
+ });
196
+ });
197
+
198
+ // /subagent command — list available agents
199
+ pi.registerCommand("subagent", {
200
+ description: "List available sub-agents, reload agent definitions, or show agent details",
201
+ handler: async (args, ctx) => {
202
+ const cmd = args.trim().toLowerCase();
203
+ const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
204
+
205
+ if (cmd === "reload" || cmd === "refresh") {
206
+ invalidateAgentCache();
207
+ const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
208
+ const list = formatAgentList(fresh.agents, 20);
209
+ const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
210
+ const dirs = fresh.projectAgentsDir ? `project: ${fresh.projectAgentsDir}` : "no project agents dir";
211
+ pi.sendMessage({
212
+ customType: "pi-subagent",
213
+ content: `Agent definitions reloaded.\n\nAvailable agents (${fresh.agents.length}):\n ${list.text}${extra}\n\nDirectories searched:\n user: ${path.join(getAgentDir(), "agents")}\n ${dirs}\n bundled: ${bundledAgentsDir}`,
214
+ display: true,
215
+ });
216
+ ctx.ui.notify("Agent definitions reloaded", "info");
217
+ return;
218
+ }
219
+
220
+ // Handle listing keywords before agent lookup
221
+ if (cmd === "all" || cmd === "list" || cmd === "agents") {
222
+ const list = formatAgentList(discovery.agents, 20);
223
+ const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
224
+ const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
225
+ pi.sendMessage({
226
+ customType: "pi-subagent",
227
+ content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
228
+ display: true,
229
+ });
230
+ return;
231
+ }
232
+
233
+ if (cmd) {
234
+ // Show details for a specific agent
235
+ const agent = discovery.agents.find(
236
+ (a) => a.name.toLowerCase() === cmd,
237
+ );
238
+ if (!agent) {
239
+ ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent to list all.`, "error");
240
+ return;
241
+ }
242
+ pi.sendMessage({
243
+ customType: "pi-subagent",
244
+ content: [
245
+ `Agent: ${agent.name} (${agent.source})`,
246
+ `Description: ${agent.description}`,
247
+ `Model: ${agent.model || "inherits from parent"}`,
248
+ `Thinking: ${agent.thinking || "off"}`,
249
+ `Tools: ${agent.tools?.join(", ") || "all default"}`,
250
+ `Source file: ${agent.filePath}`,
251
+ "",
252
+ "--- System Prompt ---",
253
+ agent.systemPrompt,
254
+ ].join("\n"),
255
+ display: true,
256
+ });
257
+ return;
258
+ }
259
+
260
+ // List all agents
261
+ const list = formatAgentList(discovery.agents, 20);
262
+ const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
263
+ const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
264
+ pi.sendMessage({
265
+ customType: "pi-subagent",
266
+ content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
267
+ display: true,
268
+ });
269
+ },
270
+ });
271
+
272
+ pi.registerTool({
273
+ name: "subagent",
274
+ label: "Subagent",
275
+ description: [
276
+ "Delegate tasks to specialized subagents with isolated context (SDK-based, minimal overhead).",
277
+ "Modes: single (agent + task), parallel (tasks array, max 8, 4 concurrent), chain (sequential with {previous}).",
278
+ `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
279
+ `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
280
+ ].join(" "),
281
+ parameters: SubagentParams,
282
+ promptSnippet: "Delegate tasks to specialized sub-agents (scout, reviewer, worker, general-purpose)",
283
+ promptGuidelines: [
284
+ "Use subagent to delegate work that would flood the main context with search results or file contents.",
285
+ "Modes: single {agent, task}, parallel {tasks: [...]} (max 8, 4 concurrent), chain {chain: [...]} (sequential with {previous}).",
286
+ "Bundled agents: scout (fast recon), reviewer (code review), worker (implementation), general-purpose (fallback).",
287
+ "Use /subagent to list all available agents or /subagent <name> for agent details.",
288
+ ],
289
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
290
+ const agentScope: AgentScope = params.agentScope ?? "user";
291
+ const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
292
+ const agents = discovery.agents;
293
+ const confirmProjectAgents = params.confirmProjectAgents ?? true;
294
+
295
+ const hasChain = (params.chain?.length ?? 0) > 0;
296
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
297
+ const hasSingle = Boolean(params.agent && params.task);
298
+ const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
299
+
300
+ const makeDetails =
301
+ (mode: "single" | "parallel" | "chain") =>
302
+ (results: SubAgentResult[]): SubagentDetails => ({
303
+ mode,
304
+ agentScope,
305
+ projectAgentsDir: discovery.projectAgentsDir,
306
+ results,
307
+ });
308
+
309
+ // Validate: exactly one mode
310
+ if (modeCount !== 1) {
311
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
312
+ return {
313
+ content: [
314
+ {
315
+ type: "text",
316
+ text: [
317
+ "Invalid parameters. Provide exactly one mode:",
318
+ " single: { agent, task }",
319
+ " parallel: { tasks: [...] }",
320
+ " chain: { chain: [...] }",
321
+ `Available agents: ${available}`,
322
+ ].join("\n"),
323
+ },
324
+ ],
325
+ details: makeDetails("single")([]),
326
+ };
327
+ }
328
+
329
+ // Handle project-local agent confirmation
330
+ if (agentScope === "project" || agentScope === "both") {
331
+ const requestedAgentNames = new Set<string>();
332
+ if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
333
+ if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
334
+ if (params.agent) requestedAgentNames.add(params.agent);
335
+
336
+ const projectAgentsRequested = Array.from(requestedAgentNames)
337
+ .map((name) => agents.find((a) => a.name === name))
338
+ .filter((a): a is AgentConfig => a?.source === "project");
339
+
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.
357
+ return {
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
+ }],
364
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
365
+ };
366
+ }
367
+ }
368
+ }
369
+
370
+ // Shared auth/model setup for SDK sessions
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;
375
+
376
+ // Helper: inject parent's API key into child auth storage
377
+ async function injectApiKey(model: Model<any>): Promise<void> {
378
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
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.
382
+ }
383
+ }
384
+
385
+ // Helper: run a single agent via SDK
386
+ async function runOne(
387
+ agentName: string,
388
+ task: string,
389
+ cwd: string | undefined,
390
+ parentSignal?: AbortSignal,
391
+ timeoutMs?: number,
392
+ onProgress?: (partial: SubAgentResult) => void,
393
+ ): Promise<SubAgentResult> {
394
+ const agent = agents.find((a) => a.name === agentName);
395
+
396
+ if (!agent) {
397
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
398
+ return {
399
+ agent: agentName,
400
+ task,
401
+ exitCode: 1,
402
+ messages: [],
403
+ stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
404
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
405
+ errorMessage: `Unknown agent: "${agentName}"`,
406
+ };
407
+ }
408
+
409
+ const resolved = resolveModel(agent.model, ctx.model, ctx.modelRegistry);
410
+ if (!resolved.model) {
411
+ const tried = resolved.attempted.join(", ") || "none";
412
+ const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
413
+ return {
414
+ agent: agentName,
415
+ task,
416
+ exitCode: 1,
417
+ messages: [],
418
+ stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
419
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
420
+ errorMessage: `No model resolved (tried: ${tried})`,
421
+ };
422
+ }
423
+
424
+ // Inject parent's API key so --api-key and other runtime overrides work
425
+ await injectApiKey(resolved.model);
426
+
427
+ // Resolve tools; strip "subagent" to prevent accidental recursion.
428
+ // Sub-agents cannot spawn further sub-agents (one level of delegation only).
429
+ const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
430
+ let tools = agent.tools ?? defaultTools;
431
+ tools = tools.filter((t) => t !== "subagent");
432
+
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`;
462
+ }
463
+ return result;
464
+ } finally {
465
+ if (timeoutId) clearTimeout(timeoutId);
466
+ }
467
+ }
468
+
469
+ // --- Chain mode ---
470
+ if (params.chain && params.chain.length > 0) {
471
+ const results: SubAgentResult[] = [];
472
+ let previousOutput = "";
473
+
474
+ for (let i = 0; i < params.chain.length; i++) {
475
+ const step = params.chain[i];
476
+ const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
477
+
478
+ const thread = threadStore.createThread({
479
+ agentName: step.agent,
480
+ task: taskWithContext,
481
+ mode: "chain-step",
482
+ toolCallId: _toolCallId,
483
+ });
484
+ const result = await runOne(
485
+ step.agent, taskWithContext, step.cwd,
486
+ signal, step.timeout ?? params.timeout,
487
+ (partial) => threadStore.updateThread(thread.id, { result: partial }),
488
+ );
489
+ threadStore.updateThread(thread.id, {
490
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
491
+ result,
492
+ });
493
+ results.push(result);
494
+
495
+ const isError = isFailedResult(result);
496
+ if (isError) {
497
+ const errorMsg = getResultOutput(result);
498
+ if (onUpdate) {
499
+ onUpdate({
500
+ content: [{ type: "text", text: errorMsg }],
501
+ details: makeDetails("chain")(results),
502
+ });
503
+ }
504
+ // Include successful previous step outputs in the error content
505
+ const prevCount = i;
506
+ let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
507
+ if (prevCount > 0) {
508
+ const prevSummaries = results
509
+ .slice(0, prevCount)
510
+ .map((r, j) => {
511
+ const out = getResultOutput(r).slice(0, 500);
512
+ return `Step ${j + 1} (${r.agent}): ${out}`;
513
+ })
514
+ .join("\n");
515
+ contentText = `Chain stopped at step ${i + 1}/${params.chain.length}. ${prevCount} previous step(s) succeeded:\n\n${prevSummaries}\n\nError at step ${i + 1} (${step.agent}): ${errorMsg}`;
516
+ }
517
+ return {
518
+ content: [{ type: "text", text: contentText }],
519
+ details: makeDetails("chain")(results),
520
+ isError: true,
521
+ };
522
+ }
523
+
524
+ previousOutput = getFinalOutput(result.messages);
525
+
526
+ if (onUpdate) {
527
+ onUpdate({
528
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
529
+ details: makeDetails("chain")(results),
530
+ });
531
+ }
532
+ }
533
+
534
+ const last = results[results.length - 1];
535
+ return {
536
+ content: [
537
+ { type: "text", text: getFinalOutput(last.messages) || "(no output)" },
538
+ ],
539
+ details: makeDetails("chain")(results),
540
+ };
541
+ }
542
+
543
+ // --- Parallel mode ---
544
+ if (params.tasks && params.tasks.length > 0) {
545
+ if (params.tasks.length > MAX_PARALLEL_TASKS) {
546
+ return {
547
+ content: [
548
+ {
549
+ type: "text",
550
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
551
+ },
552
+ ],
553
+ details: makeDetails("parallel")([]),
554
+ };
555
+ }
556
+
557
+ const abortOnFailure = params.abortOnFailure ?? false;
558
+ const parallelController = new AbortController();
559
+ let abortCause: "parent" | "sibling" | undefined;
560
+
561
+ // Combine parent signal with parallel abort controller
562
+ let parallelSignal: AbortSignal = parallelController.signal;
563
+ if (signal) {
564
+ // Always link parent abort into parallelController so queued tasks see aborted state
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
+ }
574
+ if (typeof (AbortSignal as any).any === "function") {
575
+ parallelSignal = (AbortSignal as any).any([signal, parallelController.signal]);
576
+ } else {
577
+ parallelSignal = parallelController.signal;
578
+ }
579
+ }
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
+
591
+ const allResults: SubAgentResult[] = new Array(params.tasks.length);
592
+ // Initialize placeholder results for streaming
593
+ for (let i = 0; i < params.tasks.length; i++) {
594
+ allResults[i] = {
595
+ agent: params.tasks[i].agent,
596
+ task: params.tasks[i].task,
597
+ exitCode: -1,
598
+ messages: [],
599
+ stderr: "",
600
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
601
+ };
602
+ }
603
+
604
+ const emitParallelUpdate = () => {
605
+ if (onUpdate) {
606
+ const running = allResults.filter((r) => r.exitCode === -1).length;
607
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
608
+ onUpdate({
609
+ content: [
610
+ {
611
+ type: "text",
612
+ text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
613
+ },
614
+ ],
615
+ details: makeDetails("parallel")([...allResults]),
616
+ });
617
+ }
618
+ };
619
+
620
+ const results = await mapWithConcurrencyLimit(
621
+ params.tasks,
622
+ MAX_CONCURRENCY,
623
+ async (t, index) => {
624
+ // Skip if already aborted by sibling failure or parent abort
625
+ if (parallelSignal.aborted || parallelController.signal.aborted) {
626
+ const skippedResult: SubAgentResult = {
627
+ agent: t.agent,
628
+ task: t.task,
629
+ exitCode: 1,
630
+ messages: [],
631
+ stderr: "",
632
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
633
+ stopReason: "aborted",
634
+ errorMessage:
635
+ abortCause === "sibling"
636
+ ? "Cancelled: sibling task failed"
637
+ : "Cancelled: parent operation aborted",
638
+ };
639
+ allResults[index] = skippedResult;
640
+ threadStore.updateThread(parallelThreads[index].id, {
641
+ status: "aborted",
642
+ result: skippedResult,
643
+ });
644
+ emitParallelUpdate();
645
+ return skippedResult;
646
+ }
647
+ const result = await runOne(
648
+ t.agent, t.task, t.cwd,
649
+ parallelSignal, t.timeout ?? params.timeout,
650
+ (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
651
+ );
652
+ allResults[index] = result;
653
+ threadStore.updateThread(parallelThreads[index].id, {
654
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
655
+ result,
656
+ });
657
+ // Early-abort: if this task failed and abortOnFailure is set
658
+ if (abortOnFailure && isFailedResult(result)) {
659
+ abortCause = "sibling";
660
+ parallelController.abort();
661
+ }
662
+ emitParallelUpdate();
663
+ return result;
664
+ },
665
+ );
666
+
667
+ const successCount = results.filter((r) => !isFailedResult(r)).length;
668
+ const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
669
+ const summaries = results.map((r) => {
670
+ const output = truncateParallelOutput(getResultOutput(r));
671
+ const status = isFailedResult(r)
672
+ ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
673
+ : "completed";
674
+ return `### [${r.agent}] ${status}\n\n${output}`;
675
+ });
676
+
677
+ let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
678
+ if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
679
+ return {
680
+ content: [
681
+ {
682
+ type: "text",
683
+ text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
684
+ },
685
+ ],
686
+ details: makeDetails("parallel")(results),
687
+ };
688
+ }
689
+
690
+ // --- Single mode ---
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
+ });
698
+ const result = await runOne(
699
+ params.agent, params.task, params.cwd,
700
+ signal, params.timeout,
701
+ (partial) => threadStore.updateThread(thread.id, { result: partial }),
702
+ );
703
+ threadStore.updateThread(thread.id, {
704
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
705
+ result,
706
+ });
707
+ const isError = isFailedResult(result);
708
+
709
+ if (onUpdate) {
710
+ onUpdate({
711
+ content: [
712
+ { type: "text", text: getFinalOutput(result.messages) || "(running...)" },
713
+ ],
714
+ details: makeDetails("single")([result]),
715
+ });
716
+ }
717
+
718
+ if (isError) {
719
+ const errorMsg = getResultOutput(result);
720
+ return {
721
+ content: [
722
+ {
723
+ type: "text",
724
+ text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
725
+ },
726
+ ],
727
+ details: makeDetails("single")([result]),
728
+ isError: true,
729
+ };
730
+ }
731
+
732
+ return {
733
+ content: [
734
+ { type: "text", text: getFinalOutput(result.messages) || "(no output)" },
735
+ ],
736
+ details: makeDetails("single")([result]),
737
+ };
738
+ }
739
+
740
+ // Should not reach here due to validation above
741
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
742
+ return {
743
+ content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
744
+ details: makeDetails("single")([]),
745
+ };
746
+ },
747
+
748
+ // ------------------------------------------------------------------
749
+ // TUI rendering
750
+ // ------------------------------------------------------------------
751
+
752
+ renderCall(args, theme, _context) {
753
+ const scope: AgentScope = args.agentScope ?? "user";
754
+ const fg = theme.fg.bind(theme);
755
+
756
+ // Chain
757
+ if (args.chain && args.chain.length > 0) {
758
+ let text =
759
+ fg("toolTitle", theme.bold("subagent ")) +
760
+ fg("accent", `chain (${args.chain.length} steps)`) +
761
+ fg("muted", ` [${scope}]`);
762
+ for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
763
+ const step = args.chain[i];
764
+ const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
765
+ const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
766
+ text +=
767
+ "\n " +
768
+ fg("muted", `${i + 1}.`) +
769
+ " " +
770
+ fg("accent", step.agent) +
771
+ fg("dim", ` ${preview}`);
772
+ }
773
+ if (args.chain.length > 3)
774
+ text += `\n ${fg("muted", `... +${args.chain.length - 3} more`)}`;
775
+ return new Text(text, 0, 0);
776
+ }
777
+
778
+ // Parallel
779
+ if (args.tasks && args.tasks.length > 0) {
780
+ let text =
781
+ fg("toolTitle", theme.bold("subagent ")) +
782
+ fg("accent", `parallel (${args.tasks.length} tasks)`) +
783
+ fg("muted", ` [${scope}]`);
784
+ for (const t of args.tasks.slice(0, 3)) {
785
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
786
+ text += `\n ${fg("accent", t.agent)}${fg("dim", ` ${preview}`)}`;
787
+ }
788
+ if (args.tasks.length > 3)
789
+ text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
790
+ return new Text(text, 0, 0);
791
+ }
792
+
793
+ // Single
794
+ const agentName = args.agent || "...";
795
+ const preview = args.task
796
+ ? args.task.length > 60
797
+ ? `${args.task.slice(0, 60)}...`
798
+ : args.task
799
+ : "...";
800
+ let text =
801
+ fg("toolTitle", theme.bold("subagent ")) +
802
+ fg("accent", agentName) +
803
+ fg("muted", ` [${scope}]`);
804
+ text += `\n ${fg("dim", preview)}`;
805
+ return new Text(text, 0, 0);
806
+ },
807
+
808
+ renderResult(result, { expanded }, theme, _context) {
809
+ const details = result.details as SubagentDetails | undefined;
810
+ if (!details || details.results.length === 0) {
811
+ const text = result.content[0];
812
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
813
+ }
814
+
815
+ const fg = theme.fg.bind(theme);
816
+ const mdTheme = getMarkdownTheme();
817
+
818
+ // --- Single ---
819
+ if (details.mode === "single" && details.results.length === 1) {
820
+ return renderSingleResult(details.results[0], expanded, theme);
821
+ }
822
+
823
+ // --- Chain ---
824
+ if (details.mode === "chain") {
825
+ const successCount = details.results.filter((r) => !isFailedResult(r)).length;
826
+ const icon =
827
+ successCount === details.results.length
828
+ ? fg("success", "✓")
829
+ : fg("error", "✗");
830
+
831
+ if (expanded) {
832
+ const container = new Container();
833
+ container.addChild(
834
+ new Text(
835
+ icon +
836
+ " " +
837
+ fg("toolTitle", theme.bold("chain ")) +
838
+ fg("accent", `${successCount}/${details.results.length} steps`),
839
+ 0,
840
+ 0,
841
+ ),
842
+ );
843
+ for (const r of details.results) {
844
+ container.addChild(new Spacer(1));
845
+ const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
846
+ container.addChild(
847
+ new Text(
848
+ fg("muted", `─── Step ${r.exitCode !== -1 ? "" : "?"}: `) +
849
+ fg("accent", r.agent) +
850
+ ` ${stepIcon}`,
851
+ 0,
852
+ 0,
853
+ ),
854
+ );
855
+ if (r.errorMessage) {
856
+ container.addChild(
857
+ new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
858
+ );
859
+ }
860
+ const finalOutput = getResultOutput(r);
861
+ if (finalOutput) {
862
+ container.addChild(new Spacer(1));
863
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
864
+ }
865
+ const usageStr = formatUsageStats(r.usage, r.model);
866
+ if (usageStr)
867
+ container.addChild(new Text(fg("dim", usageStr), 0, 0));
868
+ }
869
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
870
+ if (totalUsage) {
871
+ container.addChild(new Spacer(1));
872
+ container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
873
+ }
874
+ return container;
875
+ }
876
+
877
+ let text =
878
+ icon +
879
+ " " +
880
+ fg("toolTitle", theme.bold("chain ")) +
881
+ fg("accent", `${successCount}/${details.results.length} steps`);
882
+ for (const r of details.results) {
883
+ const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
884
+ text += `\n ${stepIcon} ${fg("accent", r.agent)}`;
885
+ }
886
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
887
+ if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
888
+ text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
889
+ return new Text(text, 0, 0);
890
+ }
891
+
892
+ // --- Parallel ---
893
+ if (details.mode === "parallel") {
894
+ const running = details.results.filter((r) => r.exitCode === -1).length;
895
+ const successCount = details.results.filter(
896
+ (r) => r.exitCode !== -1 && !isFailedResult(r),
897
+ ).length;
898
+ const failCount = details.results.filter(
899
+ (r) => r.exitCode !== -1 && isFailedResult(r),
900
+ ).length;
901
+ const isRunning = running > 0;
902
+ const icon = isRunning
903
+ ? fg("warning", "⏳")
904
+ : failCount > 0
905
+ ? fg("warning", "◐")
906
+ : fg("success", "✓");
907
+ const status = isRunning
908
+ ? `${successCount + failCount}/${details.results.length} done, ${running} running`
909
+ : `${successCount}/${details.results.length} tasks`;
910
+
911
+ if (expanded && !isRunning) {
912
+ const container = new Container();
913
+ container.addChild(
914
+ new Text(
915
+ `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`,
916
+ 0,
917
+ 0,
918
+ ),
919
+ );
920
+ for (const r of details.results) {
921
+ container.addChild(new Spacer(1));
922
+ const taskIcon = isFailedResult(r)
923
+ ? fg("error", "✗")
924
+ : fg("success", "✓");
925
+ container.addChild(
926
+ new Text(
927
+ fg("muted", "─── ") + fg("accent", r.agent) + ` ${taskIcon}`,
928
+ 0,
929
+ 0,
930
+ ),
931
+ );
932
+ container.addChild(
933
+ new Text(fg("muted", "Task: ") + fg("dim", r.task), 0, 0),
934
+ );
935
+ if (r.errorMessage) {
936
+ container.addChild(
937
+ new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
938
+ );
939
+ }
940
+ const finalOutput = getResultOutput(r);
941
+ if (finalOutput) {
942
+ container.addChild(new Spacer(1));
943
+ container.addChild(
944
+ new Markdown(finalOutput.trim(), 0, 0, mdTheme),
945
+ );
946
+ }
947
+ const taskUsage = formatUsageStats(r.usage, r.model);
948
+ if (taskUsage)
949
+ container.addChild(new Text(fg("dim", taskUsage), 0, 0));
950
+ }
951
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
952
+ if (totalUsage) {
953
+ container.addChild(new Spacer(1));
954
+ container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
955
+ }
956
+ return container;
957
+ }
958
+
959
+ let text = `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`;
960
+ for (const r of details.results) {
961
+ const taskIcon =
962
+ r.exitCode === -1
963
+ ? fg("warning", "⏳")
964
+ : isFailedResult(r)
965
+ ? fg("error", "✗")
966
+ : fg("success", "✓");
967
+ text += `\n ${taskIcon} ${fg("accent", r.agent)}`;
968
+ }
969
+ if (!isRunning) {
970
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
971
+ if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
972
+ }
973
+ if (!expanded) text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
974
+ return new Text(text, 0, 0);
975
+ }
976
+
977
+ const fallback = result.content[0];
978
+ return new Text(fallback?.type === "text" ? fallback.text : "(no output)", 0, 0);
979
+ },
980
+ });
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
+ }