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/lifecycle.ts ADDED
@@ -0,0 +1,333 @@
1
+ import type { ParsedRun } from "./parse.ts";
2
+ import type { RunMeta, RunStatus } from "./registry.ts";
3
+
4
+ /** Named lifecycle quality for a finished (or terminal) subagent run. */
5
+ export type LifecycleClassification =
6
+ | "complete"
7
+ | "incomplete_no_terminal_event"
8
+ | "incomplete_open_tools"
9
+ | "failed_exit"
10
+ | "killed"
11
+ | "timed_out"
12
+ | "orphaned"
13
+ | "lost";
14
+
15
+ export interface LifecycleDiagnostics {
16
+ /** Whether the child log contained agent_end or agent_settled. */
17
+ sawTerminalEvent: boolean;
18
+ /** Count of tool starts without a matching end. */
19
+ unmatchedToolCount: number;
20
+ /** Human-readable unmatched tool summary (empty when none). */
21
+ unmatchedTools: string[];
22
+ /** Process exit code when known. */
23
+ exitCode: number | null | undefined;
24
+ /** Durable run status when known. */
25
+ status?: RunStatus | "exited";
26
+ }
27
+
28
+ export interface LifecycleValidation {
29
+ classification: LifecycleClassification;
30
+ /** True when exit-0 stream coherence failed (incomplete_* classes). */
31
+ incomplete: boolean;
32
+ diagnostics: LifecycleDiagnostics;
33
+ /** Short human summary for callbacks / headers. */
34
+ summary: string;
35
+ }
36
+
37
+ export interface ChildExitOutcome {
38
+ status: "completed" | "failed";
39
+ incomplete: boolean;
40
+ classification: LifecycleClassification;
41
+ diagnostics: LifecycleDiagnostics;
42
+ verdict: string;
43
+ }
44
+
45
+ function unmatchedToolLabels(run: ParsedRun): string[] {
46
+ return run.unmatchedToolCalls.map((call) =>
47
+ call.id ? `${call.toolName} (${call.id})` : call.toolName,
48
+ );
49
+ }
50
+
51
+ function buildDiagnostics(
52
+ run: ParsedRun,
53
+ exitCode: number | null | undefined,
54
+ status?: RunStatus | "exited",
55
+ ): LifecycleDiagnostics {
56
+ return {
57
+ sawTerminalEvent: run.sawEnd,
58
+ unmatchedToolCount: run.unmatchedToolCalls.length,
59
+ unmatchedTools: unmatchedToolLabels(run),
60
+ exitCode,
61
+ status,
62
+ };
63
+ }
64
+
65
+ /** Classify a child process exit before persisting its run metadata. */
66
+ export function classifyChildExit(code: number | null, run: ParsedRun): ChildExitOutcome {
67
+ const diagnostics = buildDiagnostics(run, code);
68
+ const exitedSuccessfully = code === 0 || code === null;
69
+
70
+ if (!exitedSuccessfully) {
71
+ return {
72
+ status: "failed",
73
+ incomplete: false,
74
+ classification: "failed_exit",
75
+ diagnostics,
76
+ verdict: `✗ failed (exit ${code})`,
77
+ };
78
+ }
79
+
80
+ if (!run.sawEnd) {
81
+ return {
82
+ status: "failed",
83
+ incomplete: true,
84
+ classification: "incomplete_no_terminal_event",
85
+ diagnostics,
86
+ verdict: `! incomplete child exit (exit ${code})`,
87
+ };
88
+ }
89
+
90
+ if (run.unmatchedToolCalls.length > 0) {
91
+ return {
92
+ status: "failed",
93
+ incomplete: true,
94
+ classification: "incomplete_open_tools",
95
+ diagnostics,
96
+ verdict: `! incomplete child exit (exit ${code})`,
97
+ };
98
+ }
99
+
100
+ return {
101
+ status: "completed",
102
+ incomplete: false,
103
+ classification: "complete",
104
+ diagnostics,
105
+ verdict: "✓ completed",
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Resolve lifecycle validation for any terminal/persisted run.
111
+ * Prefer a stored classification when present; otherwise derive from status + log.
112
+ */
113
+ export function resolveLifecycle(
114
+ meta: Pick<RunMeta, "status" | "exitCode" | "failureReason" | "lifecycleClassification">,
115
+ run: ParsedRun,
116
+ ): LifecycleValidation {
117
+ const diagnostics = buildDiagnostics(run, meta.exitCode, meta.status);
118
+
119
+ if (meta.status === "killed") {
120
+ return {
121
+ classification: "killed",
122
+ incomplete: false,
123
+ diagnostics,
124
+ summary: "run was stopped (killed)",
125
+ };
126
+ }
127
+
128
+ if (meta.lifecycleClassification) {
129
+ // Never trust a stored "complete" without re-checking stream evidence.
130
+ // Fresh finalization and result formatting both require current log authority.
131
+ if (meta.lifecycleClassification === "complete") {
132
+ const exitCode = meta.exitCode === undefined ? 0 : meta.exitCode;
133
+ const outcome = classifyChildExit(exitCode, run);
134
+ return {
135
+ classification: outcome.classification,
136
+ incomplete: outcome.incomplete,
137
+ diagnostics,
138
+ summary: summaryFor(outcome.classification),
139
+ };
140
+ }
141
+ const classification = meta.lifecycleClassification;
142
+ const incomplete = classification === "incomplete_no_terminal_event"
143
+ || classification === "incomplete_open_tools";
144
+ return {
145
+ classification,
146
+ incomplete,
147
+ diagnostics,
148
+ summary: summaryFor(classification),
149
+ };
150
+ }
151
+
152
+ // Backward-compatible derivation for older meta without lifecycleClassification.
153
+ // Never trust a bare "completed" status alone — re-validate against log evidence
154
+ // so pre-#86 false-completed records cannot render as clean final answers.
155
+ if (meta.failureReason === "incomplete-stream") {
156
+ const classification = !run.sawEnd
157
+ ? "incomplete_no_terminal_event"
158
+ : "incomplete_open_tools";
159
+ return {
160
+ classification,
161
+ incomplete: true,
162
+ diagnostics,
163
+ summary: summaryFor(classification),
164
+ };
165
+ }
166
+
167
+ if (meta.status === "completed" || meta.status === "failed") {
168
+ const exitCode = meta.exitCode === undefined ? 0 : meta.exitCode;
169
+ const outcome = classifyChildExit(exitCode, run);
170
+ // Preserve distinct killed path above; here only completed/failed remain.
171
+ // Nonzero exits stay failed_exit even if status was mis-recorded completed.
172
+ return {
173
+ classification: outcome.classification,
174
+ incomplete: outcome.incomplete,
175
+ diagnostics,
176
+ summary: summaryFor(outcome.classification),
177
+ };
178
+ }
179
+
180
+ // running / exited fallbacks — not clean completion.
181
+ return {
182
+ classification: "lost",
183
+ incomplete: false,
184
+ diagnostics,
185
+ summary: summaryFor("lost"),
186
+ };
187
+ }
188
+
189
+ function summaryFor(classification: LifecycleClassification): string {
190
+ switch (classification) {
191
+ case "complete":
192
+ return "lifecycle complete";
193
+ case "incomplete_no_terminal_event":
194
+ return "lifecycle incomplete_no_terminal_event — no agent_end/agent_settled";
195
+ case "incomplete_open_tools":
196
+ return "lifecycle incomplete_open_tools — unmatched tool executions remain";
197
+ case "failed_exit":
198
+ return "lifecycle failed_exit";
199
+ case "killed":
200
+ return "lifecycle killed";
201
+ case "timed_out":
202
+ return "lifecycle timed_out";
203
+ case "orphaned":
204
+ return "lifecycle orphaned";
205
+ case "lost":
206
+ return "lifecycle lost";
207
+ }
208
+ }
209
+
210
+ /** Render diagnostics block shared by incomplete and general result formatting. */
211
+ export function formatLifecycleDiagnostics(lifecycle: LifecycleValidation): string {
212
+ const d = lifecycle.diagnostics;
213
+ const unmatched = d.unmatchedTools.length
214
+ ? d.unmatchedTools.join(", ")
215
+ : "none";
216
+ return [
217
+ `Lifecycle: ${lifecycle.classification}`,
218
+ `Lifecycle diagnostics: terminal event: ${d.sawTerminalEvent ? "yes" : "no"}; unmatched tools: ${unmatched}; exit: ${d.exitCode === undefined ? "?" : String(d.exitCode)}`,
219
+ ].join("\n");
220
+ }
221
+
222
+ /** Render the evidence for a child that exited before producing a final answer. */
223
+ export function formatIncompleteResult(
224
+ run: ParsedRun,
225
+ rawLogTail: string,
226
+ lifecycle?: LifecycleValidation,
227
+ ): string {
228
+ const validation = lifecycle ?? {
229
+ classification: !run.sawEnd ? "incomplete_no_terminal_event" as const : "incomplete_open_tools" as const,
230
+ incomplete: true,
231
+ diagnostics: buildDiagnostics(run, undefined),
232
+ summary: !run.sawEnd
233
+ ? summaryFor("incomplete_no_terminal_event")
234
+ : summaryFor("incomplete_open_tools"),
235
+ };
236
+ const evidence = [
237
+ !run.sawEnd ? "no agent_end or agent_settled event" : "terminal event observed",
238
+ run.unmatchedToolCalls.length
239
+ ? `unmatched tools: ${unmatchedToolLabels(run).join(", ")}`
240
+ : "no unmatched tools",
241
+ ].join("; ");
242
+ const bestAvailable = run.finalText || run.lastActivity || "(no parsed assistant output)";
243
+ return [
244
+ "Run ended unexpectedly before producing a coherent final result.",
245
+ formatLifecycleDiagnostics(validation),
246
+ `Stream evidence: ${evidence}.`,
247
+ "",
248
+ "--- best available parsed output ---",
249
+ bestAvailable,
250
+ "",
251
+ "--- raw log tail ---",
252
+ rawLogTail,
253
+ ].join("\n");
254
+ }
255
+
256
+ /** Best-effort parsed body shared by incomplete / orphaned / lost diagnostics. */
257
+ function bestParsedOutput(run: ParsedRun): string {
258
+ return run.finalText || run.lastActivity || "(no parsed assistant output)";
259
+ }
260
+
261
+ /**
262
+ * Non-final diagnostic for an orphaned run (#65).
263
+ * Supervision is broken; related process-group work may still be alive.
264
+ * Surfaces best-CURRENT artifacts without pretending the run finished.
265
+ */
266
+ export function formatOrphanedResult(run: ParsedRun, rawLogTail: string): string {
267
+ return [
268
+ "Run is orphaned — non-final. Supervision was lost; related processes may still be alive.",
269
+ "There is no final result yet. Best-current artifacts below (output may still change).",
270
+ "",
271
+ "--- best-current parsed output ---",
272
+ bestParsedOutput(run),
273
+ "",
274
+ "--- raw log tail ---",
275
+ rawLogTail,
276
+ ].join("\n");
277
+ }
278
+
279
+ /**
280
+ * Terminal-unknown diagnostic for a lost run (#65).
281
+ * No related process remains and no coherent terminal completion was observed.
282
+ * Surfaces best-AVAILABLE artifacts without claiming normal completion/failure.
283
+ */
284
+ export function formatLostResult(run: ParsedRun, rawLogTail: string): string {
285
+ return [
286
+ "Run is lost: no related process remains and no coherent terminal completion was observed.",
287
+ "This is a terminal unknown outcome, not a normal failure. Best-available artifacts below.",
288
+ "",
289
+ "--- best-available parsed output ---",
290
+ bestParsedOutput(run),
291
+ "",
292
+ "--- raw log tail ---",
293
+ rawLogTail,
294
+ ].join("\n");
295
+ }
296
+
297
+ export interface FormatSubagentResultInput {
298
+ id: string;
299
+ status: string;
300
+ exitCode: string | number;
301
+ statSeg: string;
302
+ toolsSeg: string;
303
+ run: ParsedRun;
304
+ rawLogTail: string;
305
+ lifecycle: LifecycleValidation;
306
+ }
307
+
308
+ /**
309
+ * Format `subagent_result` body. Incomplete classifications never present
310
+ * progress text as a clean final answer; all paths include lifecycle diagnostics.
311
+ * Lost runs use the dedicated #65 diagnostic so best-available artifacts stay
312
+ * consistent with the registered tool path.
313
+ */
314
+ export function formatSubagentResult(input: FormatSubagentResultInput): string {
315
+ const { id, status, exitCode, statSeg, toolsSeg, run, rawLogTail, lifecycle } = input;
316
+ const head = `[${id} · ${status} · exit ${exitCode}${statSeg}${toolsSeg} · lifecycle ${lifecycle.classification}]`;
317
+ if (lifecycle.incomplete) {
318
+ return `${head}\n${formatIncompleteResult(run, rawLogTail, lifecycle)}`;
319
+ }
320
+ // Terminal lost: dedicated diagnostic + best-available artifacts (#65).
321
+ if (status === "lost" || lifecycle.classification === "lost") {
322
+ return `${head}\n${formatLostResult(run, rawLogTail)}`;
323
+ }
324
+ const body = run.finalText
325
+ || (run.lastActivity
326
+ ? `(no final answer parsed; latest activity)\n${run.lastActivity}`
327
+ : `(no final answer parsed)\n\n--- raw log tail ---\n${rawLogTail}`);
328
+ return [
329
+ head,
330
+ formatLifecycleDiagnostics(lifecycle),
331
+ body,
332
+ ].join("\n");
333
+ }
package/list.mjs ADDED
@@ -0,0 +1,123 @@
1
+ import { fmtElapsed, fmtSpend } from "./widget.mjs";
2
+ import { formatListHealthSuffix } from "./health-surface.mjs";
3
+
4
+ export const SUBAGENT_LIST_DEFAULT_LIMIT = 20;
5
+ export const SUBAGENT_LIST_MAX_LIMIT = 100;
6
+ /** Effective + durable supervision statuses accepted by subagent_list filters. */
7
+ export const SUBAGENT_LIST_STATUSES = [
8
+ "running",
9
+ "completed",
10
+ "failed",
11
+ "killed",
12
+ "exited",
13
+ "orphaned",
14
+ "lost",
15
+ ];
16
+
17
+ const STATUS_SET = new Set(SUBAGENT_LIST_STATUSES);
18
+
19
+ function promptPreview(meta) {
20
+ return String(meta.promptPreview ?? "").replace(/\s+/g, " ").slice(0, 100);
21
+ }
22
+
23
+ export function normalizeSubagentListOptions(params = {}) {
24
+ const warnings = [];
25
+ const rawLimit = params.limit;
26
+ let limit = SUBAGENT_LIST_DEFAULT_LIMIT;
27
+ if (rawLimit !== undefined && rawLimit !== null) {
28
+ const n = Number(rawLimit);
29
+ if (!Number.isFinite(n)) {
30
+ throw new Error("subagent_list limit must be a finite number.");
31
+ }
32
+ limit = Math.floor(n);
33
+ if (limit < 0) {
34
+ warnings.push(`Requested limit ${rawLimit} is below 0; using 0.`);
35
+ limit = 0;
36
+ }
37
+ if (limit > SUBAGENT_LIST_MAX_LIMIT) {
38
+ warnings.push(
39
+ `Requested limit ${rawLimit} exceeds maximum ${SUBAGENT_LIST_MAX_LIMIT}; using ${SUBAGENT_LIST_MAX_LIMIT}.`,
40
+ );
41
+ limit = SUBAGENT_LIST_MAX_LIMIT;
42
+ }
43
+ }
44
+
45
+ const rawStatus = params.status;
46
+ let statuses = null;
47
+ if (rawStatus !== undefined && rawStatus !== null) {
48
+ const values = Array.isArray(rawStatus) ? rawStatus : [rawStatus];
49
+ const normalized = values.map((s) => String(s).trim().toLowerCase()).filter(Boolean);
50
+ const invalid = normalized.filter((s) => !STATUS_SET.has(s));
51
+ if (invalid.length) {
52
+ throw new Error(
53
+ `Unsupported subagent_list status: ${[...new Set(invalid)].join(", ")}. ` +
54
+ `Supported statuses: ${SUBAGENT_LIST_STATUSES.join(", ")}.`,
55
+ );
56
+ }
57
+ statuses = new Set(normalized);
58
+ }
59
+
60
+ return {
61
+ all: params.all === true,
62
+ limit,
63
+ statuses,
64
+ warnings,
65
+ };
66
+ }
67
+
68
+ export function formatSubagentListRow(meta, p) {
69
+ const status = p.status;
70
+ const now = p.now ?? Date.now();
71
+ const usage = p.usage;
72
+ const elapsed = fmtElapsed((meta.endedAt ?? now) - meta.startedAt);
73
+ const spend = fmtSpend(usage);
74
+ const name = meta.name ? `${meta.name} ` : "";
75
+ const stat = `${elapsed}${spend ? ` · ${spend}` : ""}`;
76
+ const health = formatListHealthSuffix(p.health);
77
+ const batch = meta.batchId
78
+ ? ` [batch: ${meta.batchName ? `${meta.batchName} ` : ""}${meta.batchId}]`
79
+ : "";
80
+ return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${stat}${health}${batch}\n ${promptPreview(meta)}`;
81
+ }
82
+
83
+ export function buildSubagentList(p) {
84
+ const options = normalizeSubagentListOptions(p.params ?? {});
85
+ const now = p.now ?? Date.now();
86
+ const parentPid = p.parentPid ?? process.pid;
87
+ const statusOf = p.statusOf ?? ((meta) => meta.status);
88
+ const usageById = p.usageById ?? (() => undefined);
89
+ const healthById = p.healthById ?? (() => undefined);
90
+
91
+ const scoped = (p.metas ?? [])
92
+ .filter((meta) => options.all || meta.spawnPid === parentPid)
93
+ .sort((a, b) => b.startedAt - a.startedAt)
94
+ .map((meta) => ({ meta, status: statusOf(meta) }));
95
+
96
+ const matching = options.statuses === null
97
+ ? scoped
98
+ : scoped.filter((row) => options.statuses.has(row.status));
99
+
100
+ const displayed = matching.slice(0, options.limit);
101
+ const lines = [...options.warnings];
102
+
103
+ if (matching.length === 0) {
104
+ lines.push("No subagent runs match filters.");
105
+ return lines.join("\n");
106
+ }
107
+
108
+ lines.push(...displayed.map((row) => formatSubagentListRow(row.meta, {
109
+ status: row.status,
110
+ now,
111
+ usage: usageById(row.meta.id),
112
+ health: healthById(row.meta.id),
113
+ })));
114
+
115
+ if (matching.length > displayed.length) {
116
+ lines.push(
117
+ `Showing ${displayed.length} of ${matching.length} matching subagent runs ` +
118
+ `(limit ${options.limit}). Increase limit up to ${SUBAGENT_LIST_MAX_LIMIT} to see more.`,
119
+ );
120
+ }
121
+
122
+ return lines.join("\n");
123
+ }
package/list.ts ADDED
@@ -0,0 +1,17 @@
1
+ export {
2
+ SUBAGENT_LIST_DEFAULT_LIMIT,
3
+ SUBAGENT_LIST_MAX_LIMIT,
4
+ SUBAGENT_LIST_STATUSES,
5
+ normalizeSubagentListOptions,
6
+ formatSubagentListRow,
7
+ buildSubagentList,
8
+ } from "./list.mjs";
9
+
10
+ export type EffectiveSubagentStatus =
11
+ | "running"
12
+ | "completed"
13
+ | "failed"
14
+ | "killed"
15
+ | "exited"
16
+ | "orphaned"
17
+ | "lost";