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 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 and result retrieval across reloads.
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`. It is the same shared mechanism [`pi-better-sandbox`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme) applies to Pi's foreground tools: writes only, reads and network untouched. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox).
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) return "subagent metadata is unavailable";
358
+ if (!current) throw new Error("Subagent metadata is unavailable; defer completion");
352
359
  return callbackSuppressionReason(current);
353
360
  },
354
- onDelivered: (at) => markCompletionCallbackSent(id, 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
- return current ? isHealthCallbackHandled(current, status) : true;
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) return "subagent metadata is unavailable";
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
- // Stop existing the moment nothing current-parent needs monitoring/recovery.
616
- if (!needsMonitoring(listMetasForParent(process.pid))) stopHealthTicker();
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 = p.sandbox !== false; // default on
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", sessionsDir(),
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 (writes confined to its working dir, reads and network open) and triggers completion here on finish. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
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: "Default TRUE (macOS): kernel-confine the child's file WRITES to its working dir — reads and network stay open, but it cannot write outside, whatever it runs. Set false to allow writes anywhere." })),
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: writes confined to ${sandboxDir}\n` : "") +
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: "Default TRUE: kernel-confine writes to the working dir." })),
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) {