@bacnh85/pi-subagent 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agent
11
11
  - **Abort support**: Esc propagates to all sub-agents
12
12
  - **TUI rendering**: Collapsed/expanded views with tool-call formatting and usage stats
13
13
  - **Bundled agents**: scout, reviewer, worker — overridable with your own
14
+ - **Thread viewing**: `/agent` slash command to view subagent threads in isolation
14
15
 
15
16
  ## Install
16
17
 
@@ -29,6 +30,17 @@ pi -e ./extensions/pi-subagent
29
30
 
30
31
  ## Usage
31
32
 
33
+ ### Thread Viewing (`/agent`)
34
+
35
+ After running subagents, type `/agent` to view individual subagent threads:
36
+
37
+ 1. A picker shows `Main [default]` + all subagent threads with status icons
38
+ 2. Select a thread to view its full output (task, tool calls, final result, usage stats)
39
+ 3. Within the viewer: `Esc` to close, `Alt+←`/`Alt+→` to cycle between threads, `↑/↓` to scroll
40
+
41
+ This is useful when running many parallel subagents — instead of Ctrl+O to
42
+ see all output at once, you can focus on one thread at a time.
43
+
32
44
  ### Single agent
33
45
 
34
46
  ```
package/agents.ts CHANGED
@@ -32,6 +32,7 @@ interface AgentCache {
32
32
  userDir: string;
33
33
  projectDir: string | null;
34
34
  bundledDir: string;
35
+ scope: AgentScope;
35
36
  agents: AgentConfig[];
36
37
  projectAgentsDir: string | null;
37
38
  /** File-level signature per directory (name:mtime:size for each .md file) */
@@ -69,20 +70,22 @@ function loadAgentsFromDir(dir: string, source: "user" | "project" | "bundled"):
69
70
  continue;
70
71
  }
71
72
 
72
- const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
73
+ const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
73
74
 
74
- if (!frontmatter.name || !frontmatter.description) continue;
75
+ if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") continue;
75
76
 
76
- const tools = frontmatter.tools
77
- ?.split(",")
78
- .map((t: string) => t.trim())
79
- .filter(Boolean);
77
+ const tools =
78
+ typeof frontmatter.tools === "string"
79
+ ? frontmatter.tools.split(",").map((t) => t.trim()).filter(Boolean)
80
+ : Array.isArray(frontmatter.tools)
81
+ ? (frontmatter.tools as unknown[]).filter((t): t is string => typeof t === "string")
82
+ : undefined;
80
83
 
81
84
  agents.push({
82
85
  name: frontmatter.name,
83
86
  description: frontmatter.description,
84
87
  tools: tools && tools.length > 0 ? tools : undefined,
85
- model: frontmatter.model || undefined,
88
+ model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
86
89
  systemPrompt: body,
87
90
  source,
88
91
  filePath,
@@ -151,7 +154,8 @@ export function discoverAgents(
151
154
  _cache &&
152
155
  _cache.userDir === userDir &&
153
156
  _cache.projectDir === projectAgentsDir &&
154
- _cache.bundledDir === bundledAgentsDir
157
+ _cache.bundledDir === bundledAgentsDir &&
158
+ _cache.scope === scope
155
159
  ) {
156
160
  let stale = false;
157
161
  for (const [dir, cachedSig] of _cache.dirSignatures) {
@@ -195,6 +199,7 @@ export function discoverAgents(
195
199
  userDir,
196
200
  projectDir: projectAgentsDir,
197
201
  bundledDir: bundledAgentsDir,
202
+ scope,
198
203
  agents,
199
204
  projectAgentsDir,
200
205
  dirSignatures,
package/index.ts CHANGED
@@ -20,12 +20,13 @@ import { StringEnum } from "@earendil-works/pi-ai";
20
20
  import {
21
21
  AuthStorage,
22
22
  CONFIG_DIR_NAME,
23
+ DynamicBorder,
23
24
  type ExtensionAPI,
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,8 @@ import {
42
43
  formatUsageStats,
43
44
  renderSingleResult,
44
45
  } from "./render.ts";
46
+ import { type SubagentThread, threadStore } from "./threads.ts";
47
+ import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
45
48
 
46
49
  // ---------------------------------------------------------------------------
47
50
  // Constants
@@ -159,9 +162,10 @@ interface SubagentDetails {
159
162
  // ---------------------------------------------------------------------------
160
163
 
161
164
  export default function (pi: ExtensionAPI) {
162
- // Invalidate agent cache on reload so edited agent files take effect
165
+ // Invalidate agent cache + clear thread store on reload
163
166
  pi.on("session_start", (event) => {
164
167
  if (event.reason === "reload") invalidateAgentCache();
168
+ threadStore.clear();
165
169
  });
166
170
 
167
171
  // Proactively steer agents toward sub-agent delegation when users mention it
@@ -372,6 +376,7 @@ export default function (pi: ExtensionAPI) {
372
376
  cwd: string | undefined,
373
377
  parentSignal?: AbortSignal,
374
378
  timeoutMs?: number,
379
+ onProgress?: (partial: SubAgentResult) => void,
375
380
  ): Promise<SubAgentResult> {
376
381
  const agent = agents.find((a) => a.name === agentName);
377
382
 
@@ -444,6 +449,7 @@ export default function (pi: ExtensionAPI) {
444
449
  modelRegistry,
445
450
  signal: combinedSignal,
446
451
  agentName,
452
+ onMessage: onProgress,
447
453
  });
448
454
 
449
455
  // Clean up timeout
@@ -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, theme, _kb, done) => {
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, theme, _kb, done) => {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agents with isolated context using the pi SDK in-process.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,8 @@
30
30
  "agents.ts",
31
31
  "runner.ts",
32
32
  "render.ts",
33
+ "threads.ts",
34
+ "thread-viewer.ts",
33
35
  "agents/",
34
36
  "agent-format.md"
35
37
  ],
package/render.ts CHANGED
@@ -12,6 +12,24 @@ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
12
12
  import type { Message } from "@earendil-works/pi-ai";
13
13
  import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.ts";
14
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // Safe type guards
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function asString(value: unknown, fallback = "..."): string {
20
+ return typeof value === "string" ? value : fallback;
21
+ }
22
+
23
+ function asNumber(value: unknown): number | undefined {
24
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
25
+ }
26
+
27
+ function asRecord(value: unknown): Record<string, unknown> {
28
+ return value && typeof value === "object" && !Array.isArray(value)
29
+ ? (value as Record<string, unknown>)
30
+ : {};
31
+ }
32
+
15
33
  // ---------------------------------------------------------------------------
16
34
  // Display helpers
17
35
  // ---------------------------------------------------------------------------
@@ -53,15 +71,15 @@ function formatToolCall(
53
71
 
54
72
  switch (toolName) {
55
73
  case "bash": {
56
- const command = (args.command as string) || "...";
74
+ const command = asString(args.command);
57
75
  const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
58
76
  return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
59
77
  }
60
78
  case "read": {
61
- const rawPath = (args.file_path || args.path || "...") as string;
79
+ const rawPath = asString(args.file_path ?? args.path);
62
80
  const filePath = shortenPath(rawPath);
63
- const offset = args.offset as number | undefined;
64
- const limit = args.limit as number | undefined;
81
+ const offset = asNumber(args.offset);
82
+ const limit = asNumber(args.limit);
65
83
  let text = themeFg("accent", filePath);
66
84
  if (offset !== undefined || limit !== undefined) {
67
85
  const startLine = offset ?? 1;
@@ -71,24 +89,24 @@ function formatToolCall(
71
89
  return themeFg("muted", "read ") + text;
72
90
  }
73
91
  case "write": {
74
- const rawPath = (args.file_path || args.path || "...") as string;
75
- const content = (args.content || "") as string;
92
+ const rawPath = asString(args.file_path ?? args.path);
93
+ const content = asString(args.content, "");
76
94
  const lines = content.split("\n").length;
77
95
  let text = themeFg("muted", "write ") + themeFg("accent", shortenPath(rawPath));
78
96
  if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
79
97
  return text;
80
98
  }
81
99
  case "edit": {
82
- const rawPath = (args.file_path || args.path || "...") as string;
100
+ const rawPath = asString(args.file_path ?? args.path);
83
101
  return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
84
102
  }
85
103
  case "ls": {
86
- const rawPath = (args.path || ".") as string;
104
+ const rawPath = asString(args.path, ".");
87
105
  return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
88
106
  }
89
107
  case "find": {
90
- const pattern = (args.pattern || "*") as string;
91
- const rawPath = (args.path || ".") as string;
108
+ const pattern = asString(args.pattern, "*");
109
+ const rawPath = asString(args.path, ".");
92
110
  return (
93
111
  themeFg("muted", "find ") +
94
112
  themeFg("accent", pattern) +
@@ -96,8 +114,8 @@ function formatToolCall(
96
114
  );
97
115
  }
98
116
  case "grep": {
99
- const pattern = (args.pattern || "") as string;
100
- const rawPath = (args.path || ".") as string;
117
+ const pattern = asString(args.pattern);
118
+ const rawPath = asString(args.path, ".");
101
119
  return (
102
120
  themeFg("muted", "grep ") +
103
121
  themeFg("accent", `/${pattern}/`) +
@@ -127,7 +145,7 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
127
145
  items.push({
128
146
  type: "toolCall",
129
147
  name: part.name,
130
- args: part.arguments as Record<string, unknown>,
148
+ args: asRecord(part.arguments),
131
149
  });
132
150
  }
133
151
  }
package/runner.ts CHANGED
@@ -67,6 +67,7 @@ export async function runSubAgent(options: {
67
67
  signal?: AbortSignal;
68
68
  agentName?: string;
69
69
  onUpdate?: (text: string) => void;
70
+ onMessage?: (partialResult: SubAgentResult) => void;
70
71
  }): Promise<SubAgentResult> {
71
72
  const {
72
73
  cwd,
@@ -79,6 +80,7 @@ export async function runSubAgent(options: {
79
80
  signal,
80
81
  agentName = "subagent",
81
82
  onUpdate,
83
+ onMessage,
82
84
  } = options;
83
85
 
84
86
  const result: SubAgentResult = {
@@ -111,6 +113,13 @@ export async function runSubAgent(options: {
111
113
  });
112
114
 
113
115
  try {
116
+ if (signal?.aborted) {
117
+ result.exitCode = 1;
118
+ result.stopReason = "aborted";
119
+ result.errorMessage = "Sub-agent aborted before start";
120
+ return result;
121
+ }
122
+
114
123
  const { session } = await createAgentSession({
115
124
  cwd,
116
125
  model,
@@ -124,6 +133,7 @@ export async function runSubAgent(options: {
124
133
  });
125
134
 
126
135
  let cleanupAbort: (() => void) | undefined;
136
+ let cleanupEventAbort: (() => void) | undefined;
127
137
  try {
128
138
  // Wire abort signal
129
139
  if (signal) {
@@ -142,6 +152,13 @@ export async function runSubAgent(options: {
142
152
 
143
153
  // Collect all messages and usage stats from events
144
154
  const eventPromise = new Promise<void>((resolve, reject) => {
155
+ let settled = false;
156
+ const finish = (fn: () => void) => {
157
+ if (settled) return;
158
+ settled = true;
159
+ fn();
160
+ };
161
+
145
162
  const unsubscribe = session.subscribe((event) => {
146
163
  try {
147
164
  switch (event.type) {
@@ -165,6 +182,7 @@ export async function runSubAgent(options: {
165
182
  }
166
183
  // Collect all messages for extraction
167
184
  result.messages.push(msg as unknown as Message);
185
+ if (onMessage) onMessage({ ...result, messages: [...result.messages] });
168
186
  break;
169
187
  }
170
188
  case "agent_end": {
@@ -172,25 +190,47 @@ export async function runSubAgent(options: {
172
190
  if (result.messages.length === 0 && event.messages) {
173
191
  result.messages = event.messages as unknown as Message[];
174
192
  }
175
- unsubscribe();
176
- resolve();
193
+ finish(() => {
194
+ unsubscribe();
195
+ resolve();
196
+ });
177
197
  break;
178
198
  }
179
199
  }
180
200
  } catch (err) {
181
- unsubscribe();
182
- reject(err);
201
+ finish(() => {
202
+ unsubscribe();
203
+ reject(err);
204
+ });
183
205
  }
184
206
  });
207
+
208
+ // Resolve on abort so the eventPromise doesn't hang
209
+ if (signal) {
210
+ const onAbortResolve = () => {
211
+ finish(() => {
212
+ result.exitCode = 1;
213
+ result.stopReason = "aborted";
214
+ if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
215
+ unsubscribe();
216
+ resolve();
217
+ });
218
+ };
219
+ signal.addEventListener("abort", onAbortResolve, { once: true });
220
+ cleanupEventAbort = () => signal.removeEventListener("abort", onAbortResolve);
221
+ }
185
222
  });
186
223
 
187
224
  await session.prompt(task);
188
225
  await eventPromise;
189
226
 
190
- result.exitCode = 0;
227
+ if (result.stopReason !== "aborted") {
228
+ result.exitCode = 0;
229
+ }
191
230
  return result;
192
231
  } finally {
193
232
  cleanupAbort?.();
233
+ cleanupEventAbort?.();
194
234
  try {
195
235
  session.dispose();
196
236
  } catch {
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Thread Viewer — Overlay TUI component for pi-subagent.
3
+ *
4
+ * Displays a single subagent thread's full output in an overlay.
5
+ * Supports keyboard navigation between threads and scrolling.
6
+ */
7
+
8
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
9
+ import { matchesKey, Key, truncateToWidth, Markdown } from "@earendil-works/pi-tui";
10
+ import type { Message } from "@earendil-works/pi-ai";
11
+
12
+ import { type SubAgentResult, isFailedResult, getResultOutput, getFinalOutput } from "./runner.ts";
13
+ import { formatUsageStats } from "./render.ts";
14
+ import type { SubagentThread } from "./threads.ts";
15
+ import * as os from "node:os";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Safe type guards for tool-call arguments
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function asString(value: unknown, fallback = "..."): string {
22
+ return typeof value === "string" ? value : fallback;
23
+ }
24
+
25
+ function asNumber(value: unknown): number | undefined {
26
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
27
+ }
28
+
29
+ function asRecord(value: unknown): Record<string, unknown> {
30
+ return value && typeof value === "object" && !Array.isArray(value)
31
+ ? (value as Record<string, unknown>)
32
+ : {};
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Tool-call formatting (same as render.ts)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function formatToolCall(
40
+ toolName: string,
41
+ args: Record<string, unknown>,
42
+ themeFg: (color: string, text: string) => string,
43
+ ): string {
44
+ const shortenPath = (p: string) => {
45
+ const home = os.homedir();
46
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
47
+ };
48
+
49
+ switch (toolName) {
50
+ case "bash": {
51
+ const command = asString(args.command);
52
+ const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
53
+ return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
54
+ }
55
+ case "read": {
56
+ const rawPath = asString(args.file_path ?? args.path);
57
+ const filePath = shortenPath(rawPath);
58
+ const offset = asNumber(args.offset);
59
+ const limit = asNumber(args.limit);
60
+ let text = themeFg("accent", filePath);
61
+ if (offset !== undefined || limit !== undefined) {
62
+ const startLine = offset ?? 1;
63
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
64
+ text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
65
+ }
66
+ return themeFg("muted", "read ") + text;
67
+ }
68
+ case "write": {
69
+ const rawPath = asString(args.file_path ?? args.path);
70
+ const filePath = shortenPath(rawPath);
71
+ const content = asString(args.content, "");
72
+ const lines = content.split("\n").length;
73
+ let text = themeFg("muted", "write ") + themeFg("accent", filePath);
74
+ if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
75
+ return text;
76
+ }
77
+ case "edit": {
78
+ const rawPath = asString(args.file_path ?? args.path);
79
+ return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
80
+ }
81
+ case "ls": {
82
+ const rawPath = asString(args.path, ".");
83
+ return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
84
+ }
85
+ case "find": {
86
+ const pattern = asString(args.pattern, "*");
87
+ const rawPath = asString(args.path, ".");
88
+ return (
89
+ themeFg("muted", "find ") +
90
+ themeFg("accent", pattern) +
91
+ themeFg("dim", ` in ${shortenPath(rawPath)}`)
92
+ );
93
+ }
94
+ case "grep": {
95
+ const pattern = asString(args.pattern);
96
+ const rawPath = asString(args.path, ".");
97
+ return (
98
+ themeFg("muted", "grep ") +
99
+ themeFg("accent", `/${pattern}/`) +
100
+ themeFg("dim", ` in ${shortenPath(rawPath)}`)
101
+ );
102
+ }
103
+ default: {
104
+ const argsStr = JSON.stringify(args);
105
+ const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
106
+ return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
107
+ }
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Display helpers
113
+ // ---------------------------------------------------------------------------
114
+
115
+ type DisplayItem =
116
+ | { type: "text"; text: string }
117
+ | { type: "toolCall"; name: string; args: Record<string, unknown> };
118
+
119
+ function getDisplayItems(messages: Message[]): DisplayItem[] {
120
+ const items: DisplayItem[] = [];
121
+ for (const msg of messages) {
122
+ if (msg.role === "assistant") {
123
+ for (const part of msg.content) {
124
+ if (part.type === "text" && part.text.trim()) {
125
+ items.push({ type: "text", text: part.text });
126
+ } else if (part.type === "toolCall") {
127
+ items.push({
128
+ type: "toolCall",
129
+ name: part.name,
130
+ args: asRecord(part.arguments),
131
+ });
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return items;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Theme types
141
+ // ---------------------------------------------------------------------------
142
+
143
+ interface ViewerTheme {
144
+ fg: (color: string, text: string) => string;
145
+ bold: (text: string) => string;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Thread Viewer Component
150
+ // ---------------------------------------------------------------------------
151
+
152
+ export interface ThreadViewerCallbacks {
153
+ onClose: () => void;
154
+ onPrev: () => void;
155
+ onNext: () => void;
156
+ hasPrev: boolean;
157
+ hasNext: boolean;
158
+ }
159
+
160
+ /** Viewport height for the overlay (estimated lines). Must be > 3. */
161
+ const OVERLAY_HEIGHT = 24;
162
+
163
+ export class ThreadViewer {
164
+ private thread: SubagentThread;
165
+ private callbacks: ThreadViewerCallbacks;
166
+ private theme: ViewerTheme;
167
+ private scrollOffset = 0;
168
+ private cachedWidth?: number;
169
+ private cachedUpdatedAt?: number;
170
+ private cachedLines?: string[];
171
+
172
+ constructor(thread: SubagentThread, callbacks: ThreadViewerCallbacks, theme: ViewerTheme) {
173
+ this.thread = thread;
174
+ this.callbacks = callbacks;
175
+ this.theme = theme;
176
+ }
177
+
178
+ handleInput(data: string): void {
179
+ if (matchesKey(data, Key.escape)) {
180
+ this.callbacks.onClose();
181
+ return;
182
+ }
183
+ if (matchesKey(data, Key.alt("left"))) {
184
+ if (this.callbacks.hasPrev) {
185
+ this.scrollOffset = 0;
186
+ this.callbacks.onPrev();
187
+ }
188
+ return;
189
+ }
190
+ if (matchesKey(data, Key.alt("right"))) {
191
+ if (this.callbacks.hasNext) {
192
+ this.scrollOffset = 0;
193
+ this.callbacks.onNext();
194
+ }
195
+ return;
196
+ }
197
+ if (matchesKey(data, Key.up)) {
198
+ if (this.scrollOffset > 0) {
199
+ this.scrollOffset--;
200
+ this.invalidate();
201
+ }
202
+ return;
203
+ }
204
+ if (matchesKey(data, Key.down)) {
205
+ this.scrollOffset++;
206
+ this.invalidate();
207
+ return;
208
+ }
209
+ if (matchesKey(data, Key.pageUp)) {
210
+ this.scrollOffset = Math.max(0, this.scrollOffset - OVERLAY_HEIGHT);
211
+ this.invalidate();
212
+ return;
213
+ }
214
+ if (matchesKey(data, Key.pageDown)) {
215
+ this.scrollOffset += OVERLAY_HEIGHT;
216
+ this.invalidate();
217
+ return;
218
+ }
219
+ }
220
+
221
+ render(width: number): string[] {
222
+ // Use updatedAt in cache key so running→completed transitions bust the cache
223
+ if (
224
+ this.cachedLines &&
225
+ this.cachedWidth === width &&
226
+ this.cachedUpdatedAt === this.thread.updatedAt
227
+ ) {
228
+ return this.renderVisible(this.cachedLines, width);
229
+ }
230
+
231
+ const t = this.theme;
232
+ const lines: string[] = [];
233
+ const result = this.thread.result;
234
+ const isErr = result ? isFailedResult(result) : false;
235
+ const status = this.thread.status;
236
+
237
+ // Status icon
238
+ let icon: string;
239
+ if (status === "running") icon = t.fg("warning", "⏳");
240
+ else if (status === "aborted") icon = t.fg("error", "✗");
241
+ else if (isErr) icon = t.fg("error", "✗");
242
+ else icon = t.fg("success", "✓");
243
+
244
+ // Mode label
245
+ let modeLabel = "";
246
+ if (this.thread.mode === "parallel-task") modeLabel = t.fg("muted", " [parallel]");
247
+ else if (this.thread.mode === "chain-step") modeLabel = t.fg("muted", " [chain]");
248
+
249
+ // Header
250
+ let header = `${icon} ${t.fg("toolTitle", t.bold(this.thread.agentName))}${modeLabel}`;
251
+ if (status === "running") header += ` ${t.fg("warning", "(running...)")}`;
252
+ else if (status === "aborted") header += ` ${t.fg("error", "[aborted]")}`;
253
+ if (result && isErr && result.stopReason && result.stopReason !== "error" && result.stopReason !== "aborted") {
254
+ const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
255
+ header += ` ${t.fg(reasonColor, `[${result.stopReason}]`)}`;
256
+ }
257
+ lines.push(truncateToWidth(header, width));
258
+
259
+ // Error message
260
+ if (result && isErr && result.errorMessage) {
261
+ const msgColor = result.stopReason === "timeout" ? "warning" : "error";
262
+ lines.push(truncateToWidth(t.fg(msgColor, `Error: ${result.errorMessage}`), width));
263
+ }
264
+
265
+ lines.push("");
266
+
267
+ // Task
268
+ lines.push(truncateToWidth(t.fg("muted", "─── Task ───"), width));
269
+ lines.push(truncateToWidth(t.fg("dim", this.thread.task), width));
270
+ lines.push("");
271
+
272
+ if (status === "running" && (!result || result.messages.length === 0)) {
273
+ lines.push(truncateToWidth(t.fg("muted", "(waiting for first message...)"), width));
274
+ } else if (result) {
275
+ const displayItems = getDisplayItems(result.messages);
276
+ const finalOutput = getFinalOutput(result.messages);
277
+
278
+ lines.push(truncateToWidth(t.fg("muted", "─── Output ───"), width));
279
+
280
+ if (displayItems.length === 0 && !finalOutput) {
281
+ lines.push(truncateToWidth(t.fg("muted", "(no output)"), width));
282
+ } else {
283
+ const mdTheme = getMarkdownTheme();
284
+
285
+ // Show all display items: text + tool calls
286
+ for (const item of displayItems) {
287
+ if (item.type === "toolCall") {
288
+ lines.push(
289
+ truncateToWidth(
290
+ t.fg("muted", "→ ") + formatToolCall(item.name, item.args, t.fg.bind(t)),
291
+ width,
292
+ ),
293
+ );
294
+ } else {
295
+ // Assistant text — render as markdown
296
+ const contentWidth = Math.max(1, width - 2);
297
+ const md = new Markdown(item.text.trim(), 0, 0, mdTheme);
298
+ const mdLines = md.render(contentWidth);
299
+ for (const mdLine of mdLines) {
300
+ lines.push(` ${truncateToWidth(mdLine, contentWidth)}`);
301
+ }
302
+ }
303
+ }
304
+ // Check if final output not already shown
305
+ const finalAlreadyShown = finalOutput && displayItems.some(
306
+ (it) => it.type === "text" && it.text.includes(finalOutput.slice(0, 100)),
307
+ );
308
+ if (finalOutput && !finalAlreadyShown) {
309
+ const contentWidth = Math.max(1, width - 2);
310
+ const md = new Markdown(finalOutput.trim(), 0, 0, mdTheme);
311
+ for (const mdLine of md.render(contentWidth)) {
312
+ lines.push(` ${truncateToWidth(mdLine, contentWidth)}`);
313
+ }
314
+ }
315
+ }
316
+
317
+ // Usage stats
318
+ const usageStr = formatUsageStats(result.usage, result.model);
319
+ if (usageStr) {
320
+ lines.push("");
321
+ lines.push(truncateToWidth(t.fg("dim", usageStr), width));
322
+ }
323
+ }
324
+
325
+ lines.push("");
326
+
327
+ // Footer navigation hints
328
+ const navParts: string[] = [];
329
+ navParts.push("Esc close");
330
+ if (this.callbacks.hasPrev) navParts.push("alt+← prev");
331
+ if (this.callbacks.hasNext) navParts.push("alt+→ next");
332
+ navParts.push("↑↓ scroll");
333
+ lines.push(truncateToWidth(t.fg("dim", navParts.join(" · ")), width));
334
+
335
+ this.cachedLines = lines;
336
+ this.cachedWidth = width;
337
+ this.cachedUpdatedAt = this.thread.updatedAt;
338
+
339
+ return this.renderVisible(lines, width);
340
+ }
341
+
342
+ private renderVisible(allLines: string[], width: number): string[] {
343
+ const total = allLines.length;
344
+ const maxVisible = Math.max(3, OVERLAY_HEIGHT);
345
+
346
+ // Clamp scrollOffset so the last page shows a full viewport minus one indicator line
347
+ const maxOffset =
348
+ total > maxVisible
349
+ ? Math.max(0, total - (maxVisible - 1))
350
+ : 0;
351
+ const offset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
352
+
353
+ // Reserve space for scroll indicators
354
+ const aboveShown = offset > 0;
355
+ const belowShown = offset + maxVisible < total;
356
+ const indicatorLines = (aboveShown ? 1 : 0) + (belowShown ? 1 : 0);
357
+ const bodyHeight = Math.max(1, maxVisible - indicatorLines);
358
+
359
+ const visible = allLines.slice(offset, offset + bodyHeight);
360
+
361
+ // Scroll indicator at top
362
+ if (aboveShown) {
363
+ visible.unshift(truncateToWidth(
364
+ this.theme.fg("muted", `↑ ${offset} more lines above`),
365
+ width,
366
+ ));
367
+ }
368
+ // Scroll indicator at bottom
369
+ if (belowShown) {
370
+ const remaining = total - offset - bodyHeight;
371
+ visible.push(truncateToWidth(
372
+ this.theme.fg("muted", `↓ ${remaining} more lines below`),
373
+ width,
374
+ ));
375
+ }
376
+
377
+ return visible;
378
+ }
379
+
380
+ invalidate(): void {
381
+ this.cachedWidth = undefined;
382
+ this.cachedUpdatedAt = undefined;
383
+ this.cachedLines = undefined;
384
+ }
385
+
386
+ /** Update the thread being displayed (for prev/next navigation). */
387
+ setThread(thread: SubagentThread, callbacks: ThreadViewerCallbacks): void {
388
+ this.thread = thread;
389
+ this.callbacks = callbacks;
390
+ this.scrollOffset = 0;
391
+ this.invalidate();
392
+ }
393
+ }
package/threads.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Thread Store for pi-subagent.
3
+ *
4
+ * In-memory registry tracking all subagent invocations during a pi session.
5
+ * Enables the /agent command to list and switch between subagent threads.
6
+ * Supports subscriptions so UIs can react to thread status changes.
7
+ */
8
+
9
+ import type { SubAgentResult } from "./runner.ts";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export type ThreadMode = "single" | "parallel-task" | "chain-step";
16
+ export type ThreadStatus = "running" | "completed" | "failed" | "aborted";
17
+
18
+ export interface SubagentThread {
19
+ id: string;
20
+ agentName: string;
21
+ task: string;
22
+ mode: ThreadMode;
23
+ status: ThreadStatus;
24
+ result?: SubAgentResult;
25
+ toolCallId?: string;
26
+ createdAt: number;
27
+ updatedAt: number;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Thread Store
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export class ThreadStore {
35
+ private threads = new Map<string, SubagentThread>();
36
+ private order: string[] = [];
37
+ private listeners = new Set<() => void>();
38
+
39
+ /** Subscribe to thread store changes. Returns an unsubscribe function. */
40
+ subscribe(listener: () => void): () => void {
41
+ this.listeners.add(listener);
42
+ return () => { this.listeners.delete(listener); };
43
+ }
44
+
45
+ private notify(): void {
46
+ for (const listener of this.listeners) {
47
+ try { listener(); } catch { /* best effort */ }
48
+ }
49
+ }
50
+
51
+ createThread(params: {
52
+ agentName: string;
53
+ task: string;
54
+ mode: ThreadMode;
55
+ toolCallId?: string;
56
+ }): SubagentThread {
57
+ const id = cryptoGenId();
58
+ const now = Date.now();
59
+ const thread: SubagentThread = {
60
+ id,
61
+ agentName: params.agentName,
62
+ task: params.task,
63
+ mode: params.mode,
64
+ status: "running",
65
+ toolCallId: params.toolCallId,
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ };
69
+ this.threads.set(id, thread);
70
+ this.order.push(id);
71
+ this.notify();
72
+ return thread;
73
+ }
74
+
75
+ updateThread(id: string, updates: Partial<Pick<SubagentThread, "status" | "result">>): void {
76
+ const thread = this.threads.get(id);
77
+ if (!thread) return;
78
+ if (updates.status) thread.status = updates.status;
79
+ if (updates.result) thread.result = updates.result;
80
+ thread.updatedAt = Date.now();
81
+ this.notify();
82
+ }
83
+
84
+ getThread(id: string): SubagentThread | undefined {
85
+ return this.threads.get(id);
86
+ }
87
+
88
+ getAllThreads(): SubagentThread[] {
89
+ return this.order.map((id) => this.threads.get(id)!).filter(Boolean);
90
+ }
91
+
92
+ clear(): void {
93
+ this.threads.clear();
94
+ this.order = [];
95
+ this.notify();
96
+ }
97
+ }
98
+
99
+ /** Singleton instance. */
100
+ export const threadStore = new ThreadStore();
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // ID generation (UUID v4 style, good enough for in-memory use)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function cryptoGenId(): string {
107
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
108
+ return crypto.randomUUID();
109
+ }
110
+ // Fallback for environments without crypto (very unlikely in Node)
111
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
112
+ const r = (Math.random() * 16) | 0;
113
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
114
+ return v.toString(16);
115
+ });
116
+ }