@nmzpy/pi-ember-stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +9 -0
- package/README.md +63 -0
- package/package.json +50 -0
- package/src/pi-ember-stack.ts +983 -0
- package/src/questionnaire-tool.ts +287 -0
- package/src/subagent/LICENSE +21 -0
- package/src/subagent/README.md +52 -0
- package/src/subagent/agent-format.md +64 -0
- package/src/subagent/agents/architect.md +56 -0
- package/src/subagent/agents/coder.md +35 -0
- package/src/subagent/agents/general-purpose.md +13 -0
- package/src/subagent/agents/reviewer.md +36 -0
- package/src/subagent/agents/scout.md +44 -0
- package/src/subagent/agents/worker.md +15 -0
- package/src/subagent/extensions/agents.ts +223 -0
- package/src/subagent/extensions/index.ts +1218 -0
- package/src/subagent/extensions/model.ts +86 -0
- package/src/subagent/extensions/package.json +3 -0
- package/src/subagent/extensions/render.ts +278 -0
- package/src/subagent/extensions/runner.ts +317 -0
- package/src/subagent/extensions/service.ts +79 -0
- package/src/subagent/extensions/thread-viewer.ts +393 -0
- package/src/subagent/extensions/threads.ts +116 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AuthStorage, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentConfig } from "./agents.ts";
|
|
3
|
+
import { runSubAgent, type SubAgentResult } from "./runner.ts";
|
|
4
|
+
import { resolveModel } from "./model.ts";
|
|
5
|
+
|
|
6
|
+
export const SUBAGENT_REQUEST_EVENT = "pi-subagent:run";
|
|
7
|
+
|
|
8
|
+
export interface SubagentRunRequest {
|
|
9
|
+
id: string;
|
|
10
|
+
agent: string;
|
|
11
|
+
task: string;
|
|
12
|
+
cwd?: string;
|
|
13
|
+
timeout?: number;
|
|
14
|
+
instructions?: string;
|
|
15
|
+
readOnly?: boolean;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
accept?: () => boolean;
|
|
18
|
+
respond: (response: SubagentRunResponse) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SubagentRunResponse =
|
|
22
|
+
| { id: string; ok: true; result: SubAgentResult }
|
|
23
|
+
| { id: string; ok: false; error: string };
|
|
24
|
+
|
|
25
|
+
export async function runNamedAgent(options: {
|
|
26
|
+
agent: AgentConfig;
|
|
27
|
+
task: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
ctx: ExtensionContext;
|
|
30
|
+
timeout?: number;
|
|
31
|
+
instructions?: string;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
onMessage?: (result: SubAgentResult) => void;
|
|
34
|
+
}): Promise<SubAgentResult> {
|
|
35
|
+
const { model, attempted } = resolveModel(options.agent.model, options.ctx.model, options.ctx.modelRegistry);
|
|
36
|
+
if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
|
|
37
|
+
|
|
38
|
+
const authStorage = AuthStorage.inMemory();
|
|
39
|
+
const modelRegistry = options.ctx.modelRegistry;
|
|
40
|
+
const auth = await options.ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
41
|
+
if (auth.ok) {
|
|
42
|
+
if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
|
|
43
|
+
// ponytail: env and headers stay on the parent modelRegistry — reuse it directly.
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const timeoutController = options.timeout && options.timeout > 0 ? new AbortController() : undefined;
|
|
47
|
+
const timeoutId = timeoutController ? setTimeout(() => timeoutController.abort(), options.timeout) : undefined;
|
|
48
|
+
const signals = [options.signal, timeoutController?.signal].filter((value): value is AbortSignal => Boolean(value));
|
|
49
|
+
const signal = signals.length > 1
|
|
50
|
+
? typeof (AbortSignal as any).any === "function"
|
|
51
|
+
? (AbortSignal as any).any(signals)
|
|
52
|
+
: signals[0]
|
|
53
|
+
: signals[0];
|
|
54
|
+
const contract = options.instructions?.slice(0, 16 * 1024);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const result = await runSubAgent({
|
|
58
|
+
cwd: options.cwd,
|
|
59
|
+
systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
|
|
60
|
+
task: options.task,
|
|
61
|
+
tools: (options.agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"]).filter((tool) => tool !== "subagent"),
|
|
62
|
+
model,
|
|
63
|
+
authStorage,
|
|
64
|
+
modelRegistry,
|
|
65
|
+
signal,
|
|
66
|
+
agentName: options.agent.name,
|
|
67
|
+
thinkingLevel: options.agent.thinking,
|
|
68
|
+
onMessage: options.onMessage,
|
|
69
|
+
});
|
|
70
|
+
if (timeoutController?.signal.aborted && !options.signal?.aborted) {
|
|
71
|
+
result.exitCode = 1;
|
|
72
|
+
result.stopReason = "timeout";
|
|
73
|
+
result.errorMessage ||= `Timeout after ${options.timeout}ms`;
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
} finally {
|
|
77
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts"]
|
|
13
|
+
}
|