pi-better-subagents 0.2.0 → 0.3.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 +2 -2
- package/docs/failure-observations.md +11 -0
- package/failures.ts +188 -0
- package/finalization.ts +14 -0
- package/index.ts +112 -23
- package/list.mjs +4 -1
- package/package.json +3 -2
- package/permission-policy.ts +106 -0
- package/sandbox.ts +26 -5
- package/shared-callback-batcher.ts +42 -16
- package/shared-failure-observations.ts +205 -0
- package/shared-sandbox-core.ts +283 -11
- package/tools.ts +9 -4
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
|
|
|
14
14
|
- Non-blocking subagent launches, with an optional role and named-agent catalog (`docs/agent-catalog.md`, `docs/agent-catalog-lifecycle.md`).
|
|
15
15
|
- Default OS write sandboxing on macOS and Linux.
|
|
16
16
|
- Explicit tool allowlists for child sessions.
|
|
17
|
-
- Durable logs
|
|
17
|
+
- Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status.
|
|
18
18
|
- Live background-work navigator for active runs.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
@@ -29,7 +29,7 @@ Try it for one run:
|
|
|
29
29
|
pi -e npm:pi-better-subagents
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Linux sandboxing uses `bubblewrap` when available, for example from `sudo apt-get install bubblewrap`.
|
|
32
|
+
Linux sandboxing uses `bubblewrap` when available, for example from `sudo apt-get install bubblewrap`. With [`pi-better-sandbox`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme) installed, `/sandbox` controls the independent Subagents profile. Each launch snapshots that profile; `sandbox:false` cannot bypass a human-enabled sandbox. Commands Off or Network access Off prevents a detached Pi launch, because the child runtime still needs its provider connection. Session/temp writes use a private directory for that run. Without published permission settings, the legacy default remains write confinement with unrestricted reads and network. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox).
|
|
33
33
|
|
|
34
34
|
## When To Use
|
|
35
35
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Failure observations
|
|
2
|
+
|
|
3
|
+
Structured tool, model, exit, and supervision failures are collected separately from lifecycle status. A child may still be `running`, or finish its agent loop, while checks remain unresolved. List, output, result, navigator, and completion notifications expose these observations before assistant progress can suggest that the work is healthy.
|
|
4
|
+
|
|
5
|
+
Each run retains a `failures.jsonl` journal. A later successful tool attempt clears an incident only when tool name, arguments, and working directory match and the retry began after the failure. Parallel or unrelated successes cannot clear it. Expected failures require explicit `expected: true` event/result metadata; prose such as “this test should fail” is not interpreted as metadata.
|
|
6
|
+
|
|
7
|
+
Running failures become eligible for attention after 60 seconds. Observation gaps are eligible immediately, and terminal runs use the existing completion callback. Delivery receipts are independent of recovery and are written after handoff; `callback:false` suppresses notifications without hiding inspection evidence.
|
|
8
|
+
|
|
9
|
+
The collector scans complete structured records independently of the finite progress tail. Missing terminal logs, unreadable records, and detected truncation are shown as **observation incomplete**. Failure classification uses structured error and exit fields; output text and domain-specific status codes are not failure signals.
|
|
10
|
+
|
|
11
|
+
A `.observed` companion marker makes missing journals detectable after restart. Failed writes retain evidence and receipts in memory and retry on subsequent reads, with an observation-incomplete warning until storage recovers. This pending memory cannot survive process loss while persistence is unavailable. Journals and markers follow existing run retention and explicit cleanup.
|
package/failures.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { logPathFor, runDir } from "./registry.ts";
|
|
4
|
+
import { failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
|
|
5
|
+
observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
|
|
6
|
+
|
|
7
|
+
export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered };
|
|
8
|
+
export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
|
|
9
|
+
export const readRunFailures = (id: string): FailureState => readFailureState(failurePath(id));
|
|
10
|
+
interface Attempt { operation: string; sequence: number }
|
|
11
|
+
interface Scan { offset: number; head: string; identity: string; attempts: Map<string, Attempt>; failed: Map<string, { id: string; sequence: number }>; sequence: number; retry?: number }
|
|
12
|
+
const scans = new Map<string, Scan>();
|
|
13
|
+
/** Drop an in-memory scan cursor (e.g. on reload); the journal remains authoritative. */
|
|
14
|
+
export function resetFailureScanCursor(id?: string): void {
|
|
15
|
+
if (id === undefined) scans.clear();
|
|
16
|
+
else scans.delete(id);
|
|
17
|
+
}
|
|
18
|
+
const CHUNK = 64 * 1024;
|
|
19
|
+
const MAX_LINE = 2 * 1024 * 1024;
|
|
20
|
+
function stable(value: unknown): unknown {
|
|
21
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
22
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, stable(v)]));
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function toolOperation(name: string, args: unknown, cwd: string): string {
|
|
26
|
+
return failureIdentity("tool", name, stable(args ?? {}), cwd);
|
|
27
|
+
}
|
|
28
|
+
function evidence(value: unknown): string | undefined {
|
|
29
|
+
if (value == null) return undefined;
|
|
30
|
+
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
|
31
|
+
return raw?.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 300);
|
|
32
|
+
}
|
|
33
|
+
function resultError(result: any): string | undefined {
|
|
34
|
+
if (result?.isError === true || (typeof result?.exitCode === "number" && result.exitCode !== 0)) {
|
|
35
|
+
return evidence(result?.content?.find?.((x: any) => x?.type === "text")?.text ?? result?.stderr ?? result?.error ?? result);
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function time(event: any): number | undefined {
|
|
40
|
+
return typeof event.at === "number" && Number.isFinite(event.at) ? event.at :
|
|
41
|
+
typeof event.message?.timestamp === "number" && Number.isFinite(event.message.timestamp) ? event.message.timestamp : undefined;
|
|
42
|
+
}
|
|
43
|
+
function fold(id: string, scan: Scan, row: any, offset: number, cwd: string): void {
|
|
44
|
+
const eventId = typeof row.toolCallId === "string" ? row.toolCallId : `offset:${offset}`;
|
|
45
|
+
const seq = ++scan.sequence;
|
|
46
|
+
const path = failurePath(id);
|
|
47
|
+
if (row.type === "tool_execution_start") {
|
|
48
|
+
scan.attempts.set(eventId, { operation: toolOperation(String(row.toolName ?? "unknown"), row.args, cwd), sequence: seq });
|
|
49
|
+
} else if (row.type === "tool_execution_end") {
|
|
50
|
+
const attempt = scan.attempts.get(eventId);
|
|
51
|
+
if (attempt) scan.attempts.delete(eventId);
|
|
52
|
+
const operation = attempt?.operation ?? toolOperation(String(row.toolName ?? "unknown"), row.args, cwd);
|
|
53
|
+
const error = row.isError === true ? evidence(row.result) ?? "Tool returned an error" : resultError(row.result);
|
|
54
|
+
if (error) {
|
|
55
|
+
const failureId = `tool:${eventId}`;
|
|
56
|
+
const state = observeFailures(path, [{ id: failureId, operation, kind: "failure", at: time(row), category: "tool",
|
|
57
|
+
summary: `${row.toolName ?? "Tool"} failed: ${error.slice(0, 180)}`, evidence: `${logPathFor(id)}#byte=${offset}`,
|
|
58
|
+
expected: row.expected === true || row.result?.expected === true }]);
|
|
59
|
+
scan.failed.set(operation, { id: state.observations[failureIdentity(operation)]?.id ?? failureId, sequence: seq });
|
|
60
|
+
} else if (attempt) {
|
|
61
|
+
const failed = scan.failed.get(operation);
|
|
62
|
+
if (failed && attempt.sequence > failed.sequence) {
|
|
63
|
+
observeFailures(path, [{ id: `recovered:${eventId}`, operation, kind: "recovered", at: time(row), incidents: [failed.id] }]);
|
|
64
|
+
scan.failed.delete(operation);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else if (row.type === "message_end" && row.message?.role === "assistant" &&
|
|
68
|
+
(row.message.stopReason === "error" || typeof row.message.errorMessage === "string")) {
|
|
69
|
+
const message = evidence(row.message.errorMessage) ?? "Model response failed";
|
|
70
|
+
const failureId = `model:${offset}`;
|
|
71
|
+
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model", summary: message }]);
|
|
72
|
+
scan.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq });
|
|
73
|
+
} else if (row.type === "auto_retry_start") {
|
|
74
|
+
if (typeof row.errorMessage === "string") {
|
|
75
|
+
const failureId = `model-retry:${offset}`;
|
|
76
|
+
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model", summary: row.errorMessage }]);
|
|
77
|
+
scan.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq - 1 });
|
|
78
|
+
}
|
|
79
|
+
scan.retry = seq;
|
|
80
|
+
} else if (row.type === "auto_retry_end" && row.success === false) {
|
|
81
|
+
const failureId = `model-exhausted:${offset}`;
|
|
82
|
+
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model",
|
|
83
|
+
summary: evidence(row.finalError) ?? "Model retry exhausted" }]);
|
|
84
|
+
scan.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq });
|
|
85
|
+
scan.retry = undefined;
|
|
86
|
+
} else if (row.type === "auto_retry_end" && row.success === true && scan.retry !== undefined) {
|
|
87
|
+
const failed = scan.failed.get("model-call");
|
|
88
|
+
if (failed && scan.retry > failed.sequence) {
|
|
89
|
+
observeFailures(path, [{ id: `model-recovered:${offset}`, operation: "model-call", kind: "recovered", at: time(row), incidents: [failed.id] }]);
|
|
90
|
+
scan.failed.delete("model-call");
|
|
91
|
+
}
|
|
92
|
+
scan.retry = undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Scan complete source records, independent of the finite progress/transcript tail. */
|
|
96
|
+
export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
|
|
97
|
+
const path = failurePath(id);
|
|
98
|
+
let fd: number;
|
|
99
|
+
try { fd = openSync(logPathFor(id), "r"); }
|
|
100
|
+
catch (error) {
|
|
101
|
+
const prior = readRunFailures(id);
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && !terminal && !scans.has(id) && prior.seen.length === 0) return prior;
|
|
103
|
+
const access = prior.observations[failureIdentity("child-log-access")];
|
|
104
|
+
if (access && access.status !== "resolved") return prior;
|
|
105
|
+
return observeFailures(path, [{ id: failureIdentity("source-unreadable", access?.id ?? "initial"), operation: "child-log-access", kind: "incomplete",
|
|
106
|
+
summary: "Child log is unavailable; observations may be incomplete" }]);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const stat = fstatSync(fd);
|
|
110
|
+
const size = stat.size;
|
|
111
|
+
const identity = `${stat.dev}:${stat.ino}`;
|
|
112
|
+
const prefix = Buffer.alloc(Math.min(256, size));
|
|
113
|
+
readSync(fd, prefix, 0, prefix.length, 0);
|
|
114
|
+
const head = prefix.toString("latin1");
|
|
115
|
+
let scan = scans.get(id);
|
|
116
|
+
const shared = scan ? Math.min(head.length, scan.head.length) : 0;
|
|
117
|
+
if (scan && (identity !== scan.identity || size < scan.offset || head.slice(0, shared) !== scan.head.slice(0, shared))) {
|
|
118
|
+
observeFailures(path, [{ id: failureIdentity("log-rewritten", scan.head, scan.offset), operation: "child-log", kind: "incomplete", summary: "Child log was truncated or rewritten; observations may be incomplete" }]);
|
|
119
|
+
scan = undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!scan) scan = { offset: 0, head, identity, attempts: new Map(), failed: new Map(), sequence: 0 };
|
|
122
|
+
scan.head = head;
|
|
123
|
+
let position = scan.offset;
|
|
124
|
+
let start = position;
|
|
125
|
+
let fragments: Buffer[] = [];
|
|
126
|
+
let length = 0;
|
|
127
|
+
let oversized = false;
|
|
128
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
129
|
+
while (position < size) {
|
|
130
|
+
const count = readSync(fd, buffer, 0, Math.min(CHUNK, size - position), position);
|
|
131
|
+
if (!count) break;
|
|
132
|
+
let from = 0;
|
|
133
|
+
for (let i = 0; i < count; i++) {
|
|
134
|
+
if (buffer[i] !== 10) continue;
|
|
135
|
+
const part = buffer.subarray(from, i);
|
|
136
|
+
length += part.length;
|
|
137
|
+
if (!oversized && length <= MAX_LINE) {
|
|
138
|
+
const line = Buffer.concat([...fragments, part]).toString("utf8").trim();
|
|
139
|
+
if (line.startsWith("{")) {
|
|
140
|
+
try {
|
|
141
|
+
const row = JSON.parse(line);
|
|
142
|
+
if (["tool_execution_start", "tool_execution_end", "message_end", "auto_retry_start", "auto_retry_end"].includes(row?.type)) {
|
|
143
|
+
if ((row.type.startsWith("tool_execution_") &&
|
|
144
|
+
(typeof row.toolCallId !== "string" || !row.toolCallId || typeof row.toolName !== "string")) ||
|
|
145
|
+
(row.type === "tool_execution_end" && typeof row.isError !== "boolean" && typeof row.result?.isError !== "boolean" && typeof row.result?.exitCode !== "number") ||
|
|
146
|
+
(row.type === "message_end" && (!row.message || typeof row.message.role !== "string")) ||
|
|
147
|
+
(row.type === "auto_retry_end" && typeof row.success !== "boolean")) throw new Error("invalid structured event");
|
|
148
|
+
fold(id, scan, row, start, cwd);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
observeFailures(path, [{ id: `malformed:${start}`, operation: "child-log", kind: "incomplete", summary: "Child log contains malformed structured events; observations may be incomplete" }]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
observeFailures(path, [{ id: `oversized:${start}`, operation: "child-log", kind: "incomplete", summary: "Child log contains an oversized event; observations may be incomplete" }]);
|
|
156
|
+
}
|
|
157
|
+
start = position + i + 1;
|
|
158
|
+
from = i + 1;
|
|
159
|
+
fragments = []; length = 0; oversized = false;
|
|
160
|
+
}
|
|
161
|
+
const rest = buffer.subarray(from, count);
|
|
162
|
+
length += rest.length;
|
|
163
|
+
if (length > MAX_LINE) oversized = true;
|
|
164
|
+
if (!oversized && rest.length) fragments.push(Buffer.from(rest));
|
|
165
|
+
position += count;
|
|
166
|
+
}
|
|
167
|
+
scan.offset = start;
|
|
168
|
+
scans.delete(id);
|
|
169
|
+
scans.set(id, scan);
|
|
170
|
+
while (scans.size > 64) scans.delete(scans.keys().next().value!);
|
|
171
|
+
const access = readRunFailures(id).observations[failureIdentity("child-log-access")];
|
|
172
|
+
if (access && access.status !== "resolved") observeFailures(path, [{ id: `source-readable:${access.id}:${identity}:${size}`,
|
|
173
|
+
operation: "child-log-access", kind: "recovered", incidents: [access.id] }]);
|
|
174
|
+
if (terminal && position > start) observeFailures(path, [{ id: `partial:${start}`, operation: "child-log", kind: "incomplete", summary: "Child log ends with a partial event; observations may be incomplete" }]);
|
|
175
|
+
} catch {
|
|
176
|
+
observeFailures(path, [{ id: "log-read-error", operation: "child-log", kind: "incomplete", summary: "Child log could not be fully scanned; observations may be incomplete" }]);
|
|
177
|
+
} finally { closeSync(fd); }
|
|
178
|
+
return readRunFailures(id);
|
|
179
|
+
}
|
|
180
|
+
export function failureSummary(id: string, cwd: string, terminal = false): string {
|
|
181
|
+
return formatFailureSummary(collectRunFailures(id, cwd, terminal));
|
|
182
|
+
}
|
|
183
|
+
export function prependFailureSummary(body: string, summary: string): string {
|
|
184
|
+
if (!summary) return body;
|
|
185
|
+
// Keep the established status header first; failure evidence still precedes assistant progress.
|
|
186
|
+
const header = /^(\[[^\n]+\])\n/.exec(body);
|
|
187
|
+
return header ? `${header[1]}\n${summary}\n${body.slice(header[0].length)}` : `${summary}\n${body}`;
|
|
188
|
+
}
|
package/finalization.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { buildCompletionDelivery } from "./completion.ts";
|
|
9
|
+
import { collectRunFailures, failurePath } from "./failures.ts";
|
|
10
|
+
import { observeFailures } from "./shared-failure-observations.ts";
|
|
9
11
|
import {
|
|
10
12
|
classifyChildExit,
|
|
11
13
|
formatSubagentResult,
|
|
@@ -57,6 +59,18 @@ export function finalizeRun(
|
|
|
57
59
|
// Lifecycle authority streams the complete NDJSON log; result text stays bounded.
|
|
58
60
|
const r = parseRunForLifecycle(id);
|
|
59
61
|
const outcome = classifyChildExit(code, r);
|
|
62
|
+
collectRunFailures(id, meta.cwd, true);
|
|
63
|
+
if (!outcome.incomplete && code !== null) {
|
|
64
|
+
observeFailures(failurePath(id), ["orphaned", "lost"].map((status) => ({
|
|
65
|
+
id: `supervision:${status}:exit-recovered`, operation: `supervision:${status}`,
|
|
66
|
+
kind: "recovered" as const, incidents: [`supervision:${status}`],
|
|
67
|
+
})));
|
|
68
|
+
}
|
|
69
|
+
if (code !== 0 || outcome.incomplete) {
|
|
70
|
+
observeFailures(failurePath(id), [{ id: `exit:${code ?? "unknown"}:${outcome.classification}`,
|
|
71
|
+
operation: "child-exit", kind: outcome.incomplete ? "incomplete" : "failure",
|
|
72
|
+
category: "exit", summary: outcome.incomplete ? "Child exit evidence is incomplete" : `Child exited with code ${code}` }]);
|
|
73
|
+
}
|
|
60
74
|
meta.status = outcome.status;
|
|
61
75
|
meta.lifecycleClassification = outcome.classification;
|
|
62
76
|
if (outcome.incomplete) meta.failureReason = "incomplete-stream";
|
package/index.ts
CHANGED
|
@@ -38,9 +38,10 @@ import { finalizeRun as finalizeRunCore } from "./finalization.ts";
|
|
|
38
38
|
import { loadConfig, normalizeTools, resolveExtensionPath, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
|
|
39
39
|
import { resolveExtensions, extensionArgs } from "./extensions.ts";
|
|
40
40
|
import { maybeBuildSandboxCommand } from "./sandbox.ts";
|
|
41
|
+
import { observeSandboxPermissions, resolveSubagentPermissions } from "./permission-policy.ts";
|
|
41
42
|
import { resolveSubagentWorkspace } from "./git-workspace.ts";
|
|
42
43
|
import { homedir } from "node:os";
|
|
43
|
-
import { join } from "node:path";
|
|
44
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
44
45
|
import {
|
|
45
46
|
sessionsDir,
|
|
46
47
|
runDir,
|
|
@@ -91,6 +92,8 @@ import {
|
|
|
91
92
|
} from "./capacity.mjs";
|
|
92
93
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
93
94
|
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
95
|
+
import { collectRunFailures, failurePath, failureSummary, formatFailureSummary, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
|
|
96
|
+
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
94
97
|
import {
|
|
95
98
|
text,
|
|
96
99
|
subagentListTool,
|
|
@@ -334,11 +337,15 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
|
|
|
334
337
|
|| meta.completionCallbackSentAt !== undefined
|
|
335
338
|
|| meta.completionCallbackSuppressedAt !== undefined) return;
|
|
336
339
|
const label = meta.name ? `${meta.name} (${id})` : id;
|
|
340
|
+
const observations = Object.values(collectRunFailures(id, meta.cwd, true).observations);
|
|
341
|
+
const unresolved = observations.filter((observation) => observation.status === "unresolved");
|
|
342
|
+
const observationStatus = unresolved.some((observation) => observation.category === "observation-incomplete")
|
|
343
|
+
? "observation incomplete" : unresolved.length ? "unresolved failure observations" : undefined;
|
|
337
344
|
getCallbackBatcher(pi).enqueue({
|
|
338
345
|
source: "subagent",
|
|
339
346
|
id,
|
|
340
347
|
label,
|
|
341
|
-
status: meta.status,
|
|
348
|
+
status: observationStatus ? `${meta.status}; ${observationStatus}` : meta.status,
|
|
342
349
|
detailTool: "subagent_result",
|
|
343
350
|
callback: true,
|
|
344
351
|
isDelivered: () => {
|
|
@@ -348,10 +355,18 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
|
|
|
348
355
|
},
|
|
349
356
|
getSuppressionReason: () => {
|
|
350
357
|
const current = readMeta(id);
|
|
351
|
-
if (!current)
|
|
358
|
+
if (!current) throw new Error("Subagent metadata is unavailable; defer completion");
|
|
352
359
|
return callbackSuppressionReason(current);
|
|
353
360
|
},
|
|
354
|
-
onDelivered: (at) =>
|
|
361
|
+
onDelivered: (at) => {
|
|
362
|
+
const state = collectRunFailures(id, meta.cwd, true);
|
|
363
|
+
const due = pendingFailureAttention(state, at, { terminal: true });
|
|
364
|
+
if (due) {
|
|
365
|
+
markFailureAttentionDelivered(failurePath(id), due, at);
|
|
366
|
+
if (due.incidents.some((incident) => pendingFailureAttention(collectRunFailures(id, meta.cwd, true), at, { terminal: true })?.incidents.includes(incident))) return;
|
|
367
|
+
}
|
|
368
|
+
markCompletionCallbackSent(id, at);
|
|
369
|
+
},
|
|
355
370
|
onSuppressed: (reason, at) => markCompletionCallbackSuppressed(id, reason, at),
|
|
356
371
|
});
|
|
357
372
|
}
|
|
@@ -364,6 +379,27 @@ function recoverCompletionCallbacks(pi: ExtensionAPI): void {
|
|
|
364
379
|
}
|
|
365
380
|
}
|
|
366
381
|
|
|
382
|
+
function deliverFailureAttention(pi: ExtensionAPI | undefined, meta: RunMeta, now: number): void {
|
|
383
|
+
if (!pi || meta.callback === false || callbackSuppressionReason(meta)) return;
|
|
384
|
+
if ((meta.status === "orphaned" || meta.status === "lost") && !isHealthCallbackHandled(meta, meta.status)) return;
|
|
385
|
+
const state = collectRunFailures(meta.id, meta.cwd, meta.status !== "running" && meta.status !== "orphaned");
|
|
386
|
+
const pending = pendingFailureAttention(state, now);
|
|
387
|
+
if (!pending || (meta.status !== "running" && meta.status !== "orphaned" && meta.completionCallbackPendingAt !== undefined)) return;
|
|
388
|
+
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
389
|
+
void getCallbackBatcher(pi).deliverUrgent({
|
|
390
|
+
source: "subagent", id: meta.id, label, status: `failure:${pending.key}`,
|
|
391
|
+
customType: "subagent-failure",
|
|
392
|
+
content: `${formatFailureSummary(state)}\nInspect: subagent_output id=${JSON.stringify(meta.id)}`,
|
|
393
|
+
isDelivered: () => failureAttentionHandled(collectRunFailures(meta.id, meta.cwd), pending.incidents),
|
|
394
|
+
getSuppressionReason: () => {
|
|
395
|
+
const current = readMeta(meta.id);
|
|
396
|
+
if (!current) throw new Error("Subagent metadata is unavailable; defer failure notification");
|
|
397
|
+
return callbackSuppressionReason(current);
|
|
398
|
+
},
|
|
399
|
+
onDelivered: (at) => { markFailureAttentionDelivered(failurePath(meta.id), pending, at); },
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
367
403
|
function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
|
|
368
404
|
if (status === "orphaned") {
|
|
369
405
|
if (meta.orphanedCallbackSuppressedAt !== undefined) return;
|
|
@@ -532,6 +568,9 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
532
568
|
|
|
533
569
|
const callback = meta.callback !== false;
|
|
534
570
|
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
571
|
+
const failureState = collectRunFailures(meta.id, meta.cwd, status === "lost");
|
|
572
|
+
const failureText = formatFailureSummary(failureState);
|
|
573
|
+
const attention = pendingFailureAttention(failureState, now, { terminal: status === "lost" });
|
|
535
574
|
const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
|
|
536
575
|
if (!delivery) {
|
|
537
576
|
// callback:false — model follow-up suppressed; mark handled so recovery
|
|
@@ -547,19 +586,24 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
547
586
|
label,
|
|
548
587
|
status,
|
|
549
588
|
customType: "subagent-health",
|
|
550
|
-
content: delivery.content,
|
|
589
|
+
content: prependFailureSummary(delivery.content, failureText),
|
|
551
590
|
isDelivered: () => {
|
|
552
591
|
const current = readMeta(meta.id);
|
|
553
|
-
|
|
592
|
+
if (!current) throw new Error("Subagent metadata is unavailable; defer health notification");
|
|
593
|
+
return isHealthCallbackHandled(current, status);
|
|
554
594
|
},
|
|
555
595
|
getSuppressionReason: () => {
|
|
556
596
|
const current = readMeta(meta.id);
|
|
557
|
-
if (!current)
|
|
597
|
+
if (!current) throw new Error("Subagent metadata is unavailable; defer health notification");
|
|
558
598
|
return callbackSuppressionReason(current);
|
|
559
599
|
},
|
|
560
600
|
onDelivered: (at) => {
|
|
561
601
|
const current = readMeta(meta.id);
|
|
562
602
|
if (!current || isHealthCallbackHandled(current, status)) return;
|
|
603
|
+
if (attention) {
|
|
604
|
+
markFailureAttentionDelivered(failurePath(meta.id), attention, at);
|
|
605
|
+
if (attention.incidents.some((incident) => pendingFailureAttention(collectRunFailures(meta.id, meta.cwd), at, { terminal: true })?.incidents.includes(incident))) return;
|
|
606
|
+
}
|
|
563
607
|
if (status === "orphaned") current.orphanedCallbackSentAt = at;
|
|
564
608
|
else current.lostCallbackSentAt = at;
|
|
565
609
|
writeMeta(current);
|
|
@@ -586,12 +630,25 @@ function reconcileHealth(): void {
|
|
|
586
630
|
if (!meta) continue;
|
|
587
631
|
if (meta.status !== "running" && meta.status !== "orphaned" && meta.status !== "lost") continue;
|
|
588
632
|
const now = Date.now();
|
|
633
|
+
if (meta.status === "orphaned" || meta.status === "lost") {
|
|
634
|
+
observeFailures(failurePath(meta.id), [{ id: `supervision:${meta.status}`, operation: `supervision:${meta.status}`, kind: "incomplete",
|
|
635
|
+
summary: meta.status === "lost" ? "Child supervision was lost; outcome is unknown" : "Child supervision interrupted; related work may still be alive" }], now);
|
|
636
|
+
}
|
|
637
|
+
deliverFailureAttention(pi, meta, now);
|
|
589
638
|
|
|
590
639
|
if (meta.status === "running" || meta.status === "orphaned") {
|
|
591
640
|
const result = reconcileRun(meta, realProcessProbe, now);
|
|
592
641
|
if (result.changed) {
|
|
593
642
|
Object.assign(meta, result.patch, { status: result.status });
|
|
594
643
|
writeMeta(meta);
|
|
644
|
+
if (result.status === "lost") {
|
|
645
|
+
observeFailures(failurePath(meta.id), [
|
|
646
|
+
{ id: "supervision:orphaned-resolved", operation: "supervision:orphaned", kind: "recovered", incidents: ["supervision:orphaned"] },
|
|
647
|
+
{ id: "supervision:lost", operation: "supervision:lost", kind: "incomplete", summary: "Child supervision was lost; outcome is unknown" },
|
|
648
|
+
], now);
|
|
649
|
+
} else if (result.status === "orphaned") {
|
|
650
|
+
observeFailures(failurePath(meta.id), [{ id: "supervision:orphaned", operation: "supervision:orphaned", kind: "incomplete", summary: "Child supervision interrupted; related work may still be alive" }], now);
|
|
651
|
+
}
|
|
595
652
|
if (result.transition) {
|
|
596
653
|
// Human-visible health (always) on fresh transitions.
|
|
597
654
|
if (!callbackSuppressionReason(meta)) {
|
|
@@ -612,8 +669,13 @@ function reconcileHealth(): void {
|
|
|
612
669
|
deliverHealthCallback(pi, meta, meta.status, now);
|
|
613
670
|
}
|
|
614
671
|
}
|
|
615
|
-
//
|
|
616
|
-
|
|
672
|
+
// Completed runs can still have failed completion handoffs to retry.
|
|
673
|
+
for (const summary of listMetasForParent(process.pid)) {
|
|
674
|
+
if (!pi || !ownedByThisParent(summary) || summary.status === "running" || summary.status === "orphaned" || summary.status === "lost") continue;
|
|
675
|
+
const meta = readMeta(summary.id);
|
|
676
|
+
if (meta && meta.completionCallbackPendingAt !== undefined && meta.completionCallbackSentAt === undefined && meta.completionCallbackSuppressedAt === undefined) enqueueCompletionCallback(pi!, meta.id);
|
|
677
|
+
}
|
|
678
|
+
if (!needsMonitoring(listMetasForParent(process.pid)) && !hasPendingFailureCallbacks()) stopHealthTicker();
|
|
617
679
|
}
|
|
618
680
|
|
|
619
681
|
/**
|
|
@@ -669,6 +731,12 @@ function reconcileAbandonedRuns(now: number = Date.now()): number {
|
|
|
669
731
|
return adopted;
|
|
670
732
|
}
|
|
671
733
|
|
|
734
|
+
function hasPendingFailureCallbacks(): boolean {
|
|
735
|
+
return listMetasForParent(process.pid).some((m) => ownedByThisParent(m) && m.callback !== false &&
|
|
736
|
+
m.completionCallbackPendingAt !== undefined && m.completionCallbackSentAt === undefined && m.completionCallbackSuppressedAt === undefined &&
|
|
737
|
+
!callbackSuppressionReason(m));
|
|
738
|
+
}
|
|
739
|
+
|
|
672
740
|
/** Start the reconciliation loop if it isn't already running. */
|
|
673
741
|
function ensureHealthTicker(): void {
|
|
674
742
|
if (healthTicker) return;
|
|
@@ -847,6 +915,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
847
915
|
if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
|
|
848
916
|
if (row.tool) bits.push(row.tool);
|
|
849
917
|
if (row.spend) bits.push(row.spend);
|
|
918
|
+
const failure = failureSummary(row.id, metaById.get(row.id)?.cwd ?? "", row.status !== "running" && row.status !== "orphaned");
|
|
919
|
+
const firstFailure = failure.split("\n")[0] || "";
|
|
850
920
|
return {
|
|
851
921
|
providerId: "subagents",
|
|
852
922
|
id: row.id,
|
|
@@ -859,7 +929,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
859
929
|
statusTone: statusTone(row.status),
|
|
860
930
|
kind: "subagent",
|
|
861
931
|
elapsed: row.elapsed,
|
|
862
|
-
primary: bits.join(" · ") || "subagent run",
|
|
932
|
+
primary: firstFailure || bits.join(" · ") || "subagent run",
|
|
933
|
+
secondary: firstFailure ? bits.join(" · ") : undefined,
|
|
863
934
|
facts: row.healthFacts,
|
|
864
935
|
sortStartedAt: metaById.get(row.id)?.startedAt ?? now,
|
|
865
936
|
expiresAt: (() => {
|
|
@@ -909,7 +980,9 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
909
980
|
if (!detail) return null;
|
|
910
981
|
void options;
|
|
911
982
|
const transcript = readRunTranscript(id);
|
|
983
|
+
const failure = failureSummary(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
|
|
912
984
|
const metadata = [
|
|
985
|
+
...(failure ? [{ label: "failure", value: failure.split("\n")[0]! }] : []),
|
|
913
986
|
{ label: "provider", value: "Subagents" },
|
|
914
987
|
{ label: "id", value: detail.id },
|
|
915
988
|
...(detail.role ? [{ label: "role", value: String(detail.role) }] : []),
|
|
@@ -926,11 +999,11 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
926
999
|
title: detail.name || detail.id,
|
|
927
1000
|
status: detail.status,
|
|
928
1001
|
statusTone: statusTone(detail.status),
|
|
929
|
-
subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
|
|
1002
|
+
subtitle: failure.split("\n")[0] || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
|
|
930
1003
|
metadata,
|
|
931
|
-
evidence: { label: "transcript", text: detail.output || "(no transcript yet)" },
|
|
1004
|
+
evidence: { label: "transcript", text: prependFailureSummary(detail.output || "(no transcript yet)", failure) },
|
|
932
1005
|
transcript: transcript.entries,
|
|
933
|
-
transcriptDiagnostic: transcript.diagnostic,
|
|
1006
|
+
transcriptDiagnostic: failure ? prependFailureSummary(transcript.diagnostic ?? "", failure) : transcript.diagnostic,
|
|
934
1007
|
footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
|
|
935
1008
|
};
|
|
936
1009
|
}
|
|
@@ -1113,13 +1186,14 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
|
|
|
1113
1186
|
// Host-facing wrapper around first-party finalizer (finalization.ts).
|
|
1114
1187
|
// Coherent child-exit evidence may supersede provisional orphaned/lost
|
|
1115
1188
|
// reconciliation; finalization.ts enforces canExitFinalize + lifecycle authority.
|
|
1116
|
-
finalizeRunCore(id, code, {
|
|
1189
|
+
const result = finalizeRunCore(id, code, {
|
|
1117
1190
|
renderWidget,
|
|
1118
1191
|
notify: (message, level) => {
|
|
1119
1192
|
try { ctx.ui.notify(message, level); } catch { /* ignore */ }
|
|
1120
1193
|
},
|
|
1121
1194
|
sendMessage: () => enqueueCompletionCallback(pi, id),
|
|
1122
1195
|
});
|
|
1196
|
+
if (result.applied && hasPendingFailureCallbacks()) ensureHealthTicker();
|
|
1123
1197
|
}
|
|
1124
1198
|
|
|
1125
1199
|
/** String for one role, or an array when the caller assigns more than one. Arrays reach clarification instead of being rejected. */
|
|
@@ -1138,6 +1212,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1138
1212
|
healthPi = pi;
|
|
1139
1213
|
ensureSubagentProvider();
|
|
1140
1214
|
registerSubagentsGoalProvider(pi);
|
|
1215
|
+
observeSandboxPermissions(pi);
|
|
1141
1216
|
let acceptanceResultToolRef: { execute: (toolCallId: string, params: { id: string }) => Promise<unknown> } | undefined;
|
|
1142
1217
|
function publishAcceptanceHooks(tool?: NonNullable<typeof acceptanceResultToolRef>): void {
|
|
1143
1218
|
if (process.env.PI_CATALOG_ACCEPTANCE_PROBE !== "1") return;
|
|
@@ -1238,6 +1313,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1238
1313
|
sandboxDir?: string;
|
|
1239
1314
|
}> {
|
|
1240
1315
|
assertThinkingLevel(p.thinking);
|
|
1316
|
+
const permissionPlan = resolveSubagentPermissions(pi, p.sandbox);
|
|
1241
1317
|
const cfg = loadConfig();
|
|
1242
1318
|
let model: string | undefined;
|
|
1243
1319
|
let thinking: ThinkingLevel | undefined;
|
|
@@ -1268,11 +1344,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1268
1344
|
// Sandbox is ON by default. sandbox_dir moves the confinement + working
|
|
1269
1345
|
// dir elsewhere. git_clone_workspace prepares a disposable clone with
|
|
1270
1346
|
// .git/ inside the writable root for Git-mutating sandboxed subagents.
|
|
1271
|
-
const explicitSandbox = p.sandbox === true || typeof p.sandbox_dir === "string" || p.git_clone_workspace === true;
|
|
1272
|
-
const sandboxEnabled =
|
|
1347
|
+
const explicitSandbox = p.sandbox === true || typeof p.sandbox_dir === "string" || p.git_clone_workspace === true || permissionPlan.enforced;
|
|
1348
|
+
const sandboxEnabled = permissionPlan.sandboxEnabled;
|
|
1273
1349
|
|
|
1274
|
-
mkdirSync(sessionsDir(), { recursive: true });
|
|
1275
1350
|
const id = nextRunId();
|
|
1351
|
+
const childSessionDir = permissionPlan.permissions ? join(sessionsDir(), id) : sessionsDir();
|
|
1352
|
+
mkdirSync(childSessionDir, { recursive: true });
|
|
1276
1353
|
mkdirSync(runDir(id), { recursive: true });
|
|
1277
1354
|
|
|
1278
1355
|
const workspace = resolveSubagentWorkspace({
|
|
@@ -1320,7 +1397,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1320
1397
|
|
|
1321
1398
|
const args = [
|
|
1322
1399
|
"-p", "--mode", "json",
|
|
1323
|
-
"--session-dir",
|
|
1400
|
+
"--session-dir", childSessionDir,
|
|
1324
1401
|
"--session-id", id,
|
|
1325
1402
|
...extArgs,
|
|
1326
1403
|
...(model ? ["--model", model] : []),
|
|
@@ -1332,10 +1409,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1332
1409
|
];
|
|
1333
1410
|
|
|
1334
1411
|
const piBin = resolvePiBinary();
|
|
1412
|
+
const writableContainsRunDir = requestedSandboxDir && (() => {
|
|
1413
|
+
const rel = relative(runDir(id), requestedSandboxDir);
|
|
1414
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
1415
|
+
})();
|
|
1416
|
+
const denyWrite = permissionPlan.enforced ? [
|
|
1417
|
+
join(PiCodingAgent.getAgentDir?.() ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "extensions"),
|
|
1418
|
+
...(writableContainsRunDir ? [
|
|
1419
|
+
join(runDir(id), "meta.json"), join(runDir(id), ".launch.json"),
|
|
1420
|
+
promptPathFor(id), join(runDir(id), "sandbox.sb"),
|
|
1421
|
+
] : [runDir(id)]),
|
|
1422
|
+
] : undefined;
|
|
1335
1423
|
const sandboxCommand = requestedSandboxDir
|
|
1336
1424
|
? maybeBuildSandboxCommand({
|
|
1337
1425
|
profilePath: join(runDir(id), "sandbox.sb"),
|
|
1338
1426
|
writableDir: requestedSandboxDir, home: homedir(), piBin, piArgs: args,
|
|
1427
|
+
...(permissionPlan.permissions ? { permissions: permissionPlan.permissions, denyWrite, runtimeDir: childSessionDir } : {}),
|
|
1339
1428
|
}, { sandboxEnabled, explicitSandbox })
|
|
1340
1429
|
: undefined;
|
|
1341
1430
|
const cmd = sandboxCommand ?? { file: piBin, fileArgs: args };
|
|
@@ -1403,7 +1492,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1403
1492
|
...SUBAGENT_ORCHESTRATION_GUIDELINES,
|
|
1404
1493
|
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
|
|
1405
1494
|
...CATALOG_GUIDELINES,
|
|
1406
|
-
"By default the subagent is sandboxed
|
|
1495
|
+
"By default the subagent is sandboxed. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Without published settings, legacy write confinement applies. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
|
|
1407
1496
|
"Use git_clone_workspace:true when the subagent will mutate Git in a sandbox. The parent prepares a disposable, self-contained clone with a real .git/ directory inside the sandbox root, so linked-worktree metadata outside the sandbox cannot stall the child.",
|
|
1408
1497
|
],
|
|
1409
1498
|
parameters: Type.Object({
|
|
@@ -1417,7 +1506,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1417
1506
|
tools: Type.Optional(Type.String({ description: "Tool allowlist: comma-separated names the child may use (e.g. 'read,bash,web_fetch'). This ALSO selects which extensions load — only packages backing a requested tool are loaded. Defaults to the configured safe set." })),
|
|
1418
1507
|
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist, applied on top of the allowlist." })),
|
|
1419
1508
|
clean: Type.Optional(Type.Boolean({ description: "Run a hermetic child with NO extensions at all (only built-ins: read, bash, edit, write). Default false — the extensions backing the requested tools load, so web_fetch and model auth (e.g. xai) work." })),
|
|
1420
|
-
sandbox: Type.Optional(Type.Boolean({ description: "
|
|
1509
|
+
sandbox: Type.Optional(Type.Boolean({ description: "Use the human Subagents profile from /sandbox. An enabled human profile cannot be bypassed with false. Without published settings, defaults to kernel write confinement; false opts out of that legacy default." })),
|
|
1421
1510
|
sandbox_dir: Type.Optional(Type.String({ description: "Confine writes to (and run the child in) this directory instead of the working dir. Created if missing." })),
|
|
1422
1511
|
callback: Type.Optional(Type.Boolean({ description: "Default TRUE: on completion, trigger a turn that calls subagent_result and presents the result. Set false to finish quietly — the result is then read on demand via subagent_result." })),
|
|
1423
1512
|
cwd: Type.Optional(Type.String({ description: "Working directory (default: current)." })),
|
|
@@ -1475,7 +1564,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1475
1564
|
(p.callback === false
|
|
1476
1565
|
? `Running in the background; the foreground is free. It will finish quietly — read the result with subagent_result id=${id}.\n`
|
|
1477
1566
|
: `Running in the background; the foreground is free. Its result will be posted back here when it finishes.\n`) +
|
|
1478
|
-
(sandboxDir ? `Sandboxed:
|
|
1567
|
+
(sandboxDir ? `Sandboxed: project root ${sandboxDir}; launch permissions apply.\n` : "") +
|
|
1479
1568
|
runtime + warn +
|
|
1480
1569
|
`Log: ${logPathFor(id)}`,
|
|
1481
1570
|
);
|
|
@@ -1518,7 +1607,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1518
1607
|
thinking: Type.Optional(Type.String({ description: "Reasoning effort applied to every job: off, minimal, low, medium, high, xhigh, or max." })),
|
|
1519
1608
|
tools: Type.Optional(Type.String({ description: "Tool allowlist applied to every job." })),
|
|
1520
1609
|
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist applied to every job." })),
|
|
1521
|
-
sandbox: Type.Optional(Type.Boolean({ description: "
|
|
1610
|
+
sandbox: Type.Optional(Type.Boolean({ description: "Use the human Subagents profile; false cannot override an enabled profile. Legacy default is write confinement." })),
|
|
1522
1611
|
sandbox_dir: Type.Optional(Type.String({ description: "Writable root for every job." })),
|
|
1523
1612
|
callback: Type.Optional(Type.Boolean({ description: "Default TRUE: post result back on completion." })),
|
|
1524
1613
|
clean: Type.Optional(Type.Boolean({ description: "Hermetic builtins-only child; no extensions load." })),
|
|
@@ -1810,7 +1899,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1810
1899
|
// Resume supervision reconciliation + durable health-callback recovery
|
|
1811
1900
|
// across /reload while current-parent work still needs the ticker
|
|
1812
1901
|
// (running/orphaned, or unmarked lost); it stops itself when idle.
|
|
1813
|
-
if (needsMonitoring(listMetasForParent(process.pid))) ensureHealthTicker();
|
|
1902
|
+
if (needsMonitoring(listMetasForParent(process.pid)) || hasPendingFailureCallbacks()) ensureHealthTicker();
|
|
1814
1903
|
});
|
|
1815
1904
|
|
|
1816
1905
|
pi.on("session_before_switch", () => {
|
package/list.mjs
CHANGED
|
@@ -74,10 +74,11 @@ export function formatSubagentListRow(meta, p) {
|
|
|
74
74
|
const name = meta.name ? `${meta.name} ` : "";
|
|
75
75
|
const stat = `${elapsed}${spend ? ` · ${spend}` : ""}`;
|
|
76
76
|
const health = formatListHealthSuffix(p.health);
|
|
77
|
+
const failure = p.failure ? `\n ${p.failure.replace(/\n/g, "\n ")}` : "";
|
|
77
78
|
const batch = meta.batchId
|
|
78
79
|
? ` [batch: ${meta.batchName ? `${meta.batchName} ` : ""}${meta.batchId}]`
|
|
79
80
|
: "";
|
|
80
|
-
return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${stat}${health}${batch}\n ${promptPreview(meta)}`;
|
|
81
|
+
return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${stat}${health}${batch}${failure}\n ${promptPreview(meta)}`;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
export function buildSubagentList(p) {
|
|
@@ -87,6 +88,7 @@ export function buildSubagentList(p) {
|
|
|
87
88
|
const statusOf = p.statusOf ?? ((meta) => meta.status);
|
|
88
89
|
const usageById = p.usageById ?? (() => undefined);
|
|
89
90
|
const healthById = p.healthById ?? (() => undefined);
|
|
91
|
+
const failureById = p.failureById ?? (() => "");
|
|
90
92
|
|
|
91
93
|
const scoped = (p.metas ?? [])
|
|
92
94
|
.filter((meta) => options.all || meta.spawnPid === parentPid)
|
|
@@ -110,6 +112,7 @@ export function buildSubagentList(p) {
|
|
|
110
112
|
now,
|
|
111
113
|
usage: usageById(row.meta.id),
|
|
112
114
|
health: healthById(row.meta.id),
|
|
115
|
+
failure: failureById(row.meta.id),
|
|
113
116
|
})));
|
|
114
117
|
|
|
115
118
|
if (matching.length > displayed.length) {
|