pi-better-subagents 0.6.0 → 0.7.1

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
@@ -30,7 +30,7 @@ Try it for one run:
30
30
  pi -e npm:pi-better-subagents
31
31
  ```
32
32
 
33
- Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or newer. `/sandbox` controls the independent Subagents profile, which each launch freezes. Pi handles its own startup, authentication, and provider connection; task tools obey the selected file, command, and network permissions. Outside project defaults to Write: tasks write across home and temp but can remove files outside the workspace only in temp, hidden home directories, and worktree folders (Linux keeps ordinary home folders read-only instead). Currently confined children admit `read`, `write`, `edit`, and `bash`; unsupported requested tools are reported as unavailable. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox) for the runtime boundary and supported configurations.
33
+ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or newer. `/sandbox` controls the independent Subagents profile, which each launch freezes. Pi handles its own startup, authentication, and provider connection; task tools obey the selected file, command, and network permissions. Outside project defaults to Write: tasks write across home and temp but can remove files outside the workspace only in temp, hidden home directories, and worktree folders (Linux keeps ordinary home folders read-only instead). Confined children admit `read`, `write`, `edit`, and `bash`, the guarded `apply_patch` (Codex patches that follow the file rules), and the trusted tools ticked in `/sandbox` → Subagents · Tools (default `web_fetch` and `web_search`), which run outside the file rules. Other requested tools are reported as unavailable, with the reason. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox) for the runtime boundary and supported configurations.
34
34
 
35
35
  ## Delegation Modes
36
36
 
@@ -11,9 +11,11 @@
11
11
  */
12
12
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
13
13
  import type { BashOperations, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
- import { emptyFailureState, reduceFailure, type FailureEvent, type FailureState } from "./shared-failure-observations.ts";
15
- import { DISPOSITION_TOOL, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, describeOpenTargets, foldToolEnd, foldToolStart,
16
- newIncidentModel, readCommandIntent, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
14
+ // The intent validator is shared with background tasks through the vendored failure-observations module (#325).
15
+ import { emptyFailureState, reduceFailure, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent,
16
+ type FailureEvent, type FailureState } from "./shared-failure-observations.ts";
17
+ import { DISPOSITION_TOOL, describeOpenTargets, foldToolEnd, foldToolStart,
18
+ newIncidentModel, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
17
19
 
18
20
  const { createBashToolDefinition } = PiCodingAgent;
19
21
 
@@ -28,10 +30,10 @@ const intentProperties = {
28
30
  description: "Optional stable id for one logical operation. Reuse it on a modified retry (changed scope, timeout, or flags) so a later success recovers the earlier failure." },
29
31
  attemptId: { ...nullable({ type: "string", pattern: INTENT_ID_PATTERN }),
30
32
  description: "Optional unique id for this execution, for use as evidence in failure_disposition." },
31
- expectedExitCodes: { ...nullable({ type: "array", items: { type: "integer", minimum: 1, maximum: 255 }, minItems: 1, maxItems: MAX_EXPECTED_EXIT_CODES }),
33
+ expectedExitCodes: { ...nullable({ type: "array", items: { type: "integer", minimum: 0, maximum: 255 }, minItems: 1, maxItems: MAX_EXPECTED_EXIT_CODES }),
32
34
  // Distinctness is enforced by readCommandIntent, not `uniqueItems`: OpenAI rejects that keyword
33
35
  // in function schemas, which failed every confined child's first request.
34
- description: "Optional distinct non-zero exit codes that are intentional for this command (for example [1] for an rg/grep no-match or git diff --exit-code probe). Declared before the command runs; the final shell exit code is what is classified." },
36
+ description: "Optional distinct non-zero exit codes that are intentional for this command (for example [1] for an rg/grep no-match or git diff --exit-code probe). 0 is allowed and ignored: exit 0 is already success. Declared before the command runs; the final shell exit code is what is classified." },
35
37
  };
36
38
 
37
39
  /**
@@ -95,7 +97,7 @@ export function intentBashDefinition(cwd: string, operations: BashOperations) {
95
97
  if (error) throw new Error(`Invalid command intent: ${error}. The command was not run.`);
96
98
  if (intent.attemptId) {
97
99
  const reused = sessionToolCalls(ctx).some((row) => row.type === "tool_execution_start" && row.toolName === "bash" &&
98
- row.toolCallId !== toolCallId && (row.args as any)?.attemptId === intent.attemptId);
100
+ row.toolCallId !== toolCallId && (withoutAbsentIntent(row.args) as any)?.attemptId === intent.attemptId);
99
101
  if (reused) throw new Error(`Invalid command intent: attemptId ${intent.attemptId} was already used. The command was not run.`);
100
102
  }
101
103
  const { operationId: _o, attemptId: _a, expectedExitCodes: _e, ...command } = params ?? {};
@@ -47,9 +47,11 @@ Automatic recovery still requires an exact retry: the same tool name, arguments,
47
47
 
48
48
  Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs it launched on the task runtime: `meta.taskRuntime` and a parent-authored provenance record written before the child starts, outside the run directory, where the confined child cannot write. For any other run, intent-looking arguments are ordinary arguments and `failure_disposition` calls are ignored. If the metadata cannot be read for a moment, the parent waits and reads it again rather than falling back to the exact rule for the rest of the run. The wait is bounded: when the run ends, or after 30 seconds, the log is scanned under the exact-retry rule so real failures stay visible, with an `Observation incomplete` note that the metadata could not be read.
49
49
 
50
+ Trust is decided once per scan, when the scan begins. A running trusted run whose metadata later becomes unreadable (a corrupt `meta.json`, say) keeps its trusted scan for as long as the parent keeps that scan in memory. Only after a parent restart, a `/reload`, or an eviction from the scan cache does the bounded wait apply again, and the scan after it uses the exact-retry rule with the same note. When the metadata is readable again, a later trusted scan closes the note once the incidents that the exact-rule scan left unresolved are resolved. Failures from the trusted period never hold it open, however often the scan is rebuilt.
51
+
50
52
  - `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`, validated before the command runs.
51
53
  - `operationId` names one logical operation across modified retries. A later success with the same `operationId` (a changed scope, timeout, or flag) recovers the earlier failure automatically. Without it, the exact rule applies. `attemptId` names one execution for use as evidence and never changes operation identity.
52
- - `expectedExitCodes` (distinct integers 1–255) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
54
+ - `expectedExitCodes` (distinct integers 1–255; a `0` is ignored, since exit 0 is already success) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
53
55
  - `failure_disposition({ disposition, targets, reason, evidence? })` records an explicit classification of the child's own incidents:
54
56
  - `recovered`: the same operation later passed. Evidence must be a successful attempt of that operation that started after the failure.
55
57
  - `superseded`: a different verification or remediation established the outcome, such as resolving a merge conflict and continuing. Evidence must be a successful attempt that started after the failure.
package/failures.ts CHANGED
@@ -1,14 +1,21 @@
1
1
  import { closeSync, fstatSync, openSync, readSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { logPathFor, runDir, taskRuntimeTrust } from "./registry.ts";
4
- import { activeFailures, disposeIncidents, failureIdentity, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
4
+ import { activeFailures, actionableFailures, disposeIncidents, failureIdentity, findIncident, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
5
5
  observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
6
6
  import { evidenceText, foldToolEnd, foldToolStart, newIncidentModel, toolOperation, type IncidentModel, type IncidentSink } from "./incident-model.ts";
7
7
 
8
8
  export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered, toolOperation };
9
9
  export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
10
10
  export const readRunFailures = (id: string): FailureState => readFailureState(failurePath(id));
11
- interface Scan { offset: number; head: string; identity: string; model: IncidentModel; retry?: number }
11
+ interface Scan {
12
+ offset: number; head: string; identity: string; model: IncidentModel; retry?: number;
13
+ /**
14
+ * A "run metadata could not be read" gap this trusted rescan inherited, with the tool incidents
15
+ * that were unresolved when it began (the earlier exact-rule scan's). Re-checked after every scan.
16
+ */
17
+ gap?: { id: string; before: Set<string> };
18
+ }
12
19
  const scans = new Map<string, Scan>();
13
20
  /** When each run's trust first could not be read, for the bounded wait (#325). */
14
21
  const trustUnknownSince = new Map<string, number>();
@@ -74,6 +81,73 @@ function fold(id: string, scan: Scan, row: any, offset: number, cwd: string): vo
74
81
  ++model.sequence;
75
82
  }
76
83
  }
84
+ /**
85
+ * Close an inherited metadata gap unless an exact-rule leftover still needs it. A leftover counts only
86
+ * when its attempt declared intent (operationId, attemptId, or expectedExitCodes): its event id is
87
+ * already consumed under the exact rule, so this trusted scan cannot re-key it, and a declared retry
88
+ * or expected exit cannot resolve it. A plain failure reads the same under either rule and never
89
+ * holds the gap. Once the leftover is resolved (an exact retry or a disposition), the gap closes.
90
+ */
91
+ function settleMetadataGap(id: string, scan: Scan): void {
92
+ const gap = scan.gap!;
93
+ const state = readRunFailures(id);
94
+ const current = state.observations[failureIdentity("run-metadata")];
95
+ if (!current || current.id !== gap.id || current.status !== "unresolved") { scan.gap = undefined; return; }
96
+ const intentBearing = new Set<string>();
97
+ for (const attempt of scan.model.finished.values()) {
98
+ if (attempt.incident && gap.before.has(attempt.incident) && Object.keys(attempt.intent).length) intentBearing.add(attempt.incident);
99
+ }
100
+ const held = [...intentBearing].some((incident) => findIncident(state, incident)?.status === "unresolved");
101
+ if (held) return;
102
+ observeFailures(failurePath(id), [{ id: failureIdentity("run-metadata-readable", gap.id), operation: "run-metadata", kind: "recovered", incidents: [gap.id] }]);
103
+ scan.gap = undefined;
104
+ }
105
+ /**
106
+ * Scan-period markers (#332). While a "run metadata could not be read" gap is open, each scan that
107
+ * starts under a different rule from the last marked one journals a no-op marker (a `delivered`
108
+ * event naming no incident) whose id records the rule: `run-metadata-scan:<rule>:<journal length>`.
109
+ * The ids are read back by prefix, so they are plain strings, not `failureIdentity` hashes.
110
+ */
111
+ const SCAN_MARKER_PREFIX = "run-metadata-scan:";
112
+ type ScanRule = "exact" | "trusted";
113
+ function scanRuleAt(marker: string): ScanRule | undefined {
114
+ if (!marker.startsWith(SCAN_MARKER_PREFIX)) return undefined;
115
+ const rule = marker.slice(SCAN_MARKER_PREFIX.length).split(":")[0];
116
+ return rule === "exact" || rule === "trusted" ? rule : undefined;
117
+ }
118
+ /** Journal that a scan under `rule` begins, unless the last marked period already used it. */
119
+ function markScanPeriod(path: string, state: FailureState, rule: ScanRule): FailureState {
120
+ let last: ScanRule | undefined;
121
+ for (let i = state.seen.length - 1; i >= 0 && !last; i -= 1) last = scanRuleAt(state.seen[i]!);
122
+ if (last === rule) return state;
123
+ return observeFailures(path, [{ id: `${SCAN_MARKER_PREFIX}${rule}:${state.seen.length}`, operation: "run-metadata", kind: "delivered", incidents: [] }]);
124
+ }
125
+ /**
126
+ * The unresolved tool incidents first journaled by an exact-rule scan, which a trusted scan cannot
127
+ * re-key. Each incident belongs to the period of the last marker before it, so a failure from a
128
+ * trusted period is never mistaken for a leftover, however often the scan is rebuilt (a parent
129
+ * restart, `/reload`, or cache eviction), and a later exact-rule period's failures always are. An
130
+ * incident before every marker is a leftover only when the journal has no exact-rule marker at all
131
+ * (the first trusted scan of a gap, or a journal from before the markers existed).
132
+ */
133
+ function exactRuleLeftovers(path: string, prior: FailureState): Set<string> {
134
+ const state = markScanPeriod(path, prior, "trusted");
135
+ const period = new Map<string, ScanRule | undefined>();
136
+ let current: ScanRule | undefined;
137
+ let sawExact = false;
138
+ for (const id of state.seen) {
139
+ const rule = scanRuleAt(id);
140
+ if (rule) { current = rule; sawExact ||= rule === "exact"; continue; }
141
+ period.set(id, current);
142
+ }
143
+ const leftover = (id: string) => {
144
+ if (!period.has(id)) return false;
145
+ const rule = period.get(id);
146
+ return rule === "exact" || (rule === undefined && !sawExact);
147
+ };
148
+ return new Set(Object.values(state.observations)
149
+ .filter((x) => x.category === "tool" && x.status === "unresolved" && leftover(x.id)).map((x) => x.id));
150
+ }
77
151
  /** Scan complete source records, independent of the finite progress/transcript tail. */
78
152
  export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
79
153
  const path = failurePath(id);
@@ -123,13 +197,17 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
123
197
  summary: "Run metadata could not be read; tool failures are scanned under the exact-retry rule" }]);
124
198
  }
125
199
  if (!waited) return prior;
200
+ markScanPeriod(path, readRunFailures(id), "exact");
126
201
  scan = { offset: 0, head, identity, model: newIncidentModel(false) };
127
202
  } else {
128
203
  trustUnknownSince.delete(id);
129
204
  scan = { offset: 0, head, identity, model: newIncidentModel(trust === "trusted") };
130
- const deferred = readRunFailures(id).observations[failureIdentity("run-metadata")];
131
- if (deferred && deferred.status === "unresolved") observeFailures(path, [{ id: `run-metadata-readable:${deferred.id}`,
132
- operation: "run-metadata", kind: "recovered", incidents: [deferred.id] }]);
205
+ // The gap is settled after this scan has folded the log (settleMetadataGap), and again after
206
+ // every later scan, once no leftover of the exact-rule scan that trust would have read
207
+ // differently is still unresolved (#332).
208
+ const prior = readRunFailures(id);
209
+ const deferred = prior.observations[failureIdentity("run-metadata")];
210
+ if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id, before: exactRuleLeftovers(path, prior) };
133
211
  }
134
212
  }
135
213
  scan.head = head;
@@ -178,6 +256,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
178
256
  position += count;
179
257
  }
180
258
  scan.offset = start;
259
+ if (scan.gap) settleMetadataGap(id, scan);
181
260
  scans.delete(id);
182
261
  scans.set(id, scan);
183
262
  while (scans.size > 64) scans.delete(scans.keys().next().value!);
@@ -200,7 +279,16 @@ export function runFailureFacts(state: FailureState, terminal: boolean): string
200
279
  return formatTerminalFailureFacts(state, activeFailures(state).map((x) => x.id));
201
280
  }
202
281
  export function failureSummary(id: string, cwd: string, terminal = false): string {
203
- return runFailureFacts(collectRunFailures(id, cwd, terminal), terminal);
282
+ return failureView(id, cwd, terminal).text;
283
+ }
284
+ /**
285
+ * The failure facts plus whether anything needs action. A navigator row leads with failure text only
286
+ * when something does; a quiet "No failures need action · … (history)" line never displaces the
287
+ * row's model, tool, and spend columns (#332).
288
+ */
289
+ export function failureView(id: string, cwd: string, terminal = false): { text: string; actionable: boolean } {
290
+ const state = collectRunFailures(id, cwd, terminal);
291
+ return { text: runFailureFacts(state, terminal), actionable: actionableFailures(state).length > 0 };
204
292
  }
205
293
  export function prependFailureSummary(body: string, summary: string): string {
206
294
  if (!summary) return body;
package/incident-model.ts CHANGED
@@ -8,12 +8,10 @@
8
8
  * `operationId` or the exact tool/arguments/cwd hash; expected exits require a code
9
9
  * declared on the attempt before it ran and a structured exit code from the tool.
10
10
  */
11
- import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES,
11
+ import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS,
12
12
  REJECTED_INTENT_CATEGORY, readCommandIntent, withoutAbsentIntent, type CommandIntent, type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
13
13
 
14
14
  export const DISPOSITION_TOOL = "failure_disposition";
15
- // The intent validator is shared with background tasks through the vendored failure-observations module (#325).
16
- export { INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent, type CommandIntent };
17
15
 
18
16
  function stable(value: unknown): unknown {
19
17
  if (Array.isArray(value)) return value.map(stable);
@@ -202,15 +200,19 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
202
200
  const seq = ++model.sequence;
203
201
  if (!model.structuredIntent) {
204
202
  // Unconfined: intent-looking fields are ordinary arguments; exact identity only.
205
- model.open.set(toolCallId, { toolCallId, toolName, operation: toolOperation(toolName, row.args, cwd), intent: {}, startSequence: seq });
203
+ // An explicit-null intent-named field is still normalized away, as on the confined path, so
204
+ // `{command, expectedExitCodes: null}` and `{command}` are one command (#332).
205
+ model.open.set(toolCallId, { toolCallId, toolName, operation: toolOperation(toolName, withoutAbsentIntent(row.args), cwd), intent: {}, startSequence: seq });
206
206
  return;
207
207
  }
208
208
  const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
209
209
  const intent = parsed.intent;
210
- // Mirrors the child's check: any earlier bash start that named this attemptId, valid or not.
211
- const rawAttemptId = toolName === "bash" && typeof row.args?.attemptId === "string" ? row.args.attemptId as string : undefined;
212
- const reused = rawAttemptId !== undefined && model.namedAttempts.has(rawAttemptId);
213
- if (rawAttemptId !== undefined) model.namedAttempts.add(rawAttemptId);
210
+ // Mirrors the child's check: any earlier bash start that named this attemptId, valid or not. The name
211
+ // is read after the same normalization the child applies, so `attemptId: ""` or `null` names nothing (#332).
212
+ const namedArgs = toolName === "bash" ? withoutAbsentIntent(row.args) : undefined;
213
+ const namedAttemptId = typeof namedArgs?.attemptId === "string" ? namedArgs.attemptId as string : undefined;
214
+ const reused = namedAttemptId !== undefined && model.namedAttempts.has(namedAttemptId);
215
+ if (namedAttemptId !== undefined) model.namedAttempts.add(namedAttemptId);
214
216
  const predictedRejection = parsed.error ?? (reused ? `attemptId ${intent.attemptId} was already used` : undefined);
215
217
  // A predicted refusal falls back to exact identity: if the child ran the command after all, it is
216
218
  // an ordinary failure of exactly that command, never a failure of the operation it named.
@@ -238,8 +240,13 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
238
240
 
239
241
  /** The confined intent bash's own pre-run refusal (child-incidents.ts), matched as the whole result text. */
240
242
  const INTENT_REFUSAL = /^Invalid command intent: ([\s\S]+)\. The command was not run\.$/;
241
- /** Pi's pre-execution schema rejection; it names the tool, and nothing ran. */
242
- const SCHEMA_REFUSAL = /^Validation failed for tool "/;
243
+ /**
244
+ * Pi's pre-execution schema rejection of the bash call (pi-ai `validateToolArguments`), matched as its
245
+ * whole shape: the header, one or more ` - path: message` lines, and the received arguments as
246
+ * pretty-printed JSON ending the text, so a command that ran (and ends "Command exited with code N")
247
+ * can never match. Nothing ran.
248
+ */
249
+ const SCHEMA_REFUSAL = /^Validation failed for tool "bash":\n(?: - [^\n]*\n)+\nReceived arguments:\n(?:\{\}|\{(?:\n {2,}[^\n]*)*\n\})$/;
243
250
 
244
251
  /**
245
252
  * Why the child refused this bash call before running it, read from the end row the child
@@ -266,7 +273,7 @@ export function foldToolEnd(model: IncidentModel, row: any, cwd: string, evidenc
266
273
  const attempt = model.open.get(toolCallId);
267
274
  if (attempt) model.open.delete(toolCallId);
268
275
  const intent = attempt?.intent ?? {};
269
- const operation = attempt?.operation ?? toolOperation(toolName, row.args, cwd);
276
+ const operation = attempt?.operation ?? toolOperation(toolName, withoutAbsentIntent(row.args), cwd);
270
277
  const details = row.result?.details;
271
278
  const declaredExit = row.isError === false && details?.expectedExit === true && typeof details.exitCode === "number" &&
272
279
  (intent.expectedExitCodes ?? []).includes(details.exitCode) ? details.exitCode as number : undefined;
package/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { execSync } from "node:child_process";
15
- import { writeFileSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
15
+ import { writeFileSync, mkdirSync, renameSync, statSync } from "node:fs";
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
18
18
  import * as PiTui from "@earendil-works/pi-tui";
@@ -57,6 +57,7 @@ import { resolveExtensions, extensionArgs } from "./extensions.ts";
57
57
  import { prepareTaskRuntime } from "./task-policy.ts";
58
58
  import { canonicalizePath, takeRecoverySnapshot } from "./shared-sandbox-core.ts";
59
59
  import { TASK_BUILTINS } from "./shared-task-sandbox.ts";
60
+ import { APPLY_PATCH, describeTaskTools, planTaskTools } from "./subagent-tools.ts";
60
61
  import { observeSandboxPermissions, resolveSubagentPermissions } from "./permission-policy.ts";
61
62
  import { resolveSubagentWorkspace } from "./git-workspace.ts";
62
63
  import { join } from "node:path";
@@ -66,7 +67,7 @@ import {
66
67
  sessionsDir,
67
68
  runDir,
68
69
  recordTaskRuntimeProvenance,
69
- removeRunDirectory,
70
+ discardFailedLaunch,
70
71
  logPathFor,
71
72
  promptPathFor,
72
73
  nextRunId,
@@ -115,7 +116,7 @@ import {
115
116
  import { buildHealthCallbackDelivery } from "./completion.ts";
116
117
  import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
117
118
  import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
118
- import { collectRunFailures, failurePath, failureSummary, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
119
+ import { collectRunFailures, failurePath, failureView, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
119
120
  import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
120
121
  import {
121
122
  text,
@@ -1132,8 +1133,9 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
1132
1133
  if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
1133
1134
  if (row.tool) bits.push(row.tool);
1134
1135
  if (row.spend) bits.push(row.spend);
1135
- const failure = failureSummary(row.id, metaById.get(row.id)?.cwd ?? "", row.status !== "running" && row.status !== "orphaned");
1136
- const firstFailure = failure.split("\n")[0] || "";
1136
+ const failure = failureView(row.id, metaById.get(row.id)?.cwd ?? "", row.status !== "running" && row.status !== "orphaned");
1137
+ // Only a failure that needs action replaces the row's columns; history stays in the detail view.
1138
+ const firstFailure = failure.actionable ? failure.text.split("\n")[0] || "" : "";
1137
1139
  return {
1138
1140
  providerId: "subagents",
1139
1141
  id: row.id,
@@ -1197,7 +1199,8 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
1197
1199
  if (!detail) return null;
1198
1200
  void options;
1199
1201
  const transcript = readRunTranscript(id);
1200
- const failure = failureSummary(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
1202
+ const view = failureView(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
1203
+ const failure = view.text;
1201
1204
  const metadata = [
1202
1205
  ...(failure ? [{ label: "failure", value: failure.split("\n")[0]! }] : []),
1203
1206
  { label: "provider", value: "Subagents" },
@@ -1216,7 +1219,7 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
1216
1219
  title: detail.name || detail.id,
1217
1220
  status: detail.status,
1218
1221
  statusTone: statusTone(detail.status),
1219
- subtitle: failure.split("\n")[0] || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
1222
+ subtitle: (view.actionable && failure.split("\n")[0]) || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
1220
1223
  metadata,
1221
1224
  evidence: { label: "transcript", text: prependFailureSummary(detail.output || "(no transcript yet)", failure) },
1222
1225
  transcript: transcript.entries,
@@ -1587,13 +1590,15 @@ export default function (pi: ExtensionAPI) {
1587
1590
 
1588
1591
  const id = nextRunId();
1589
1592
  const childSessionDir = sandboxEnabled ? join(sessionsDir(), id) : sessionsDir();
1593
+ // The disposable clone this launch owns (removed if the spawn fails); a caller's sandbox_dir is not ours.
1594
+ const ownedCloneWorkspace = !p.sandbox_dir && sandboxEnabled && p.git_clone_workspace ? taskWorkspaceDir(id) : undefined;
1590
1595
  mkdirSync(childSessionDir, { recursive: true });
1591
1596
  mkdirSync(runDir(id), { recursive: true });
1592
1597
 
1593
1598
  const workspace = resolveSubagentWorkspace({
1594
1599
  ctxCwd: ctx.cwd,
1595
1600
  cwd: p.cwd,
1596
- sandboxDir: p.sandbox_dir ?? (sandboxEnabled && p.git_clone_workspace ? taskWorkspaceDir(id) : undefined),
1601
+ sandboxDir: p.sandbox_dir ?? ownedCloneWorkspace,
1597
1602
  gitCloneWorkspace: p.git_clone_workspace,
1598
1603
  runId: id,
1599
1604
  runDirPath: runDir(id),
@@ -1616,14 +1621,32 @@ export default function (pi: ExtensionAPI) {
1616
1621
  .filter(Boolean).join(",");
1617
1622
  }
1618
1623
 
1624
+ // A confined child gets extension tools only through /sandbox Subagents · Tools (ADR 0009):
1625
+ // the guarded apply_patch adapter, and trusted tools admitted by name and package.
1626
+ const requestedExcludes = new Set((p.exclude_tools ?? "").split(",").map((t) => t.trim()).filter(Boolean));
1627
+ if (sandboxEnabled && !clean) {
1628
+ const names = allow.split(",").filter(Boolean);
1629
+ // Models reach for apply_patch whenever they may edit; ticked trusted tools join the default set.
1630
+ if (permissionPlan.tools.applyPatch && names.some((name) => name === "edit" || name === "write")) names.push(APPLY_PATCH);
1631
+ if (p.tools === undefined) names.push(...permissionPlan.tools.trusted.map((entry) => entry.name));
1632
+ allow = [...new Set(names)].join(",");
1633
+ }
1634
+ const taskTools = sandboxEnabled ? planTaskTools({
1635
+ requested: allow.split(",").filter((name) => name && !requestedExcludes.has(name)),
1636
+ settings: permissionPlan.tools, network: permissionPlan.permissions?.network ?? true,
1637
+ builtins: TASK_BUILTINS, registered: pi.getAllTools?.() ?? [],
1638
+ toolExtensions: cfg.toolExtensions, resolvePath: resolveExtensionPath,
1639
+ }) : undefined;
1619
1640
  const resolution = resolveExtensions({
1620
1641
  tools: sandboxEnabled ? allow.split(",").filter((name) => (TASK_BUILTINS as readonly string[]).includes(name)).join(",") : allow,
1621
1642
  model, clean, allowNested: sandboxEnabled ? false : p.allow_nested, config: cfg,
1622
1643
  });
1623
1644
  const { args: resolvedExtArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
1645
+ const trustedExtArgs = [...new Set(taskTools?.trusted.map((tool) => tool.loadPath) ?? [])]
1646
+ .filter((path) => !resolvedExtArgs.includes(path)).flatMap((path) => ["--extension", path]);
1624
1647
  // The harness's own child control extension (no tools, no command I/O) rides along in every
1625
1648
  // mode, including clean: it is how a soft-deadline steer reaches the child session.
1626
- const extArgs = [...resolvedExtArgs, "--extension", childSteerExtensionPath()]
1649
+ const extArgs = [...resolvedExtArgs, ...trustedExtArgs, "--extension", childSteerExtensionPath()]
1627
1650
  .map((value, index, all) => sandboxEnabled && all[index - 1] === "--extension" ? canonicalizePath(value) : value);
1628
1651
  if (sandboxEnabled && resolution.mode === "inherit") {
1629
1652
  throw new Error("Task confinement requires explicit extensions; inheritExtensions is unsupported while sandboxing is enabled.");
@@ -1656,13 +1679,16 @@ export default function (pi: ExtensionAPI) {
1656
1679
 
1657
1680
  const piBin = resolvePiBinary();
1658
1681
  const selectedTools = allow.split(",").filter((name) => name && !excludes.has(name));
1659
- const unavailableTools = sandboxEnabled ? selectedTools.filter((name) => !(TASK_BUILTINS as readonly string[]).includes(name)) : [];
1682
+ const unavailable = taskTools?.refused.filter((tool) => selectedTools.includes(tool.name)) ?? [];
1683
+ const unavailableTools = unavailable.map((tool) => tool.name);
1660
1684
  if (sandboxEnabled && selectedTools.length && unavailableTools.length === selectedTools.length) {
1661
- throw new Error(`No requested tool has a verified task sandbox adapter: ${unavailableTools.join(", ")}.`);
1685
+ throw new Error(`No requested tool has a verified task sandbox adapter: ${unavailable.map((tool) => `${tool.name} (${tool.reason})`).join(", ")}.`);
1662
1686
  }
1663
1687
  const taskRuntime = sandboxEnabled && requestedSandboxDir ? prepareTaskRuntime({
1664
1688
  root: requestedSandboxDir, controlDir: join(runDir(id), "control"), piBin,
1665
1689
  tools: selectedTools, permissions: permissionPlan.permissions,
1690
+ applyPatch: taskTools?.applyPatch === true,
1691
+ extensionTools: taskTools?.trusted.map(({ loadPath: _loadPath, ...tool }) => tool) ?? [],
1666
1692
  extensionPaths: extArgs.flatMap((arg, index) => arg === "--extension" && extArgs[index + 1] ? [extArgs[index + 1]!] : []),
1667
1693
  runtimeRoots: [baseDir()],
1668
1694
  }) : undefined;
@@ -1686,10 +1712,10 @@ export default function (pi: ExtensionAPI) {
1686
1712
  spawned = spawnDetached({ file: cmd.file, fileArgs: cmd.fileArgs, cwd, logPath: logPathFor(id),
1687
1713
  env: { [STEER_FILE_ENV]: steerPathFor(id) } });
1688
1714
  } catch (error) {
1689
- // Nothing references the run yet (no metadata): drop its directory (prompt, control/),
1690
- // its provenance record, and the task scratch, so a failed launch leaves nothing behind (#325).
1691
- try { removeRunDirectory(id); } catch { /* best effort */ }
1692
- if (taskRuntime?.policy.scratch) rmSync(taskRuntime.policy.scratch, { recursive: true, force: true });
1715
+ // Nothing references the run yet (no metadata): drop its directory (prompt, control/), its
1716
+ // provenance record, its session directory, its clone workspace, and the task scratch, so a
1717
+ // failed launch leaves nothing behind (#325, #332).
1718
+ discardFailedLaunch(id, { sessionDir: childSessionDir, workspaceDir: ownedCloneWorkspace, scratch: taskRuntime?.policy.scratch });
1693
1719
  throw error;
1694
1720
  }
1695
1721
  // Record process identity (pgid, start-time token) so health
@@ -1726,13 +1752,16 @@ export default function (pi: ExtensionAPI) {
1726
1752
  // Footer hint: a visible run now exists, so `← background work · N` shows.
1727
1753
  updateNavigatorFooter(ctx);
1728
1754
 
1755
+ const toolSummary = taskTools ? describeTaskTools(taskTools) : undefined;
1729
1756
  const runtime = resolution.mode === "inherit"
1730
1757
  ? `Runtime: ALL installed extensions (inheritExtensions) — mid-turn drain risk\n`
1731
- : resolution.specs.length
1758
+ : toolSummary
1759
+ ? `Runtime: isolated · ${toolSummary}${resolution.specs.length ? ` · extensions ${resolution.specs.join(", ")}` : ""}\n`
1760
+ : resolution.specs.length
1732
1761
  ? `Runtime: isolated · extensions ${resolution.specs.join(", ")}\n`
1733
1762
  : `Runtime: isolated · built-in tools only\n`;
1734
- const warn = (unavailableTools.length ? `Task sandbox: unavailable adapters for ${unavailableTools.join(", ")}; these tools are disabled.\n` : "") +
1735
- (resolution.unmapped.length
1763
+ const warn = (unavailable.length ? `Task sandbox: ${unavailable.map((tool) => `${tool.name} disabled (${tool.reason})`).join("; ")}.\n` : "") +
1764
+ (!sandboxEnabled && resolution.unmapped.length
1736
1765
  ? `NOTE: no extension mapped for ${resolution.unmapped.join(", ")} — ` +
1737
1766
  `${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
1738
1767
  `Add a toolExtensions entry in config.json.\n`
package/output-payload.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  import {
49
49
  actionableFailures,
50
50
  failureJournalFingerprint,
51
+ fileChangeIdentity,
51
52
  failureRevision,
52
53
  formatIncidentSummary,
53
54
  formatTerminalIncidentSummary,
@@ -74,7 +75,6 @@ export interface PayloadRequest {
74
75
  mode?: unknown;
75
76
  all?: unknown;
76
77
  lines?: unknown;
77
- tail_lines?: unknown;
78
78
  limit?: unknown;
79
79
  /** Explicit opt-in facts: `cost` (token/cost spend) and/or `tools` (tool-call count). */
80
80
  include?: unknown;
@@ -409,12 +409,12 @@ function answerReserve(maxBytes: number, cursor: string | undefined): number {
409
409
  }
410
410
 
411
411
  function outputLineCap(request: PayloadRequest): number {
412
- return positiveInt(request.lines) ?? positiveInt(request.tail_lines) ?? OUTPUT_PAGE_DEFAULTS.logLines;
412
+ return positiveInt(request.lines) ?? OUTPUT_PAGE_DEFAULTS.logLines;
413
413
  }
414
414
 
415
415
  /** Answer-page line cap for subagent_result: only an explicit `lines` request caps it. */
416
416
  function answerLineCap(request: PayloadRequest): number | undefined {
417
- return positiveInt(request.lines) ?? positiveInt(request.tail_lines);
417
+ return positiveInt(request.lines);
418
418
  }
419
419
 
420
420
  /**
@@ -778,16 +778,7 @@ const LIST_INCIDENT_CACHE_MAX = 4096;
778
778
  let listIncidentComputations = 0;
779
779
 
780
780
  function listIncidentKey(id: string, terminal: boolean): string {
781
- return JSON.stringify([terminal, logIdentityKey(logPathFor(id)), failureJournalFingerprint(failurePath(id))]);
782
- }
783
-
784
- function logIdentityKey(path: string): string {
785
- try {
786
- const stats = statSync(path, { bigint: true });
787
- return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
788
- } catch (error) {
789
- return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
790
- }
781
+ return JSON.stringify([terminal, fileChangeIdentity(logPathFor(id)), failureJournalFingerprint(failurePath(id))]);
791
782
  }
792
783
 
793
784
  export function listIncidentCount(id: string, cwd: string, terminal: boolean): ListIncidentCount {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,4 +1,5 @@
1
1
  /** Optional sandbox-extension policy, mirrored per Pi event bus at launch time. */
2
+ import { defaultSubagentTools, parseSubagentTools, type SubagentToolSettings } from "./shared-task-tools.ts";
2
3
  export const SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
3
4
  export const SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
4
5
 
@@ -15,6 +16,7 @@ export type PermissionProfile = Readonly<{
15
16
  export type PermissionSnapshot = Readonly<{
16
17
  permissions?: PermissionProfile;
17
18
  subagentPermissions?: PermissionProfile;
19
+ subagentTools?: SubagentToolSettings;
18
20
  }>;
19
21
 
20
22
  type EventBus = {
@@ -52,6 +54,7 @@ function readPolicy(data: unknown): PermissionSnapshot | undefined {
52
54
  return Object.freeze({
53
55
  ...(p.permissions === undefined ? {} : { permissions: profile(p.permissions) }),
54
56
  ...(p.subagentPermissions === undefined ? {} : { subagentPermissions: profile(p.subagentPermissions) }),
57
+ ...(p.subagentTools === undefined ? {} : { subagentTools: parseSubagentTools(p.subagentTools) }),
55
58
  });
56
59
  }
57
60
 
@@ -87,20 +90,23 @@ export function resolveSubagentPermissions(pi: unknown, requestedSandbox: boolea
87
90
  sandboxEnabled: boolean;
88
91
  enforced: boolean;
89
92
  permissions?: Omit<PermissionProfile, "enabled">;
93
+ /** Which extension tools a confined child may use; defaults when no settings are published. */
94
+ tools: SubagentToolSettings;
90
95
  } {
91
96
  const snapshot = currentSandboxPermissions(pi);
97
+ const tools = snapshot?.subagentTools ?? defaultSubagentTools();
92
98
  if (snapshot?.permissions?.enabled && !snapshot.permissions.commands) {
93
99
  throw new Error("Main sandbox profile disables commands. Enable commands in the sandbox UI before launching a subagent.");
94
100
  }
95
101
  const child = snapshot?.subagentPermissions;
96
- if (!child) return { sandboxEnabled: requestedSandbox !== false, enforced: false };
102
+ if (!child) return { sandboxEnabled: requestedSandbox !== false, enforced: false, tools };
97
103
  if (child.enabled && requestedSandbox === false) {
98
104
  throw new Error("Subagent sandbox is enforced by the human-enabled profile; sandbox:false cannot bypass it. Change Subagents permissions in the sandbox UI.");
99
105
  }
100
106
  const sandboxEnabled = child.enabled || requestedSandbox === true;
101
- if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false };
107
+ if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false, tools };
102
108
  // Starting Pi is runtime work. These capabilities constrain task tools;
103
109
  // they do not disable provider transport or the fixed file-operation worker.
104
110
  const { enabled: _enabled, ...permissions } = child;
105
- return { sandboxEnabled: true, enforced: true, permissions };
111
+ return { sandboxEnabled: true, enforced: true, permissions, tools };
106
112
  }
package/registry.ts CHANGED
@@ -235,16 +235,33 @@ export function removeRunDirectory(id: string): void {
235
235
  rmSync(runDir(id), { recursive: true, force: true });
236
236
  removeTaskRuntimeProvenance(id);
237
237
  }
238
+ /**
239
+ * Undo everything a launch prepared before its child failed to spawn (#325, #332). Nothing references
240
+ * the run yet (no metadata), so its run directory and provenance go, plus the per-run session
241
+ * directory and disposable clone workspace of a sandboxed launch and the task scratch. Only the
242
+ * run's own paths are removed: a shared sessions directory or a caller-chosen sandbox_dir is kept.
243
+ */
244
+ export function discardFailedLaunch(id: string, prepared: { sessionDir?: string; workspaceDir?: string; scratch?: string } = {}): void {
245
+ try { removeRunDirectory(id); } catch { /* best effort */ }
246
+ const remove = (path: string) => { try { rmSync(path, { recursive: true, force: true }); } catch { /* best effort */ } };
247
+ if (prepared.sessionDir && prepared.sessionDir === join(sessionsDir(), id)) remove(prepared.sessionDir);
248
+ if (prepared.workspaceDir && /^sa_[a-z0-9]+_[a-z0-9]+$/i.test(id) && prepared.workspaceDir === taskWorkspaceDir(id)) remove(prepared.workspaceDir);
249
+ if (prepared.scratch) remove(prepared.scratch);
250
+ }
238
251
  /** Best-effort removal of a run's provenance record (e.g. after a failed launch). */
239
252
  export function removeTaskRuntimeProvenance(id: string): void {
240
253
  try { rmSync(taskRuntimeProvenancePath(id), { force: true }); } catch { /* invalid id or already gone */ }
241
254
  }
242
- /** Provenance records whose run directory no longer exists (e.g. removed by an older version). */
255
+ /**
256
+ * Provenance records whose run directory no longer exists (e.g. removed by an older version). Only a
257
+ * definite ENOENT counts as gone: any other stat error (permissions, I/O) keeps the record, since
258
+ * deleting a live run's provenance would silently drop it to the exact-retry rule (#332).
259
+ */
243
260
  export function orphanedTaskRuntimeProvenance(): string[] {
244
261
  let names: string[];
245
262
  try { names = readdirSync(join(baseDir(), "task-runtime")); } catch { return []; }
246
263
  return names.filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5))
247
- .filter((id) => { try { statSync(runDir(id)); return false; } catch { return true; } });
264
+ .filter((id) => { try { statSync(runDir(id)); return false; } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; } });
248
265
  }
249
266
  /**
250
267
  * Whether the parent may honour structured intent for this run. `unknown` means the answer could