pi-better-subagents 0.6.0 → 0.7.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
@@ -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
 
@@ -28,10 +28,10 @@ const intentProperties = {
28
28
  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
29
  attemptId: { ...nullable({ type: "string", pattern: INTENT_ID_PATTERN }),
30
30
  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 }),
31
+ expectedExitCodes: { ...nullable({ type: "array", items: { type: "integer", minimum: 0, maximum: 255 }, minItems: 1, maxItems: MAX_EXPECTED_EXIT_CODES }),
32
32
  // Distinctness is enforced by readCommandIntent, not `uniqueItems`: OpenAI rejects that keyword
33
33
  // 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." },
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). 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
35
  };
36
36
 
37
37
  /**
@@ -49,7 +49,7 @@ Sandboxed children (the task runtime) get two structured additions. The parent h
49
49
 
50
50
  - `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`, validated before the command runs.
51
51
  - `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.
52
+ - `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
53
  - `failure_disposition({ disposition, targets, reason, evidence? })` records an explicit classification of the child's own incidents:
54
54
  - `recovered`: the same operation later passed. Evidence must be a successful attempt of that operation that started after the failure.
55
55
  - `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,27 @@ 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: `run-metadata-readable:${gap.id}`, operation: "run-metadata", kind: "recovered", incidents: [gap.id] }]);
103
+ scan.gap = undefined;
104
+ }
77
105
  /** Scan complete source records, independent of the finite progress/transcript tail. */
78
106
  export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
79
107
  const path = failurePath(id);
@@ -127,9 +155,13 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
127
155
  } else {
128
156
  trustUnknownSince.delete(id);
129
157
  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] }]);
158
+ // The gap is settled after this scan has folded the log (settleMetadataGap), and again after
159
+ // every later scan, once no leftover of the exact-rule scan that trust would have read
160
+ // differently is still unresolved (#332).
161
+ const prior = readRunFailures(id);
162
+ const deferred = prior.observations[failureIdentity("run-metadata")];
163
+ if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id,
164
+ before: new Set(Object.values(prior.observations).filter((x) => x.category === "tool" && x.status === "unresolved").map((x) => x.id)) };
133
165
  }
134
166
  }
135
167
  scan.head = head;
@@ -178,6 +210,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
178
210
  position += count;
179
211
  }
180
212
  scan.offset = start;
213
+ if (scan.gap) settleMetadataGap(id, scan);
181
214
  scans.delete(id);
182
215
  scans.set(id, scan);
183
216
  while (scans.size > 64) scans.delete(scans.keys().next().value!);
@@ -200,7 +233,16 @@ export function runFailureFacts(state: FailureState, terminal: boolean): string
200
233
  return formatTerminalFailureFacts(state, activeFailures(state).map((x) => x.id));
201
234
  }
202
235
  export function failureSummary(id: string, cwd: string, terminal = false): string {
203
- return runFailureFacts(collectRunFailures(id, cwd, terminal), terminal);
236
+ return failureView(id, cwd, terminal).text;
237
+ }
238
+ /**
239
+ * The failure facts plus whether anything needs action. A navigator row leads with failure text only
240
+ * when something does; a quiet "No failures need action · … (history)" line never displaces the
241
+ * row's model, tool, and spend columns (#332).
242
+ */
243
+ export function failureView(id: string, cwd: string, terminal = false): { text: string; actionable: boolean } {
244
+ const state = collectRunFailures(id, cwd, terminal);
245
+ return { text: runFailureFacts(state, terminal), actionable: actionableFailures(state).length > 0 };
204
246
  }
