pi-better-subagents 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/README.md +420 -0
- package/batch.mjs +208 -0
- package/capacity.mjs +112 -0
- package/completion.mjs +165 -0
- package/completion.ts +11 -0
- package/config.json +14 -0
- package/config.ts +104 -0
- package/extensions.mjs +147 -0
- package/extensions.ts +19 -0
- package/finalization.ts +145 -0
- package/git-remotes.ts +413 -0
- package/git-workspace.ts +430 -0
- package/health-observation.ts +670 -0
- package/health-surface.mjs +276 -0
- package/health.ts +303 -0
- package/index.ts +1235 -0
- package/lifecycle.ts +333 -0
- package/list.mjs +123 -0
- package/list.ts +17 -0
- package/navigator.mjs +1188 -0
- package/navigator.ts +38 -0
- package/package.json +43 -0
- package/parse.ts +1144 -0
- package/registry.ts +236 -0
- package/sandbox.ts +164 -0
- package/spawn.ts +78 -0
- package/stop.ts +155 -0
- package/tools.ts +399 -0
- package/widget.mjs +218 -0
- package/widget.ts +28 -0
package/tools.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing tool definitions — the seam between pi registration and tests.
|
|
3
|
+
*
|
|
4
|
+
* index.ts registers EXACTLY the objects these factories return
|
|
5
|
+
* (`pi.registerTool(subagentListTool(Type))`), so a test that invokes a
|
|
6
|
+
* factory-built tool's `execute` exercises the same handler logic the model
|
|
7
|
+
* reaches — there is no second, drift-prone copy of the list/output/result/
|
|
8
|
+
* stop behavior.
|
|
9
|
+
*
|
|
10
|
+
* The factories take the `Type` schema builder as a parameter instead of
|
|
11
|
+
* importing `@earendil-works/pi-ai` directly: that package only exists inside
|
|
12
|
+
* the pi runtime, and keeping this module free of it lets `node --test` load
|
|
13
|
+
* the handlers with a trivial stub (the parameters schema is inert data as
|
|
14
|
+
* far as `execute` is concerned).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { readMeta, listMetas, effectiveStatus, isFinalResultStatus, type RunMeta, type RunStatus } from "./registry.ts";
|
|
19
|
+
import { parseRun, tailLog, formatSubagentOutputBody } from "./parse.ts";
|
|
20
|
+
import { buildSubagentResultText } from "./finalization.ts";
|
|
21
|
+
import { formatOrphanedResult } from "./lifecycle.ts";
|
|
22
|
+
import { stopRun } from "./stop.ts";
|
|
23
|
+
import { fmtElapsed, fmtSpend } from "./widget.ts";
|
|
24
|
+
import {
|
|
25
|
+
SUBAGENT_LIST_DEFAULT_LIMIT,
|
|
26
|
+
SUBAGENT_LIST_MAX_LIMIT,
|
|
27
|
+
SUBAGENT_LIST_STATUSES,
|
|
28
|
+
buildSubagentList,
|
|
29
|
+
} from "./list.ts";
|
|
30
|
+
import {
|
|
31
|
+
extractChildEventFactsFromLog,
|
|
32
|
+
loadHealthThresholdsFromConfig,
|
|
33
|
+
observeRunHealth,
|
|
34
|
+
type HealthObservation,
|
|
35
|
+
} from "./health-observation.ts";
|
|
36
|
+
import {
|
|
37
|
+
appendHealthDiagnostic,
|
|
38
|
+
formatHealthDiagnosticLine,
|
|
39
|
+
statusThemeColor,
|
|
40
|
+
truncateToVisibleWidth,
|
|
41
|
+
} from "./health-surface.mjs";
|
|
42
|
+
|
|
43
|
+
/** Observe one run for list/output/result diagnostics (#66/#67). Best-effort. */
|
|
44
|
+
function observeMetaHealth(meta: RunMeta, now: number = Date.now()): HealthObservation {
|
|
45
|
+
// Observation uses durable RunStatus (orphaned/lost/running/…); transient
|
|
46
|
+
// effective "exited" falls back to meta.status so process liveness stays truthful.
|
|
47
|
+
const eff = effectiveStatus(meta);
|
|
48
|
+
const status: RunStatus = eff === "exited" ? meta.status : eff;
|
|
49
|
+
const { facts, rawLog } = extractChildEventFactsFromLog(meta.id, { now });
|
|
50
|
+
return observeRunHealth({
|
|
51
|
+
status,
|
|
52
|
+
now,
|
|
53
|
+
facts,
|
|
54
|
+
rawLog,
|
|
55
|
+
thresholds: loadHealthThresholdsFromConfig(),
|
|
56
|
+
startedAt: meta.startedAt,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The slice of `@earendil-works/pi-ai`'s Type the tool schemas use. */
|
|
61
|
+
type TypeModule = {
|
|
62
|
+
Object: (v: unknown) => unknown;
|
|
63
|
+
String: (v?: unknown) => unknown;
|
|
64
|
+
Number: (v?: unknown) => unknown;
|
|
65
|
+
Boolean: (v?: unknown) => unknown;
|
|
66
|
+
Array: (v: unknown, o?: unknown) => unknown;
|
|
67
|
+
Optional: (v: unknown) => unknown;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** pi's tool-result text shape. */
|
|
71
|
+
export const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
|
|
72
|
+
|
|
73
|
+
const SUBAGENT_RESULT_PREVIEW_LINES = 8;
|
|
74
|
+
|
|
75
|
+
function resultTextContent(result: unknown): string {
|
|
76
|
+
const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content;
|
|
77
|
+
if (!Array.isArray(content)) return "";
|
|
78
|
+
return content
|
|
79
|
+
.filter((c) => c && (c.type === undefined || c.type === "text"))
|
|
80
|
+
.map((c) => c.text ?? "")
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseSubagentResultHead(head: string): { id?: string; status?: string } {
|
|
85
|
+
const raw = String(head ?? "");
|
|
86
|
+
const match = raw.match(/^\[([^\s\]]+)\s+·\s+([^·\]]+)/);
|
|
87
|
+
if (!match) return {};
|
|
88
|
+
return { id: match[1], status: match[2]?.trim() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function nonEmptyPreviewLines(lines: string[]): string[] {
|
|
92
|
+
const preview: string[] = [];
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (/^---\s+raw log tail\s+---$/i.test(line.trim())) break;
|
|
95
|
+
if (line.trim() === "") continue;
|
|
96
|
+
preview.push(line);
|
|
97
|
+
if (preview.length >= SUBAGENT_RESULT_PREVIEW_LINES) break;
|
|
98
|
+
}
|
|
99
|
+
return preview;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildSubagentResultDisplayDetails(body: string) {
|
|
103
|
+
const fullLines = String(body ?? "").split(/\r?\n/);
|
|
104
|
+
const head = fullLines[0] || "subagent_result";
|
|
105
|
+
const { id, status } = parseSubagentResultHead(head);
|
|
106
|
+
const rest = fullLines.slice(1);
|
|
107
|
+
const compactLines = nonEmptyPreviewLines(rest);
|
|
108
|
+
return {
|
|
109
|
+
kind: "subagent-result-display",
|
|
110
|
+
id,
|
|
111
|
+
status,
|
|
112
|
+
head,
|
|
113
|
+
fullLineCount: fullLines.length,
|
|
114
|
+
compactLines,
|
|
115
|
+
foldedLineCount: Math.max(0, rest.length - compactLines.length),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function subagentResultText(body: string) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text" as const, text: body }],
|
|
122
|
+
details: buildSubagentResultDisplayDetails(body),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function themed(theme: unknown, color: string, value: string): string {
|
|
127
|
+
const fg = (theme as { fg?: (color: string, text: string) => string })?.fg;
|
|
128
|
+
return typeof fg === "function" ? fg(color, value) : value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function wrapLineToVisibleWidth(line: string, width: number): string[] {
|
|
132
|
+
const str = String(line ?? "");
|
|
133
|
+
const max = Math.max(1, Number(width) || 80);
|
|
134
|
+
if (truncateToVisibleWidth(str, max) === str) return [str];
|
|
135
|
+
|
|
136
|
+
const out: string[] = [];
|
|
137
|
+
let current = "";
|
|
138
|
+
let visible = 0;
|
|
139
|
+
let i = 0;
|
|
140
|
+
while (i < str.length) {
|
|
141
|
+
if (str[i] === "\u001b" || str[i] === "\u009b") {
|
|
142
|
+
const match = str.slice(i).match(/^[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/);
|
|
143
|
+
if (match) {
|
|
144
|
+
current += match[0];
|
|
145
|
+
i += match[0].length;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (str[i] === "<") {
|
|
150
|
+
const close = str.indexOf(">", i);
|
|
151
|
+
if (close !== -1) {
|
|
152
|
+
const tag = str.slice(i, close + 1);
|
|
153
|
+
if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
|
|
154
|
+
current += tag;
|
|
155
|
+
i = close + 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (visible >= max) {
|
|
161
|
+
out.push(current);
|
|
162
|
+
current = "";
|
|
163
|
+
visible = 0;
|
|
164
|
+
}
|
|
165
|
+
current += str[i];
|
|
166
|
+
visible += 1;
|
|
167
|
+
i += 1;
|
|
168
|
+
}
|
|
169
|
+
out.push(current);
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderLines(lines: string[], mode: "truncate" | "wrap" = "truncate") {
|
|
174
|
+
return {
|
|
175
|
+
render(width: number = 80) {
|
|
176
|
+
return mode === "wrap"
|
|
177
|
+
? lines.flatMap((line) => wrapLineToVisibleWidth(line, width))
|
|
178
|
+
: lines.map((line) => truncateToVisibleWidth(line, width));
|
|
179
|
+
},
|
|
180
|
+
invalidate() { /* stateless */ },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function renderSubagentResultDisplay(result: unknown, options: unknown = {}, theme: unknown = {}) {
|
|
185
|
+
const fullText = resultTextContent(result);
|
|
186
|
+
const details = ((result as { details?: unknown })?.details as ReturnType<typeof buildSubagentResultDisplayDetails> | undefined)
|
|
187
|
+
?? buildSubagentResultDisplayDetails(fullText);
|
|
188
|
+
const expanded = (options as { expanded?: boolean })?.expanded === true;
|
|
189
|
+
const status = details.status ?? "result";
|
|
190
|
+
const statusText = themed(theme, displayThemeColor(status), status);
|
|
191
|
+
const meta = [details.id, `${details.fullLineCount} lines`].filter(Boolean).join(" · ");
|
|
192
|
+
|
|
193
|
+
if (expanded) {
|
|
194
|
+
return renderLines([
|
|
195
|
+
`${themed(theme, "accent", "subagent_result")} ${statusText}${meta ? themed(theme, "dim", ` · ${meta}`) : ""}`,
|
|
196
|
+
themed(theme, "dim", "Full displayed result. Click or collapse to fold."),
|
|
197
|
+
"",
|
|
198
|
+
...fullText.split(/\r?\n/),
|
|
199
|
+
], "wrap");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const folded = details.foldedLineCount > 0
|
|
203
|
+
? themed(theme, "dim", `Folded ${details.foldedLineCount} display lines. Click or expand for full result. Model payload unchanged.`)
|
|
204
|
+
: themed(theme, "dim", "Compact result. Expand for full display if needed.");
|
|
205
|
+
return renderLines([
|
|
206
|
+
`${themed(theme, "accent", "subagent_result")} ${statusText}${meta ? themed(theme, "dim", ` · ${meta}`) : ""}`,
|
|
207
|
+
details.head,
|
|
208
|
+
"",
|
|
209
|
+
themed(theme, "dim", "preview"),
|
|
210
|
+
...details.compactLines,
|
|
211
|
+
folded,
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function displayThemeColor(status: string): string {
|
|
216
|
+
const color = statusThemeColor(status);
|
|
217
|
+
return color === "danger" ? "error" : color;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A registered-tool definition as `pi.registerTool` accepts it. */
|
|
221
|
+
type ToolDefinition = Parameters<ExtensionAPI["registerTool"]>[0];
|
|
222
|
+
|
|
223
|
+
// ---- subagent_list --------------------------------------------------------
|
|
224
|
+
export function subagentListTool(Type: TypeModule): ToolDefinition {
|
|
225
|
+
return {
|
|
226
|
+
name: "subagent_list",
|
|
227
|
+
label: "List Subagents",
|
|
228
|
+
description:
|
|
229
|
+
`List background subagent runs with status and metadata. Non-blocking. ` +
|
|
230
|
+
`Default: this parent process only, newest first, limit ${SUBAGENT_LIST_DEFAULT_LIMIT}. ` +
|
|
231
|
+
`Pass all:true for machine-global; limit is clamped to max ${SUBAGENT_LIST_MAX_LIMIT}.`,
|
|
232
|
+
promptSnippet: "List background subagent runs and their status",
|
|
233
|
+
parameters: Type.Object({
|
|
234
|
+
all: Type.Optional(Type.Boolean({ description: "If true, list every run on this machine. Default false = only runs spawned by this pi process." })),
|
|
235
|
+
limit: Type.Optional(Type.Number({ description: `Maximum rows to display (default ${SUBAGENT_LIST_DEFAULT_LIMIT}, max ${SUBAGENT_LIST_MAX_LIMIT}; larger values are clamped).` })),
|
|
236
|
+
status: Type.Optional(Type.Array(Type.String(), { description: `Effective statuses to include: ${SUBAGENT_LIST_STATUSES.join(", ")}.` })),
|
|
237
|
+
}),
|
|
238
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
239
|
+
const p = (params ?? {}) as { all?: boolean; limit?: number; status?: string[] | string };
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
const metas = listMetas();
|
|
242
|
+
// Cache observations per id so usage + health share one parse where needed.
|
|
243
|
+
const healthCache = new Map<string, HealthObservation>();
|
|
244
|
+
const healthById = (id: string) => {
|
|
245
|
+
const hit = healthCache.get(id);
|
|
246
|
+
if (hit) return hit;
|
|
247
|
+
const meta = metas.find((m) => m.id === id) ?? readMeta(id);
|
|
248
|
+
if (!meta) return undefined;
|
|
249
|
+
const obs = observeMetaHealth(meta, now);
|
|
250
|
+
healthCache.set(id, obs);
|
|
251
|
+
return obs;
|
|
252
|
+
};
|
|
253
|
+
return text(buildSubagentList({
|
|
254
|
+
metas,
|
|
255
|
+
params: p,
|
|
256
|
+
parentPid: process.pid,
|
|
257
|
+
now,
|
|
258
|
+
statusOf: effectiveStatus,
|
|
259
|
+
usageById: (id: string) => parseRun(id).usage,
|
|
260
|
+
healthById,
|
|
261
|
+
}));
|
|
262
|
+
},
|
|
263
|
+
} as ToolDefinition;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- subagent_output ------------------------------------------------------
|
|
267
|
+
export function subagentOutputTool(Type: TypeModule): ToolDefinition {
|
|
268
|
+
return {
|
|
269
|
+
name: "subagent_output",
|
|
270
|
+
label: "Subagent Output",
|
|
271
|
+
description:
|
|
272
|
+
"Tail the live output of a subagent run. Non-blocking: returns whatever exists right now and " +
|
|
273
|
+
"returns immediately whether or not the run has finished. Never waits.",
|
|
274
|
+
promptSnippet: "Peek at a subagent's current output without waiting",
|
|
275
|
+
promptGuidelines: [
|
|
276
|
+
"Use subagent_output only when the user explicitly asks how a run is progressing. It never waits — do not call it in a loop.",
|
|
277
|
+
],
|
|
278
|
+
parameters: Type.Object({
|
|
279
|
+
id: Type.String({ description: "Run id from subagent_spawn." }),
|
|
280
|
+
tail_lines: Type.Optional(Type.Number({ description: "How many trailing lines to show (default 40)." })),
|
|
281
|
+
}),
|
|
282
|
+
async execute(_id: string, params: unknown) {
|
|
283
|
+
const p = params as { id: string; tail_lines?: number };
|
|
284
|
+
const meta = readMeta(p.id);
|
|
285
|
+
if (!meta) throw new Error(`Unknown run id: ${p.id}`);
|
|
286
|
+
const st = effectiveStatus(meta);
|
|
287
|
+
const r = parseRun(p.id);
|
|
288
|
+
const el = fmtElapsed((meta.endedAt ?? Date.now()) - meta.startedAt);
|
|
289
|
+
const spend = fmtSpend(r.usage);
|
|
290
|
+
const head = `[${p.id} · ${st} · ${el}${spend ? ` · ${spend}` : ""}]`;
|
|
291
|
+
const tools = r.toolCalls.length ? `\ntools used: ${r.toolCalls.join(", ")}` : "";
|
|
292
|
+
const raw = tailLog(p.id, p.tail_lines ?? 40);
|
|
293
|
+
const body = formatSubagentOutputBody(
|
|
294
|
+
head,
|
|
295
|
+
tools,
|
|
296
|
+
r.finalText || r.lastActivity || undefined,
|
|
297
|
+
raw,
|
|
298
|
+
r.diagnostics,
|
|
299
|
+
);
|
|
300
|
+
// Health diagnostics for orphaned/lost/degraded only (#67). Healthy/quiet
|
|
301
|
+
// stays on today's body. Independent of meta.callback.
|
|
302
|
+
const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
|
|
303
|
+
return text(appendHealthDiagnostic(body, healthLine));
|
|
304
|
+
},
|
|
305
|
+
} as ToolDefinition;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- subagent_result ------------------------------------------------------
|
|
309
|
+
export function subagentResultTool(Type: TypeModule): ToolDefinition {
|
|
310
|
+
return {
|
|
311
|
+
name: "subagent_result",
|
|
312
|
+
label: "Subagent Result",
|
|
313
|
+
description:
|
|
314
|
+
"Read a subagent's final output if it has finished. NEVER waits: if the run is still going it " +
|
|
315
|
+
"says so and returns immediately.",
|
|
316
|
+
promptSnippet: "Read a finished subagent's final result (never waits)",
|
|
317
|
+
promptGuidelines: [
|
|
318
|
+
"Use subagent_result to collect a finished run's output. If it reports the run is still going, stop — do not poll; you'll be notified when it finishes.",
|
|
319
|
+
],
|
|
320
|
+
parameters: Type.Object({
|
|
321
|
+
id: Type.String({ description: "Run id from subagent_spawn." }),
|
|
322
|
+
}),
|
|
323
|
+
renderResult(result: unknown, options: unknown, theme: unknown) {
|
|
324
|
+
return renderSubagentResultDisplay(result, options, theme);
|
|
325
|
+
},
|
|
326
|
+
async execute(_id: string, params: unknown) {
|
|
327
|
+
const p = params as { id: string };
|
|
328
|
+
const meta = readMeta(p.id);
|
|
329
|
+
if (!meta) throw new Error(`Unknown run id: ${p.id}`);
|
|
330
|
+
const st = effectiveStatus(meta);
|
|
331
|
+
if (!isFinalResultStatus(st)) {
|
|
332
|
+
if (st === "orphaned") {
|
|
333
|
+
// Non-terminal: supervision is broken but related process-
|
|
334
|
+
// group work may still be alive — never present this as a
|
|
335
|
+
// final result. Surface best-CURRENT artifacts (#65) plus
|
|
336
|
+
// health diagnostic (#67).
|
|
337
|
+
const r = parseRun(p.id);
|
|
338
|
+
const el = fmtElapsed((meta.endedAt ?? Date.now()) - meta.startedAt);
|
|
339
|
+
const spend = fmtSpend(r.usage);
|
|
340
|
+
const tools = r.toolCalls.length ? ` · tools: ${r.toolCalls.join(", ")}` : "";
|
|
341
|
+
const head = `[${p.id} · orphaned · ${el}${spend ? ` · ${spend}` : ""}${tools}]`;
|
|
342
|
+
const rawTail = tailLog(p.id, 40);
|
|
343
|
+
const body = `${head}\n${formatOrphanedResult(r, rawTail)}`;
|
|
344
|
+
const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
|
|
345
|
+
return subagentResultText(appendHealthDiagnostic(body, healthLine));
|
|
346
|
+
}
|
|
347
|
+
return subagentResultText(`Run ${p.id} is still running — no result yet. You'll be notified when it finishes; don't poll.`);
|
|
348
|
+
}
|
|
349
|
+
// Lifecycle-aware body (complete-stream authority + diagnostics).
|
|
350
|
+
// Lost runs go through formatLostResult inside formatSubagentResult (#65).
|
|
351
|
+
const body = buildSubagentResultText(p.id);
|
|
352
|
+
if (body === null) {
|
|
353
|
+
// Defensive: status race between effectiveStatus and body assembly.
|
|
354
|
+
return subagentResultText(`Run ${p.id} is still running — no result yet. You'll be notified when it finishes; don't poll.`);
|
|
355
|
+
}
|
|
356
|
+
// Append degraded/lost health facts when present; completed/failed
|
|
357
|
+
// happy paths stay quiet when observation is non-actionable.
|
|
358
|
+
const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
|
|
359
|
+
return subagentResultText(appendHealthDiagnostic(body, healthLine));
|
|
360
|
+
},
|
|
361
|
+
} as ToolDefinition;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---- subagent_stop --------------------------------------------------------
|
|
365
|
+
/**
|
|
366
|
+
* Stop is the one tool with a UI side effect (widget redraw after a kill);
|
|
367
|
+
* the caller injects it so the factory stays loadable without a TUI context.
|
|
368
|
+
*/
|
|
369
|
+
export function subagentStopTool(
|
|
370
|
+
Type: TypeModule,
|
|
371
|
+
deps: { onStopped?: () => void } = {},
|
|
372
|
+
): ToolDefinition {
|
|
373
|
+
return {
|
|
374
|
+
name: "subagent_stop",
|
|
375
|
+
label: "Stop Subagent",
|
|
376
|
+
description:
|
|
377
|
+
"Stop a running or orphaned subagent. Terminates identifiable related " +
|
|
378
|
+
"process-group members when present; otherwise finalizes from log " +
|
|
379
|
+
"evidence (completed/failed) or records lost.",
|
|
380
|
+
promptSnippet: "Stop a running or orphaned background subagent",
|
|
381
|
+
parameters: Type.Object({
|
|
382
|
+
id: Type.String({ description: "Run id from subagent_spawn." }),
|
|
383
|
+
}),
|
|
384
|
+
async execute(_id: string, params: unknown) {
|
|
385
|
+
const p = params as { id: string };
|
|
386
|
+
// Shared stop semantics with the TUI navigator close action (#44/#68):
|
|
387
|
+
// stopRun rereads meta + effective status from disk before acting.
|
|
388
|
+
const outcome = stopRun(p.id);
|
|
389
|
+
if (outcome.action === "not-running") {
|
|
390
|
+
return text(`Run ${p.id} is not running (${outcome.status}).`);
|
|
391
|
+
}
|
|
392
|
+
deps.onStopped?.();
|
|
393
|
+
if (outcome.action === "finalized") {
|
|
394
|
+
return text(`Resolved orphaned subagent ${p.id} → ${outcome.status}.`);
|
|
395
|
+
}
|
|
396
|
+
return text(`Stopped subagent ${p.id}.`);
|
|
397
|
+
},
|
|
398
|
+
} as ToolDefinition;
|
|
399
|
+
}
|
package/widget.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the live subagent status widget.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of TUI / registry I/O so unit tests can pin the flicker-related
|
|
5
|
+
* contracts (dirty-check, fixed-width geometry, undefined clear) without a
|
|
6
|
+
* live pi session.
|
|
7
|
+
*
|
|
8
|
+
* Host note: pi's setWidget(string[]) path disposes + rebuilds the component
|
|
9
|
+
* tree on every call. Callers must skip identical frames and keep line geometry
|
|
10
|
+
* stable so neighboring ▶ job-* rows do not jump.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { formatWidgetHealthSuffix } from "./health-surface.mjs";
|
|
14
|
+
|
|
15
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
|
+
|
|
17
|
+
/** Default ticker cadence (ms). */
|
|
18
|
+
export const TICK_MS = 1000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How often to re-parse a run log for spend/tool on the UI hot path.
|
|
22
|
+
* Elapsed still updates every tick; spend may lag by this much (AC3).
|
|
23
|
+
*/
|
|
24
|
+
export const SPEND_REFRESH_MS = 5000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Value that clears the widget. pi only clears on `undefined`; `[]` leaves an
|
|
28
|
+
* empty residual widget node.
|
|
29
|
+
*/
|
|
30
|
+
export const WIDGET_CLEAR = undefined;
|
|
31
|
+
|
|
32
|
+
/** Fixed field widths so line geometry does not jump as values grow. */
|
|
33
|
+
export const ELAPSED_WIDTH = 8; // "999h 59m", "99m 59s", "9999s"
|
|
34
|
+
export const TOKENS_WIDTH = 6; // "999.9k", "99.9M"
|
|
35
|
+
export const COST_WIDTH = 7; // "$999.99", "$0.0000"
|
|
36
|
+
|
|
37
|
+
/** "45s" · "2m 03s" · "1h 04m" — variable width (list/finalize paths). */
|
|
38
|
+
export function fmtElapsed(ms) {
|
|
39
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
40
|
+
if (s < 60) return `${s}s`;
|
|
41
|
+
const m = Math.floor(s / 60);
|
|
42
|
+
const rs = s % 60;
|
|
43
|
+
if (m < 60) return `${m}m ${String(rs).padStart(2, "0")}s`;
|
|
44
|
+
const h = Math.floor(m / 60);
|
|
45
|
+
const rm = m % 60;
|
|
46
|
+
return `${h}h ${String(rm).padStart(2, "0")}m`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fixed-width elapsed for the live widget (right-pad to ELAPSED_WIDTH). */
|
|
50
|
+
export function fmtElapsedFixed(ms) {
|
|
51
|
+
return fmtElapsed(ms).padEnd(ELAPSED_WIDTH, " ");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "412" · "1.2k" · "27.9k" · "1.4M". */
|
|
55
|
+
export function fmtTokens(n) {
|
|
56
|
+
if (n < 1000) return String(n);
|
|
57
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
58
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fmtTokensFixed(n) {
|
|
62
|
+
return fmtTokens(n).padStart(TOKENS_WIDTH, " ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Compact USD cost, e.g. "$0.0057" or "$1.23". */
|
|
66
|
+
export function fmtCost(usd) {
|
|
67
|
+
if (usd <= 0) return "$0";
|
|
68
|
+
return usd < 1 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Compact model label for the widget: drop the provider prefix. */
|
|
72
|
+
export function shortModel(model) {
|
|
73
|
+
return model ? (model.split("/").pop() ?? model) : "?";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function fmtCostFixed(usd) {
|
|
77
|
+
return fmtCost(usd).padStart(COST_WIDTH, " ");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One-line spend summary, or "" when nothing has been spent yet.
|
|
82
|
+
* Variable-width form used by list/finalize (not the live widget).
|
|
83
|
+
*/
|
|
84
|
+
export function fmtSpend(u) {
|
|
85
|
+
if (!u || (u.total <= 0 && u.costUSD <= 0)) return "";
|
|
86
|
+
return `${fmtTokens(u.total)} tok (↑${fmtTokens(u.input)} ↓${fmtTokens(u.output)}) · ${fmtCost(u.costUSD)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fixed-width spend for the live widget. Empty string when no spend yet —
|
|
91
|
+
* callers should still reserve geometry via a stable suffix policy if needed;
|
|
92
|
+
* once spend appears, widths stay constant as digits grow within the caps.
|
|
93
|
+
*/
|
|
94
|
+
export function fmtSpendFixed(u) {
|
|
95
|
+
if (!u || (u.total <= 0 && u.costUSD <= 0)) return "";
|
|
96
|
+
return (
|
|
97
|
+
`${fmtTokensFixed(u.total)} tok` +
|
|
98
|
+
` (↑${fmtTokensFixed(u.input)} ↓${fmtTokensFixed(u.output)})` +
|
|
99
|
+
` · ${fmtCostFixed(u.costUSD)}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Deep equality for widget line arrays. */
|
|
104
|
+
export function linesEqual(a, b) {
|
|
105
|
+
if (a === b) return true;
|
|
106
|
+
if (!a || !b) return false;
|
|
107
|
+
if (a.length !== b.length) return false;
|
|
108
|
+
for (let i = 0; i < a.length; i++) {
|
|
109
|
+
if (a[i] !== b[i]) return false;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decide what setWidget should receive for this frame.
|
|
116
|
+
*
|
|
117
|
+
* @param {string[]|undefined|null} prevLines - last lines sent to setWidget
|
|
118
|
+
* @param {string[]|null} nextLines - newly built lines, or null when idle
|
|
119
|
+
* @returns {{ op: 'skip' } | { op: 'set', lines: string[] } | { op: 'clear' }}
|
|
120
|
+
*/
|
|
121
|
+
export function nextWidgetAction(prevLines, nextLines) {
|
|
122
|
+
if (nextLines === null || nextLines === undefined) {
|
|
123
|
+
// Already cleared / never painted — nothing to do.
|
|
124
|
+
if (prevLines === undefined || prevLines === null) return { op: "skip" };
|
|
125
|
+
// Had visible content (or a mistaken [] residual) → clear with undefined.
|
|
126
|
+
return { op: "clear" };
|
|
127
|
+
}
|
|
128
|
+
if (linesEqual(prevLines, nextLines)) return { op: "skip" };
|
|
129
|
+
return { op: "set", lines: nextLines };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the widget lines for currently-running subagents.
|
|
134
|
+
*
|
|
135
|
+
* Healthy/quiet health is silent (issue #67). Degraded observations may append
|
|
136
|
+
* a short suffix via `healthById` without making the widget interactive.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} p
|
|
139
|
+
* @param {Array<{ id: string, name?: string|null, model?: string|null, startedAt: number }>} p.running
|
|
140
|
+
* @param {number} p.frame - spinner frame index
|
|
141
|
+
* @param {number} p.now - Date.now()
|
|
142
|
+
* @param {Record<string, { usage?: object, tool?: string|null }>} [p.spendById]
|
|
143
|
+
* @param {Record<string, object>|undefined} [p.healthById] - optional #66 observations
|
|
144
|
+
* @param {string|null} [p.affordanceHint] - left-arrow affordance shown on the title line
|
|
145
|
+
* @param {string|null} [p.selectedId] - selected run id while the main-window list is focused
|
|
146
|
+
* @returns {string[]}
|
|
147
|
+
*/
|
|
148
|
+
export function buildWidgetLines(p) {
|
|
149
|
+
const running = p.running ?? [];
|
|
150
|
+
const frame = p.frame ?? 0;
|
|
151
|
+
const now = p.now ?? Date.now();
|
|
152
|
+
const spendById = p.spendById ?? {};
|
|
153
|
+
const healthById = p.healthById ?? {};
|
|
154
|
+
const selectedId = p.selectedId ?? null;
|
|
155
|
+
const spin = SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length];
|
|
156
|
+
const hint = p.affordanceHint ? ` ${p.affordanceHint}` : "";
|
|
157
|
+
const lines = [`Subagents · ${running.length} running${hint}`];
|
|
158
|
+
for (const m of running) {
|
|
159
|
+
const el = fmtElapsedFixed(now - m.startedAt);
|
|
160
|
+
const snap = spendById[m.id] ?? {};
|
|
161
|
+
const spend = fmtSpendFixed(snap.usage);
|
|
162
|
+
const tool = snap.tool ? ` · ${snap.tool}` : "";
|
|
163
|
+
const health = formatWidgetHealthSuffix(healthById[m.id]);
|
|
164
|
+
const nm = m.name ?? m.id;
|
|
165
|
+
const prefix = selectedId === m.id ? "› " : " ";
|
|
166
|
+
// Preserve list-show-model (#14): "name · shortModel" before fixed elapsed.
|
|
167
|
+
// Two spaces before elapsed keep a stable gap; elapsed itself is fixed-width.
|
|
168
|
+
lines.push(`${prefix}${spin} ${nm} · ${shortModel(m.model)} ${el}${tool}${spend ? ` ${spend}` : ""}${health}`);
|
|
169
|
+
}
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether a cached spend snapshot is still fresh enough for the UI tick.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ refreshedAt: number, logSize?: number }|null|undefined} cached
|
|
177
|
+
* @param {number} now
|
|
178
|
+
* @param {number} [logSize]
|
|
179
|
+
* @param {number} [ttlMs]
|
|
180
|
+
*/
|
|
181
|
+
export function isSpendCacheFresh(cached, now, logSize, ttlMs = SPEND_REFRESH_MS) {
|
|
182
|
+
if (!cached) return false;
|
|
183
|
+
if (typeof logSize === "number" && cached.logSize !== logSize) return false;
|
|
184
|
+
return now - cached.refreshedAt < ttlMs;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Whether a cached health-log parse is still valid for the widget tick.
|
|
189
|
+
* Invalidates on log size or mtime change so growth/rewrite re-extracts, while
|
|
190
|
+
* unchanged logs skip the synchronous full-log reparse on every 1 Hz frame.
|
|
191
|
+
*
|
|
192
|
+
* @param {{ logSize?: number, mtimeMs?: number }|null|undefined} cached
|
|
193
|
+
* @param {number} logSize
|
|
194
|
+
* @param {number} [mtimeMs]
|
|
195
|
+
*/
|
|
196
|
+
export function isHealthLogCacheFresh(cached, logSize, mtimeMs) {
|
|
197
|
+
if (!cached) return false;
|
|
198
|
+
if (cached.logSize !== logSize) return false;
|
|
199
|
+
if (typeof mtimeMs === "number" && cached.mtimeMs !== mtimeMs) return false;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve child-event facts via size/mtime cache. Calls `extract` only on miss
|
|
205
|
+
* so the widget hot path can bound full-log reparses across frames.
|
|
206
|
+
*
|
|
207
|
+
* @param {{ facts: unknown, rawLog: unknown, logSize?: number, mtimeMs?: number }|null|undefined} cached
|
|
208
|
+
* @param {{ logSize: number, mtimeMs?: number }} identity
|
|
209
|
+
* @param {() => { facts: unknown, rawLog: unknown }} extract
|
|
210
|
+
* @returns {{ facts: unknown, rawLog: unknown, hit: boolean }}
|
|
211
|
+
*/
|
|
212
|
+
export function resolveHealthLogExtraction(cached, identity, extract) {
|
|
213
|
+
if (isHealthLogCacheFresh(cached, identity.logSize, identity.mtimeMs)) {
|
|
214
|
+
return { facts: cached.facts, rawLog: cached.rawLog, hit: true };
|
|
215
|
+
}
|
|
216
|
+
const { facts, rawLog } = extract();
|
|
217
|
+
return { facts, rawLog, hit: false };
|
|
218
|
+
}
|
package/widget.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript re-export of widget.mjs pure helpers.
|
|
3
|
+
* Logic lives in widget.mjs for ESM unit-test compatibility (mirrors completion.ts).
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
SPINNER,
|
|
7
|
+
TICK_MS,
|
|
8
|
+
SPEND_REFRESH_MS,
|
|
9
|
+
WIDGET_CLEAR,
|
|
10
|
+
ELAPSED_WIDTH,
|
|
11
|
+
TOKENS_WIDTH,
|
|
12
|
+
COST_WIDTH,
|
|
13
|
+
fmtElapsed,
|
|
14
|
+
fmtElapsedFixed,
|
|
15
|
+
fmtTokens,
|
|
16
|
+
fmtTokensFixed,
|
|
17
|
+
fmtCost,
|
|
18
|
+
fmtCostFixed,
|
|
19
|
+
shortModel,
|
|
20
|
+
fmtSpend,
|
|
21
|
+
fmtSpendFixed,
|
|
22
|
+
linesEqual,
|
|
23
|
+
nextWidgetAction,
|
|
24
|
+
buildWidgetLines,
|
|
25
|
+
isSpendCacheFresh,
|
|
26
|
+
isHealthLogCacheFresh,
|
|
27
|
+
resolveHealthLogExtraction,
|
|
28
|
+
} from "./widget.mjs";
|