@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 +12 -0
- package/agents.ts +13 -8
- package/index.ts +302 -8
- package/package.json +3 -1
- package/render.ts +31 -13
- package/runner.ts +45 -5
- package/thread-viewer.ts +393 -0
- package/threads.ts +116 -0
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,
|
|
73
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
|
|
73
74
|
|
|
74
|
-
if (
|
|
75
|
+
if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") continue;
|
|
75
76
|
|
|
76
|
-
const tools =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
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)
|
|
548
|
-
|
|
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:
|
|
600
|
-
|
|
601
|
-
|
|
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
|
+
"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
|
|
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
|
|
79
|
+
const rawPath = asString(args.file_path ?? args.path);
|
|
62
80
|
const filePath = shortenPath(rawPath);
|
|
63
|
-
const offset = args.offset
|
|
64
|
-
const limit = args.limit
|
|
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
|
|
75
|
-
const content = (args.content
|
|
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
|
|
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
|
|
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
|
|
91
|
-
const rawPath = (args.path
|
|
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
|
|
100
|
-
const rawPath = (args.path
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
193
|
+
finish(() => {
|
|
194
|
+
unsubscribe();
|
|
195
|
+
resolve();
|
|
196
|
+
});
|
|
177
197
|
break;
|
|
178
198
|
}
|
|
179
199
|
}
|
|
180
200
|
} catch (err) {
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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 {
|
package/thread-viewer.ts
ADDED
|
@@ -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
|
+
}
|