205
247
  export function prependFailureSummary(body: string, summary: string): string {
206
248
  if (!summary) return body;
package/incident-model.ts CHANGED
@@ -202,7 +202,9 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
202
202
  const seq = ++model.sequence;
203
203
  if (!model.structuredIntent) {
204
204
  // 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 });
205
+ // An explicit-null intent-named field is still normalized away, as on the confined path, so
206
+ // `{command, expectedExitCodes: null}` and `{command}` are one command (#332).
207
+ model.open.set(toolCallId, { toolCallId, toolName, operation: toolOperation(toolName, withoutAbsentIntent(row.args), cwd), intent: {}, startSequence: seq });
206
208
  return;
207
209
  }
208
210
  const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
@@ -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
@@ -74,7 +74,6 @@ export interface PayloadRequest {
74
74
  mode?: unknown;
75
75
  all?: unknown;
76
76
  lines?: unknown;
77
- tail_lines?: unknown;
78
77
  limit?: unknown;
79
78
  /** Explicit opt-in facts: `cost` (token/cost spend) and/or `tools` (tool-call count). */
80
79
  include?: unknown;
@@ -409,12 +408,12 @@ function answerReserve(maxBytes: number, cursor: string | undefined): number {
409
408
  }
410
409
 
411
410
  function outputLineCap(request: PayloadRequest): number {
412
- return positiveInt(request.lines) ?? positiveInt(request.tail_lines) ?? OUTPUT_PAGE_DEFAULTS.logLines;
411
+ return positiveInt(request.lines) ?? OUTPUT_PAGE_DEFAULTS.logLines;
413
412
  }
414
413
 
415
414
  /** Answer-page line cap for subagent_result: only an explicit `lines` request caps it. */
416
415
  function answerLineCap(request: PayloadRequest): number | undefined {
417
- return positiveInt(request.lines) ?? positiveInt(request.tail_lines);
416
+ return positiveInt(request.lines);
418
417
  }
419
418
 
420
419
  /**
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.0",
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
@@ -104,24 +104,60 @@ export interface CommandIntent {
104
104
  export type CommandIntentField = keyof CommandIntent;
105
105
  const INTENT_FIELDS: readonly CommandIntentField[] = ["operationId", "attemptId", "expectedExitCodes"];
106
106
  /**
107
- * The one normalization of intent-bearing arguments: an explicit `null` intent field means
108
- * "not declared", exactly like an omitted one. Models routinely send optional fields as null, and
109
- * Pi's argument validation (0.87+) drops optional nulls before a tool runs, while the process log
110
- * and session record keep the raw arguments. Every reader of intent, the executing tool and the
111
- * replay alike, goes through this so both sides see the same declaration from the same input.
107
+ * Pi's own argument coercion for the intent fields (pi-ai `validateToolArguments` against the confined
108
+ * bash schema, where each field is `anyOf: [field schema, null]`). Pi applies it before execute, but the
109
+ * process log and session record keep the raw arguments; mirroring it lets the parent's replay read
110
+ * the declaration the child executed with (#332).
111
+ * - ids: a number or boolean becomes its string; `""` fails the pattern and falls through to null.
112
+ * - exit codes: array items that read as integers become integers (null -> 0, true -> 1);
113
+ * a scalar `0`, `""`, or `false` falls through to null.
114
+ */
115
+ function coerceInteger(value: unknown): unknown {
116
+ if (value === null) return 0;
117
+ if (typeof value === "string" && value.trim() !== "" && Number.isInteger(Number(value))) return Number(value);
118
+ if (typeof value === "boolean") return value ? 1 : 0;
119
+ return value;
120
+ }
121
+ function normalizeIntentField(key: CommandIntentField, value: unknown): unknown {
122
+ if (value == null) return undefined;
123
+ if (key === "expectedExitCodes") {
124
+ if (value === 0 || value === "" || value === false) return undefined;
125
+ if (!Array.isArray(value)) return value;
126
+ // 0 is a no-op declaration: exit 0 is already success. A list of only zeros declares nothing.
127
+ const codes = value.map(coerceInteger).filter((code) => code !== 0);
128
+ if (codes.length === 0 && value.length > 0) return undefined;
129
+ return codes.length === value.length && codes.every((code, index) => code === value[index]) ? value : codes;
130
+ }
131
+ if (value === "") return undefined;
132
+ return typeof value === "number" || typeof value === "boolean" ? String(value) : value;
133
+ }
134
+ /**
135
+ * The one normalization of intent-bearing arguments. An explicit `null` intent field means "not
136
+ * declared", exactly like an omitted one: models routinely send optional fields as null, and Pi's
137
+ * argument validation drops or nulls them before a tool runs, while the process log and session
138
+ * record keep the raw arguments. Values Pi would coerce are coerced the same way, and `0` is dropped
139
+ * from `expectedExitCodes`. Every reader of intent, the executing tool and the replay alike, and the
140
+ * exact operation identity go through this, so all see the same declaration from the same input.
141
+ * Malformed values are kept as they are for the validator to refuse.
112
142
  */
113
143
  export function withoutAbsentIntent<T>(args: T): T {
114
144
  if (!args || typeof args !== "object" || Array.isArray(args)) return args;
115
145
  const input = args as Record<string, unknown>;
116
- if (!INTENT_FIELDS.some((key) => key in input && input[key] == null)) return args;
117
- const out = { ...input };
118
- for (const key of INTENT_FIELDS) if (key in out && out[key] == null) delete out[key];
119
- return out as T;
146
+ let out: Record<string, unknown> | undefined;
147
+ for (const key of INTENT_FIELDS) {
148
+ if (!(key in input)) continue;
149
+ const value = normalizeIntentField(key, input[key]);
150
+ if (value === input[key]) continue;
151
+ out ??= { ...input };
152
+ if (value === undefined) delete out[key];
153
+ else out[key] = value;
154
+ }
155
+ return (out ?? args) as T;
120
156
  }
121
157
  /**
122
- * Validate structured intent fields. Absent fields (omitted, undefined, or null) are fine; malformed
158
+ * Validate structured intent fields after `withoutAbsentIntent`. Absent fields are fine; malformed
123
159
  * ones are an error, and the caller must not run the command. `names` renames fields in the error
124
- * (e.g. snake_case parameters).
160
+ * (e.g. snake_case parameters). `0` in `expectedExitCodes` is accepted and dropped (#332).
125
161
  */
126
162
  export function readCommandIntent(args: unknown, names: Partial<Record<CommandIntentField, string>> = {}): { intent: CommandIntent; error?: string } {
127
163
  const input = withoutAbsentIntent((args && typeof args === "object" ? args : {}) as Record<string, unknown>);
@@ -138,7 +174,7 @@ export function readCommandIntent(args: unknown, names: Partial<Record<CommandIn
138
174
  if (codes !== undefined) {
139
175
  if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
140
176
  !codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
141
- return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255` };
177
+ return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255 (0 is allowed and ignored)` };
142
178
  }
143
179
  intent.expectedExitCodes = [...codes] as number[];
144
180
  }
@@ -819,29 +819,22 @@ export interface OutputControls {
819
819
  maxBytes?: unknown;
820
820
  /** Raw requested line count (canonical `lines`, else deprecated `tail_lines`). */
821
821
  lines?: unknown;
822
- /** Deprecated alias names the caller used (whether or not the canonical name won). */
823
- deprecated: string[];
824
822
  }
825
823
 
826
824
  /**
827
825
  * Resolve output-control parameters. Both spellings work; when both are given
828
- * the canonical name wins. Values are returned unvalidated so each surface
829
- * keeps its own defaults and hard caps.
826
+ * the canonical name wins. An explicit `null` is "not given", so a null
827
+ * canonical value never hides an alias value (#332). Values are returned
828
+ * unvalidated so each surface keeps its own defaults and hard caps.
830
829
  */
831
830
  export function readOutputControls(params: unknown): OutputControls {
832
831
  const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
833
- const deprecated: string[] = [];
834
- const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => {
835
- const alias = OUTPUT_CONTROL_ALIASES[canonical];
836
- if (p[alias] !== undefined) deprecated.push(alias);
837
- return p[canonical] !== undefined ? p[canonical] : p[alias];
838
- };
832
+ const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => p[canonical] ?? p[OUTPUT_CONTROL_ALIASES[canonical]] ?? undefined;
839
833
  const maxBytes = pick("max_bytes");
840
834
  const lines = pick("lines");
841
835
  return {
842
836
  ...(maxBytes !== undefined ? { maxBytes } : {}),
843
837
  ...(lines !== undefined ? { lines } : {}),
844
- deprecated,
845
838
  };
846
839
  }
847
840
 
@@ -851,7 +844,8 @@ export type OutputInclude = (typeof OUTPUT_INCLUDE_VALUES)[number];
851
844
 
852
845
  /** Parse an `include` request into known values and unknown ones (both deduplicated). */
853
846
  export function readOutputInclude(value: unknown): { include: Set<OutputInclude>; unknown: string[] } {
854
- const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
847
+ // Any other non-null value (a number, boolean, or object) is one unknown entry, never silently nothing (#332).
848
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : value == null ? [] : [value];
855
849
  const include = new Set<OutputInclude>();
856
850
  const unknown: string[] = [];
857
851
  for (const entry of raw) {