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 +1 -1
- package/child-incidents.ts +2 -2
- package/docs/failure-observations.md +1 -1
- package/failures.ts +48 -6
- package/incident-model.ts +11 -4
- package/index.ts +47 -18
- package/output-payload.ts +2 -3
- package/package.json +1 -1
- package/permission-policy.ts +9 -3
- package/registry.ts +19 -2
- package/shared-failure-observations.ts +48 -12
- package/shared-log-utils.ts +6 -12
- package/shared-navigator.ts +169 -125
- package/shared-sandbox-core.ts +78 -14
- package/shared-task-apply-patch.ts +409 -0
- package/shared-task-files.ts +69 -7
- package/shared-task-sandbox.ts +11 -0
- package/shared-task-tools.ts +111 -0
- package/subagent-tools.ts +95 -0
- package/task-guard.ts +42 -7
- package/task-policy.ts +28 -0
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).
|
|
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
|
|
package/child-incidents.ts
CHANGED
|
@@ -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:
|
|
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 {
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
242
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
1136
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
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: ${
|
|
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
|
-
//
|
|
1691
|
-
|
|
1692
|
-
|
|
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
|
-
:
|
|
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 = (
|
|
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) ??
|
|
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)
|
|
416
|
+
return positiveInt(request.lines);
|
|
418
417
|
}
|
|
419
418
|
|
|
420
419
|
/**
|
package/package.json
CHANGED
package/permission-policy.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
}
|
package/shared-log-utils.ts
CHANGED
|
@@ -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.
|
|
829
|
-
*
|
|
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
|
|
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
|
-
|
|
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) {
|