pi-better-subagents 0.14.0 → 0.15.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/child-incidents.ts +6 -2
- package/docs/failure-observations.md +34 -0
- package/docs/usage.md +27 -2
- package/failures.ts +1 -1
- package/incident-model.ts +24 -4
- package/index.ts +31 -15
- package/package.json +1 -1
- package/shared-callback-batcher.ts +206 -15
- package/shared-failure-observations.ts +51 -6
- package/shared-permission-blocker.ts +48 -0
- package/shared-task-sandbox.ts +2 -1
- package/shared-task-tools.ts +16 -7
- package/tools.ts +32 -6
package/child-incidents.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { DISPOSITION_TOOL, describeOpenTargets, foldToolEnd, foldToolStart,
|
|
|
18
18
|
newIncidentModel, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
|
|
19
19
|
|
|
20
20
|
const { createBashToolDefinition } = PiCodingAgent;
|
|
21
|
+
import { PERMISSION_RESOURCES } from "./shared-permission-blocker.ts";
|
|
21
22
|
|
|
22
23
|
// Each intent field also admits `null`, which means "not declared" (withoutAbsentIntent). Models send
|
|
23
24
|
// optional fields as explicit null; accepting it in the schema means Pi hands execute the same raw
|
|
@@ -133,6 +134,7 @@ export function failureDispositionTool(cwd: () => string) {
|
|
|
133
134
|
"expected: the failure was intentional. open: it still needs the parent's action (this asks for attention).",
|
|
134
135
|
"targets: incident ids, the attemptId/operationId you declared on bash, or tool call ids. Unknown or already-disposed incidents are rejected.",
|
|
135
136
|
"Never claim recovery without a successful later attempt as evidence.",
|
|
137
|
+
"For open only, permissionResource optionally reports a permission blocker on the failed attempt; this is agent-reported, not a proven policy denial or remote authorization outcome.",
|
|
136
138
|
].join(" "),
|
|
137
139
|
promptSnippet: "Classify handled tool failures (recovered, superseded, expected) or flag one as open for the parent.",
|
|
138
140
|
parameters: {
|
|
@@ -141,7 +143,8 @@ export function failureDispositionTool(cwd: () => string) {
|
|
|
141
143
|
disposition: { type: "string", enum: ["recovered", "superseded", "expected", "open"] },
|
|
142
144
|
targets: { type: "array", items: { type: "string", minLength: 1, maxLength: 200 }, minItems: 1, maxItems: 20 },
|
|
143
145
|
reason: { type: "string", minLength: 1, maxLength: 400 },
|
|
144
|
-
evidence: { type: "string", minLength: 1, maxLength: 200, description: "attemptId or tool call id of the successful later attempt; required for recovered and superseded." },
|
|
146
|
+
evidence: { ...nullable({ type: "string", minLength: 1, maxLength: 200 }), description: "attemptId or tool call id of the successful later attempt; required for recovered and superseded." },
|
|
147
|
+
permissionResource: { ...nullable({ type: "string", enum: [...PERMISSION_RESOURCES] }), description: "Optional permission resource for open only. Bound to an unresolved failed worker attempt; remote outcome remains unknown." },
|
|
145
148
|
},
|
|
146
149
|
required: ["disposition", "targets", "reason"],
|
|
147
150
|
additionalProperties: false,
|
|
@@ -158,7 +161,8 @@ export function failureDispositionTool(cwd: () => string) {
|
|
|
158
161
|
const incidents = outcome.event.incidents ?? [];
|
|
159
162
|
return {
|
|
160
163
|
content: [{ type: "text" as const, text: `Recorded ${parsed.request.disposition} for ${incidents.length} incident${incidents.length === 1 ? "" : "s"}: ${incidents.join(", ")}.` }],
|
|
161
|
-
details: { disposition: parsed.request.disposition, incidents
|
|
164
|
+
details: { disposition: parsed.request.disposition, incidents,
|
|
165
|
+
...(outcome.event.permissionBlockers ? { permissionBlockers: outcome.event.permissionBlockers } : {}) },
|
|
162
166
|
};
|
|
163
167
|
},
|
|
164
168
|
};
|
|
@@ -66,6 +66,40 @@ Trust is decided once per scan, when the scan begins. A running trusted run whos
|
|
|
66
66
|
|
|
67
67
|
Unconfined children keep the exact-retry rule, and this is intentional: an unconfined child can rewrite its own log and metadata, so the parent cannot trust intent or dispositions it reports. Background tasks, which the parent agent launches itself, accept `operation_id` and `expected_exit_codes` directly (see the background-tasks README).
|
|
68
68
|
|
|
69
|
+
## Permission Blockers
|
|
70
|
+
|
|
71
|
+
A trusted worker can attach `permissionResource` to an `open` disposition of
|
|
72
|
+
an unresolved failed tool attempt. Supported categories include
|
|
73
|
+
`credential-files`, `process-inspection`, `project-files`,
|
|
74
|
+
`outside-project-files`, `runtime-control-files`, `command-execution`,
|
|
75
|
+
`network-access`, and `unknown`. For example:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"disposition": "open",
|
|
80
|
+
"targets": ["failed-attempt-id"],
|
|
81
|
+
"reason": "Local credential-cache access needs human review",
|
|
82
|
+
"permissionResource": "credential-files"
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The parent validates the failed-attempt reference and worker provenance before
|
|
87
|
+
exposing actionable reports in `subagent_result` and `subagent_output` details.
|
|
88
|
+
The report is explicitly **agent-reported**, with **remote outcome unknown**.
|
|
89
|
+
It does not prove sandbox causation, expired login, an IAM denial, a foreground
|
|
90
|
+
restriction, or successful validation. Raw `EPERM` output is not classified
|
|
91
|
+
into this contract. Failed attempts remain failed and history is retained after
|
|
92
|
+
later recovery.
|
|
93
|
+
|
|
94
|
+
Inspecting an actionable report can permission-hold the active goal. Ordinary
|
|
95
|
+
questions, unrelated completions, and settings changes do not release the hold.
|
|
96
|
+
The human's `/goal resume` or resume shortcut confirms one same-operation,
|
|
97
|
+
same-scope retry; it does not grant additional access or authorize product tests
|
|
98
|
+
or mutations. Changed worker settings require a fresh worker because running
|
|
99
|
+
workers retain their launch policy. See
|
|
100
|
+
[ADR 0012](../../../docs/adr/0012-actionable-permission-blockers.md) for the
|
|
101
|
+
transport limitations and bounded-turn recovery contract.
|
|
102
|
+
|
|
69
103
|
## Evidence health
|
|
70
104
|
|
|
71
105
|
The collector scans complete structured records independently of the finite progress tail. Missing terminal logs, unreadable records, and detected truncation are shown as **observation incomplete**. Failure classification uses structured error and exit fields; output text and domain-specific status codes are not failure signals.
|
package/docs/usage.md
CHANGED
|
@@ -67,7 +67,7 @@ is present; without this extension it uses adaptive guidance.
|
|
|
67
67
|
`subagent_spawn` starts a detached `pi -p` child and returns immediately,
|
|
68
68
|
leaving the session free for the human. When the child finishes, it sends a
|
|
69
69
|
lightweight trigger; the foreground calls `subagent_result` and presents the
|
|
70
|
-
result (as a `followUp
|
|
70
|
+
result (as a `followUp` by default; optional busy steering is described below). The foreground
|
|
71
71
|
is nudged once, at completion — never on a wait/poll loop.
|
|
72
72
|
- **Subagents are autonomous; communication is one-way (parent → child).** The
|
|
73
73
|
parent front-loads everything the child needs into the spawn; the child runs to
|
|
@@ -247,7 +247,7 @@ given.
|
|
|
247
247
|
its output is durable in a log file.
|
|
248
248
|
- **Completions trigger one batched turn that fetches durable results.** Ordinary
|
|
249
249
|
terminal callbacks stay in the harness queue while the foreground agent is
|
|
250
|
-
busy. Once Pi is idle, they accumulate for 100 ms and share one
|
|
250
|
+
busy by default. Once Pi is idle, they accumulate for 100 ms and share one
|
|
251
251
|
`pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true })` notification
|
|
252
252
|
with background-task completions from the same Pi host. Each row contains only
|
|
253
253
|
source, id, label, terminal status, and the `subagent_result` lookup. Full
|
|
@@ -260,6 +260,23 @@ given.
|
|
|
260
260
|
pending records retryable, and `/reload` recovers records explicitly marked
|
|
261
261
|
pending. `callback:false` sends no model message; read the durable result later
|
|
262
262
|
with `subagent_result`.
|
|
263
|
+
- **Optional busy steering.** In `/harness-settings`, set **Completions while
|
|
264
|
+
busy** to **Steer active run** to receive ordinary completions during
|
|
265
|
+
foreground work. Changes autosave to the current session branch and apply
|
|
266
|
+
immediately; **Ctrl+S** saves the choice as the user default for future
|
|
267
|
+
sessions, including standalone callback packages. **Wait until idle** is the
|
|
268
|
+
default. The former `PI_BETTER_CALLBACK_WHILE_BUSY` environment variable is
|
|
269
|
+
no longer supported. Completions spanning multiple accumulation windows during
|
|
270
|
+
one tool call stay together until the final active tool ends, then share one
|
|
271
|
+
compact `steer` notification without triggering a new run. Parallel and nested
|
|
272
|
+
tools are tracked together by both extensions. While busy with no active tool,
|
|
273
|
+
the normal accumulation window applies. Manual compaction and branch
|
|
274
|
+
summarization hold callbacks until an agent run starts or Pi becomes idle.
|
|
275
|
+
Idle delivery remains
|
|
276
|
+
`{ deliverAs: "followUp", triggerTurn: true }`. The same byte budget, queued overflow,
|
|
277
|
+
dedupe, delivery receipts, retry, ownership checks, and `callback:false` rules
|
|
278
|
+
apply to both modes; a completion handed off by steer is not sent again at
|
|
279
|
+
idle. Urgent health/failure alerts are unchanged.
|
|
263
280
|
- **The prompt guidelines forbid polling.** The foreground agent is told, in the
|
|
264
281
|
tool guidelines, that spawning is done and it must not loop on `output`/`result`
|
|
265
282
|
or sleep to wait.
|
|
@@ -362,6 +379,14 @@ Pi SDK 0.82.1 or newer; it is not a replacement user-facing Pi command.
|
|
|
362
379
|
|
|
363
380
|
### Git-mutating subagents and linked worktrees
|
|
364
381
|
|
|
382
|
+
Confined bash commands default to `GIT_OPTIONAL_LOCKS=0`, unless the inherited
|
|
383
|
+
environment or the call supplies a value. This keeps inventory commands such as
|
|
384
|
+
`git status` from creating an optional `index.lock` in the parent repository's
|
|
385
|
+
linked-worktree metadata. On macOS, Outside project = Write can permit creation
|
|
386
|
+
there but refuse unlink, leaving an empty lock even when status exits zero.
|
|
387
|
+
The default skips optional index refreshes; it does not disable required locks
|
|
388
|
+
for `git add` or commits, grant metadata removal, or remove existing locks.
|
|
389
|
+
|
|
365
390
|
A sandboxed subagent that will mutate Git should set **`git_clone_workspace:true`**
|
|
366
391
|
on `subagent_spawn`. The parent prepares a fresh, self-contained Git clone whose
|
|
367
392
|
`.git/` directory lives **inside the sandbox writable root**, then runs the child
|
package/failures.ts
CHANGED
|
@@ -201,7 +201,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
201
201
|
scan = { offset: 0, head, identity, model: newIncidentModel(false) };
|
|
202
202
|
} else {
|
|
203
203
|
trustUnknownSince.delete(id);
|
|
204
|
-
scan = { offset: 0, head, identity, model: newIncidentModel(trust === "trusted") };
|
|
204
|
+
scan = { offset: 0, head, identity, model: newIncidentModel(trust === "trusted", id) };
|
|
205
205
|
// The gap is settled after this scan has folded the log (settleMetadataGap), and again after
|
|
206
206
|
// every later scan, once no leftover of the exact-rule scan that trust would have read
|
|
207
207
|
// differently is still unresolved (#332).
|
package/incident-model.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
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
|
+
import { isPermissionResource, type PermissionBlocker, type PermissionResource } from "./shared-permission-blocker.ts";
|
|
13
14
|
|
|
14
15
|
export const DISPOSITION_TOOL = "failure_disposition";
|
|
15
16
|
|
|
@@ -75,6 +76,7 @@ interface Finished extends Attempt {
|
|
|
75
76
|
export interface IncidentModel {
|
|
76
77
|
/** Honour operationId / attemptId / expectedExitCodes / dispositions (confined runtime only). */
|
|
77
78
|
structuredIntent: boolean;
|
|
79
|
+
runId?: string;
|
|
78
80
|
sequence: number;
|
|
79
81
|
open: Map<string, Attempt>;
|
|
80
82
|
finished: Map<string, Finished>;
|
|
@@ -96,8 +98,8 @@ export interface IncidentModel {
|
|
|
96
98
|
* (intent bash + failure_disposition). Anywhere else the intent fields and disposition
|
|
97
99
|
* requests are ignored and the exact-retry identity rule applies.
|
|
98
100
|
*/
|
|
99
|
-
export function newIncidentModel(structuredIntent = false): IncidentModel {
|
|
100
|
-
return { structuredIntent, sequence: 0, open: new Map(), finished: new Map(), attemptIds: new Map(), namedAttempts: new Set(), operationIds: new Map(),
|
|
101
|
+
export function newIncidentModel(structuredIntent = false, runId?: string): IncidentModel {
|
|
102
|
+
return { structuredIntent, ...(runId ? { runId } : {}), sequence: 0, open: new Map(), finished: new Map(), attemptIds: new Map(), namedAttempts: new Set(), operationIds: new Map(),
|
|
101
103
|
failed: new Map(), incidentSequence: new Map(), pendingDispositions: new Map() };
|
|
102
104
|
}
|
|
103
105
|
export interface IncidentSink {
|
|
@@ -114,8 +116,11 @@ export interface DispositionRequest {
|
|
|
114
116
|
targets: string[];
|
|
115
117
|
reason: string;
|
|
116
118
|
evidence?: string;
|
|
119
|
+
permissionResource?: PermissionResource;
|
|
117
120
|
}
|
|
118
121
|
export function readDispositionRequest(args: unknown): { request?: DispositionRequest; error?: string } {
|
|
122
|
+
if (!args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).some(key =>
|
|
123
|
+
!["disposition", "targets", "reason", "evidence", "permissionResource"].includes(key))) return { error: "Unknown disposition fields" };
|
|
119
124
|
const input = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
|
|
120
125
|
const disposition = input.disposition;
|
|
121
126
|
if (typeof disposition !== "string" || !INCIDENT_DISPOSITIONS.includes(disposition as IncidentDisposition)) {
|
|
@@ -126,9 +131,16 @@ export function readDispositionRequest(args: unknown): { request?: DispositionRe
|
|
|
126
131
|
return { error: "targets must name 1-20 incidents, attempt ids, or operation ids" };
|
|
127
132
|
}
|
|
128
133
|
if (typeof input.reason !== "string" || !input.reason.trim()) return { error: "reason is required" };
|
|
129
|
-
|
|
134
|
+
// The SDK may drop optional nulls; the session/process log retains them. Both mean absent.
|
|
135
|
+
const evidence = input.evidence ?? undefined;
|
|
136
|
+
const permissionResource = input.permissionResource ?? undefined;
|
|
137
|
+
if (evidence !== undefined && (typeof evidence !== "string" || !evidence.trim())) return { error: "evidence must be an attempt id" };
|
|
138
|
+
if (permissionResource !== undefined && (disposition !== "open" || !isPermissionResource(permissionResource))) {
|
|
139
|
+
return { error: "permissionResource requires open and a supported permission resource" };
|
|
140
|
+
}
|
|
130
141
|
return { request: { disposition: disposition as IncidentDisposition, targets: [...targets] as string[], reason: input.reason.trim(),
|
|
131
|
-
...(
|
|
142
|
+
...(permissionResource !== undefined ? { permissionResource: permissionResource as PermissionResource } : {}),
|
|
143
|
+
...(typeof evidence === "string" ? { evidence: evidence.trim() } : {}) } };
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
function attemptRef(model: IncidentModel, ref: string): Finished | undefined {
|
|
@@ -146,6 +158,9 @@ function attemptLabel(attempt: Finished): string {
|
|
|
146
158
|
* recovery additionally needs the same operation. Everything else fails closed.
|
|
147
159
|
*/
|
|
148
160
|
export function resolveDisposition(model: IncidentModel, state: FailureState, request: DispositionRequest, requestId: string): { event?: FailureEvent; error?: string } {
|
|
161
|
+
if (request.permissionResource !== undefined && (!model.structuredIntent || request.disposition !== "open" || !isPermissionResource(request.permissionResource))) {
|
|
162
|
+
return { error: "Permission reports require trusted worker open dispositions" };
|
|
163
|
+
}
|
|
149
164
|
const incidents: string[] = [];
|
|
150
165
|
for (const target of request.targets) {
|
|
151
166
|
let id: string | undefined;
|
|
@@ -189,6 +204,11 @@ export function resolveDisposition(model: IncidentModel, state: FailureState, re
|
|
|
189
204
|
}
|
|
190
205
|
const event: FailureEvent = { id: `disposition:${requestId}`, operation: "incident-disposition", kind: "disposition",
|
|
191
206
|
disposition: request.disposition, incidents, reason: request.reason, ...(evidence ? { evidence } : {}) };
|
|
207
|
+
if (request.permissionResource !== undefined) {
|
|
208
|
+
event.permissionBlockers = incidents.map(id => ({ version: 1, kind: "permission-blocker", context: "worker",
|
|
209
|
+
resource: request.permissionResource!, basis: "agent-reported", operation: findIncident(state, id)?.operation ?? "unknown",
|
|
210
|
+
remoteOutcome: "unknown", incidentId: id, ...(model.runId ? { runId: model.runId } : {}) } satisfies PermissionBlocker));
|
|
211
|
+
}
|
|
192
212
|
const error = validateDisposition(state, event);
|
|
193
213
|
return error ? { error } : { event };
|
|
194
214
|
}
|
package/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { execSync } from "node:child_process";
|
|
15
15
|
import { writeFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
16
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { ExtensionAPI, ExtensionCommandContext, 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";
|
|
19
19
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
@@ -2283,6 +2283,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
2283
2283
|
},
|
|
2284
2284
|
enrich: createLaunchEnricher(),
|
|
2285
2285
|
});
|
|
2286
|
+
const openSettings = async (ctx: ExtensionCommandContext): Promise<void> => {
|
|
2287
|
+
if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
|
|
2288
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
|
|
2289
|
+
get: () => ({
|
|
2290
|
+
mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
|
|
2291
|
+
modeSource: delegationOverride ? "session" : "config",
|
|
2292
|
+
capSource: capOverride !== undefined ? "session" : "config",
|
|
2293
|
+
defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
|
|
2294
|
+
defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
|
|
2295
|
+
}),
|
|
2296
|
+
changeMode, changeCap, save: saveSettings, reset: resetSettings,
|
|
2297
|
+
}, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
|
|
2298
|
+
} else {
|
|
2299
|
+
ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. Use /subagents mode <mode> or /subagents cap <number>; /subagents save persists both.`, "info");
|
|
2300
|
+
}
|
|
2301
|
+
};
|
|
2302
|
+
const contribution = { id: "subagents", label: "Subagents", command: "/subagents settings", open: openSettings };
|
|
2303
|
+
const registerSettings = () => pi.events?.emit("harness-settings:register", contribution);
|
|
2304
|
+
const unsubscribeSettingsRequest = pi.events?.on?.("harness-settings:request", registerSettings);
|
|
2305
|
+
registerSettings();
|
|
2306
|
+
|
|
2286
2307
|
if (typeof pi.registerCommand === "function") {
|
|
2287
2308
|
agentOperations.registerCommands(pi);
|
|
2288
2309
|
pi.registerCommand("subagents", {
|
|
@@ -2291,20 +2312,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2291
2312
|
async handler(args, ctx) {
|
|
2292
2313
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
2293
2314
|
if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "settings")) {
|
|
2294
|
-
|
|
2295
|
-
await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
|
|
2296
|
-
get: () => ({
|
|
2297
|
-
mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
|
|
2298
|
-
modeSource: delegationOverride ? "session" : "config",
|
|
2299
|
-
capSource: capOverride !== undefined ? "session" : "config",
|
|
2300
|
-
defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
|
|
2301
|
-
defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
|
|
2302
|
-
}),
|
|
2303
|
-
changeMode, changeCap, save: saveSettings, reset: resetSettings,
|
|
2304
|
-
}, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
|
|
2305
|
-
} else {
|
|
2306
|
-
ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. Use /subagents mode <mode> or /subagents cap <number>; /subagents save persists both.`, "info");
|
|
2307
|
-
}
|
|
2315
|
+
await openSettings(ctx);
|
|
2308
2316
|
return;
|
|
2309
2317
|
}
|
|
2310
2318
|
const requested = tokens[1];
|
|
@@ -2346,18 +2354,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
2346
2354
|
// ---- live-status lifecycle -----------------------------------------
|
|
2347
2355
|
pi.on("agent_start", async (_event, ctx) => {
|
|
2348
2356
|
setCallbackBatchContext(pi, ctx);
|
|
2357
|
+
getCallbackBatcher(pi).setForegroundRunning(true);
|
|
2349
2358
|
uiCtx = ctx;
|
|
2350
2359
|
mainAgentStartedAt = Date.now();
|
|
2351
2360
|
mainAgentTools.clear();
|
|
2352
2361
|
refreshBackgroundWorkNavigator(ctx);
|
|
2353
2362
|
});
|
|
2354
2363
|
|
|
2364
|
+
pi.on("agent_end", () => { getCallbackBatcher(pi).setForegroundRunning(false); });
|
|
2365
|
+
|
|
2355
2366
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
2367
|
+
getCallbackBatcher(pi).toolStarted(event.toolCallId);
|
|
2356
2368
|
mainAgentTools.set(event.toolCallId, event.toolName);
|
|
2357
2369
|
refreshBackgroundWorkNavigator(ctx);
|
|
2358
2370
|
});
|
|
2359
2371
|
|
|
2360
2372
|
pi.on("tool_execution_end", async (event, ctx) => {
|
|
2373
|
+
await getCallbackBatcher(pi).toolEnded(event.toolCallId);
|
|
2361
2374
|
mainAgentTools.delete(event.toolCallId);
|
|
2362
2375
|
refreshBackgroundWorkNavigator(ctx);
|
|
2363
2376
|
});
|
|
@@ -2378,6 +2391,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2378
2391
|
});
|
|
2379
2392
|
|
|
2380
2393
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
2394
|
+
getCallbackBatcher(pi).setForegroundRunning(false);
|
|
2381
2395
|
setCallbackBatchContext(pi, ctx);
|
|
2382
2396
|
mainAgentStartedAt = undefined;
|
|
2383
2397
|
mainAgentTools.clear();
|
|
@@ -2385,6 +2399,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2385
2399
|
});
|
|
2386
2400
|
|
|
2387
2401
|
pi.on("session_tree", async (_event, ctx) => {
|
|
2402
|
+
setCallbackBatchContext(pi, ctx);
|
|
2388
2403
|
agentSessionSettings.restore(ctx);
|
|
2389
2404
|
try { noteCatalogHost(catalogHostFrom(ctx)); } catch { /* catalog inspection stays undecided */ }
|
|
2390
2405
|
restoreDelegationMode(ctx);
|
|
@@ -2446,6 +2461,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2446
2461
|
|
|
2447
2462
|
// Tear down the timer and clear the widget when the session ends.
|
|
2448
2463
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
2464
|
+
if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
|
|
2449
2465
|
// The foreground Pi session owns this work. Stop current-session
|
|
2450
2466
|
// running/orphaned children before dropping the session origin, so they
|
|
2451
2467
|
// cannot become live process groups with no coordinator.
|
package/package.json
CHANGED
|
@@ -1,6 +1,126 @@
|
|
|
1
1
|
// Generated from packages/callback-batcher/index.ts. Do not edit directly.
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
2
7
|
export type CallbackSource = "subagent" | "background-task";
|
|
3
8
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
9
|
+
export type CallbackDeliveryMode = "hold" | "steer";
|
|
10
|
+
|
|
11
|
+
export interface CallbackSettingsContext {
|
|
12
|
+
sessionManager?: { getBranch?(): readonly unknown[]; getSessionId?(): string };
|
|
13
|
+
ui?: { notify(message: string, type: "error"): void };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CallbackSettingsSeams {
|
|
17
|
+
agentDir?: () => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CallbackSettings {
|
|
21
|
+
mode: CallbackDeliveryMode;
|
|
22
|
+
source: "session" | "default";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const CALLBACK_SETTINGS_ENTRY = "pi-better-callback-settings";
|
|
26
|
+
const CALLBACK_PREFERENCES_FILE = "pi-better-callback-preferences.json";
|
|
27
|
+
|
|
28
|
+
function isDeliveryMode(mode: unknown): mode is CallbackDeliveryMode {
|
|
29
|
+
return mode === "hold" || mode === "steer";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validCallbackPreferences(value: unknown): value is { version: 1; mode: CallbackDeliveryMode } {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
34
|
+
const data = value as Record<string, unknown>;
|
|
35
|
+
return Object.keys(data).length === 2 && Object.hasOwn(data, "version") && Object.hasOwn(data, "mode")
|
|
36
|
+
&& data.version === 1 && isDeliveryMode(data.mode);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function callbackPreferencesPath(seams: CallbackSettingsSeams): string {
|
|
40
|
+
return join((seams.agentDir ?? getAgentDir)(), "extensions", CALLBACK_PREFERENCES_FILE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Branch history is authoritative; abandoned branches and invalid entries are ignored. */
|
|
44
|
+
export function getCallbackSettings(
|
|
45
|
+
ctx: CallbackSettingsContext,
|
|
46
|
+
seams: CallbackSettingsSeams = {},
|
|
47
|
+
): CallbackSettings {
|
|
48
|
+
const branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
49
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
50
|
+
const entry = branch[i] as { type?: unknown; customType?: unknown; data?: unknown } | null;
|
|
51
|
+
if (entry?.type === "custom" && entry.customType === CALLBACK_SETTINGS_ENTRY && validCallbackPreferences(entry.data)) {
|
|
52
|
+
return { mode: entry.data.mode, source: "session" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return getSessionDefault(ctx, seams);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getSessionDefault(ctx: CallbackSettingsContext, seams: CallbackSettingsSeams): CallbackSettings {
|
|
59
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
60
|
+
const defaults = globalState().sessionDefaults ??= new WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>();
|
|
61
|
+
const initial = ctx.sessionManager ? defaults.get(ctx.sessionManager) : undefined;
|
|
62
|
+
if (initial && initial.sessionId === sessionId) return { mode: initial.mode, source: "default" };
|
|
63
|
+
const snapshot = (mode: CallbackDeliveryMode): CallbackSettings => {
|
|
64
|
+
if (ctx.sessionManager) defaults.set(ctx.sessionManager, { sessionId, mode });
|
|
65
|
+
return { mode, source: "default" };
|
|
66
|
+
};
|
|
67
|
+
const path = callbackPreferencesPath(seams);
|
|
68
|
+
let text: string;
|
|
69
|
+
try {
|
|
70
|
+
text = readFileSync(path, "utf8");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return snapshot("hold");
|
|
73
|
+
throw new Error(`Cannot read callback default at ${path}: ${String(error)}`, { cause: error });
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const data: unknown = JSON.parse(text);
|
|
77
|
+
if (!validCallbackPreferences(data)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
|
|
78
|
+
return snapshot(data.mode);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`Invalid callback default at ${path}: ${String(error)}`, { cause: error });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Saves only the future-session default; never changes the active branch or batcher. */
|
|
85
|
+
export function saveCallbackDefault(mode: CallbackDeliveryMode, seams: CallbackSettingsSeams = {}): void {
|
|
86
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing saved.");
|
|
87
|
+
const dir = join((seams.agentDir ?? getAgentDir)(), "extensions");
|
|
88
|
+
const path = join(dir, CALLBACK_PREFERENCES_FILE);
|
|
89
|
+
const tmp = join(dir, `.${CALLBACK_PREFERENCES_FILE}.${randomUUID()}.tmp`);
|
|
90
|
+
let created = false;
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
const fd = openSync(tmp, "wx", 0o600);
|
|
94
|
+
created = true;
|
|
95
|
+
try { writeFileSync(fd, `${JSON.stringify({ version: 1, mode })}\n`); }
|
|
96
|
+
finally { closeSync(fd); }
|
|
97
|
+
renameSync(tmp, path);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(`Cannot save callback default at ${path}: ${String(error)}`, { cause: error });
|
|
100
|
+
} finally {
|
|
101
|
+
if (created) rmSync(tmp, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Persist first so a failed append cannot become a transient delivery-mode change. */
|
|
106
|
+
export function changeCallbackSetting(
|
|
107
|
+
host: CallbackBatchHost & { appendEntry(customType: string, data: unknown): void },
|
|
108
|
+
ctx: CallbackSettingsContext & { isIdle(): boolean },
|
|
109
|
+
mode: CallbackDeliveryMode,
|
|
110
|
+
): void {
|
|
111
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
|
|
112
|
+
const data = { version: 1, mode };
|
|
113
|
+
try { host.appendEntry(CALLBACK_SETTINGS_ENTRY, data); }
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Pi advances its in-memory branch before disk persistence. Invalidate
|
|
116
|
+
// our payload so that failed entry cannot become a restored setting.
|
|
117
|
+
data.version = 0;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
const batcher = bindCallbackBatcher(host, ctx);
|
|
121
|
+
batcher.setWhileBusy(mode);
|
|
122
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
123
|
+
}
|
|
4
124
|
|
|
5
125
|
export interface CallbackBatchHost {
|
|
6
126
|
/** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
|
|
@@ -70,6 +190,8 @@ export interface CallbackBatcherOptions {
|
|
|
70
190
|
retryMs?: number;
|
|
71
191
|
/** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
|
|
72
192
|
isAvailable?: () => boolean;
|
|
193
|
+
/** Internal initial mode. Session/default settings are restored with context. */
|
|
194
|
+
whileBusy?: CallbackDeliveryMode;
|
|
73
195
|
/** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
|
|
74
196
|
maxBytes?: number;
|
|
75
197
|
}
|
|
@@ -81,6 +203,14 @@ export interface CallbackBatcher {
|
|
|
81
203
|
setHost(host: CallbackBatchHost): void;
|
|
82
204
|
/** Update the session predicate and schedule an asynchronous drain when available. */
|
|
83
205
|
setAvailability(isAvailable: () => boolean): void;
|
|
206
|
+
/** Change delivery immediately without clearing pending callbacks or handoff receipts. */
|
|
207
|
+
setWhileBusy(mode: CallbackDeliveryMode): void;
|
|
208
|
+
/** Non-idle context operations are not foreground agent runs. */
|
|
209
|
+
setForegroundRunning(running: boolean): void;
|
|
210
|
+
/** Shared IDs make duplicate notifications from both extensions idempotent. */
|
|
211
|
+
toolStarted(toolCallId: string): void;
|
|
212
|
+
/** Await the final active tool's handoff before Pi checks its steering queue. */
|
|
213
|
+
toolEnded(toolCallId: string): Promise<boolean>;
|
|
84
214
|
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
85
215
|
cancel(): void;
|
|
86
216
|
pendingCount(): number;
|
|
@@ -104,6 +234,7 @@ interface PendingEvent {
|
|
|
104
234
|
interface SharedCallbackBatcherState {
|
|
105
235
|
byHost: WeakMap<object, CallbackBatcher>;
|
|
106
236
|
bySession?: WeakMap<object, CallbackBatcher>;
|
|
237
|
+
sessionDefaults?: WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>;
|
|
107
238
|
}
|
|
108
239
|
|
|
109
240
|
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
@@ -131,7 +262,6 @@ const RETRIEVAL_FOOTER =
|
|
|
131
262
|
|
|
132
263
|
export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
|
|
133
264
|
export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
134
|
-
|
|
135
265
|
export function resolveCallbackBatchWindowMs(
|
|
136
266
|
value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
|
|
137
267
|
): number {
|
|
@@ -436,26 +566,31 @@ export function createCallbackBatcher(
|
|
|
436
566
|
): CallbackBatcher {
|
|
437
567
|
const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
|
|
438
568
|
const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
|
|
569
|
+
let whileBusy = options.whileBusy ?? "hold";
|
|
439
570
|
const maxBytes = callbackBatchBudget(options.maxBytes);
|
|
440
571
|
const pending = new Map<string, PendingEvent>();
|
|
441
572
|
const inFlight = new Set<string>();
|
|
442
573
|
const urgentInFlight = new Set<string>();
|
|
443
574
|
const handedOff = new Map<string, number>();
|
|
575
|
+
const activeTools = new Set<string>();
|
|
444
576
|
let sequence = 0;
|
|
445
577
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
446
578
|
let flushPromise: Promise<boolean> | undefined;
|
|
447
579
|
let isAvailable = options.isAvailable ?? (() => true);
|
|
448
580
|
let gated = options.isAvailable !== undefined;
|
|
449
581
|
let awaitingRun = false;
|
|
582
|
+
let cancelled = false;
|
|
583
|
+
let foregroundRunning = false;
|
|
450
584
|
|
|
451
|
-
const
|
|
585
|
+
const deliveryMode = (): "followUp" | "steer" | undefined => {
|
|
586
|
+
if (cancelled) return undefined;
|
|
452
587
|
try {
|
|
453
588
|
if (!isAvailable()) {
|
|
454
589
|
awaitingRun = false;
|
|
455
|
-
return
|
|
590
|
+
return whileBusy === "steer" && foregroundRunning && activeTools.size === 0 ? "steer" : undefined;
|
|
456
591
|
}
|
|
457
|
-
return !awaitingRun;
|
|
458
|
-
} catch { return
|
|
592
|
+
return !awaitingRun ? "followUp" : undefined;
|
|
593
|
+
} catch { return undefined; }
|
|
459
594
|
};
|
|
460
595
|
|
|
461
596
|
const cancelTimer = (): void => {
|
|
@@ -466,7 +601,7 @@ export function createCallbackBatcher(
|
|
|
466
601
|
const schedule = (delayMs: number): void => {
|
|
467
602
|
if (timer || pending.size === 0) return;
|
|
468
603
|
// Some idle transitions (manual compaction) have no agent_settled event.
|
|
469
|
-
const delay =
|
|
604
|
+
const delay = deliveryMode() ? delayMs : retryMs;
|
|
470
605
|
timer = setTimeout(() => {
|
|
471
606
|
timer = undefined;
|
|
472
607
|
void api.flush();
|
|
@@ -485,7 +620,8 @@ export function createCallbackBatcher(
|
|
|
485
620
|
|
|
486
621
|
const performFlush = async (): Promise<boolean> => {
|
|
487
622
|
cancelTimer();
|
|
488
|
-
|
|
623
|
+
const mode = deliveryMode();
|
|
624
|
+
if (!mode) {
|
|
489
625
|
schedule(retryMs);
|
|
490
626
|
return false;
|
|
491
627
|
}
|
|
@@ -539,14 +675,16 @@ export function createCallbackBatcher(
|
|
|
539
675
|
try {
|
|
540
676
|
// Pi may defer the requested run while settled handlers finish, leaving
|
|
541
677
|
// isIdle() true. Permit no second handoff until a run starts.
|
|
542
|
-
awaitingRun = gated;
|
|
678
|
+
awaitingRun = mode === "followUp" && gated;
|
|
543
679
|
await host.sendMessage(
|
|
544
680
|
{
|
|
545
681
|
customType: "background-completion-batch",
|
|
546
682
|
content: packed.text,
|
|
547
683
|
display: true,
|
|
548
684
|
},
|
|
549
|
-
|
|
685
|
+
// Newer Pi requires triggerTurn for steering too; false only appends
|
|
686
|
+
// a custom message at the turn boundary without continuing the agent.
|
|
687
|
+
{ deliverAs: mode, triggerTurn: true },
|
|
550
688
|
);
|
|
551
689
|
} catch {
|
|
552
690
|
awaitingRun = false;
|
|
@@ -632,15 +770,42 @@ export function createCallbackBatcher(
|
|
|
632
770
|
setAvailability(check) {
|
|
633
771
|
gated = true;
|
|
634
772
|
isAvailable = check;
|
|
635
|
-
|
|
773
|
+
cancelled = false;
|
|
774
|
+
deliveryMode();
|
|
636
775
|
cancelTimer();
|
|
637
776
|
schedule(windowMs);
|
|
638
777
|
},
|
|
778
|
+
setWhileBusy(mode) {
|
|
779
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
|
|
780
|
+
whileBusy = mode;
|
|
781
|
+
cancelTimer();
|
|
782
|
+
schedule(windowMs);
|
|
783
|
+
},
|
|
784
|
+
setForegroundRunning(running) {
|
|
785
|
+
foregroundRunning = running;
|
|
786
|
+
cancelTimer();
|
|
787
|
+
schedule(windowMs);
|
|
788
|
+
},
|
|
789
|
+
toolStarted(toolCallId) {
|
|
790
|
+
activeTools.add(toolCallId);
|
|
791
|
+
},
|
|
792
|
+
async toolEnded(toolCallId) {
|
|
793
|
+
const ended = activeTools.delete(toolCallId);
|
|
794
|
+
if (activeTools.size > 0 || whileBusy !== "steer" || cancelled) return false;
|
|
795
|
+
if (!ended) return flushPromise ?? false;
|
|
796
|
+
// A timer may still be completing a deferred flush. Recheck after it so
|
|
797
|
+
// the tool boundary cannot lose the pending batch to that older attempt.
|
|
798
|
+
if (flushPromise) await flushPromise;
|
|
799
|
+
return pending.size > 0 ? api.flush() : true;
|
|
800
|
+
},
|
|
639
801
|
deliverUrgent,
|
|
640
802
|
cancel() {
|
|
641
803
|
cancelTimer();
|
|
642
804
|
pending.clear();
|
|
643
805
|
awaitingRun = false;
|
|
806
|
+
activeTools.clear();
|
|
807
|
+
foregroundRunning = false;
|
|
808
|
+
cancelled = true;
|
|
644
809
|
},
|
|
645
810
|
pendingCount() {
|
|
646
811
|
return pending.size;
|
|
@@ -671,11 +836,39 @@ export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
|
671
836
|
batcher?.cancel();
|
|
672
837
|
}
|
|
673
838
|
|
|
674
|
-
/**
|
|
839
|
+
/** Restore current branch/default mode without resetting foreground tools or receipts. */
|
|
675
840
|
export function setCallbackBatchContext(
|
|
676
841
|
host: CallbackBatchHost,
|
|
677
|
-
ctx: { isIdle(): boolean
|
|
842
|
+
ctx: CallbackSettingsContext & { isIdle(): boolean },
|
|
843
|
+
seams: CallbackSettingsSeams = {},
|
|
678
844
|
): void {
|
|
845
|
+
const batcher = bindCallbackBatcher(host, ctx);
|
|
846
|
+
let mode: CallbackDeliveryMode = "hold";
|
|
847
|
+
try {
|
|
848
|
+
const settings = getCallbackSettings(ctx, seams);
|
|
849
|
+
if (settings.source === "session") {
|
|
850
|
+
// Capture the initial default even when an override currently hides it,
|
|
851
|
+
// so navigating to an unconfigured branch cannot inherit a later save.
|
|
852
|
+
try { getSessionDefault(ctx, seams); }
|
|
853
|
+
catch {
|
|
854
|
+
if (ctx.sessionManager) {
|
|
855
|
+
const defaults = globalState().sessionDefaults ??= new WeakMap();
|
|
856
|
+
defaults.set(ctx.sessionManager, { sessionId: ctx.sessionManager.getSessionId?.(), mode: "hold" });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
mode = settings.mode;
|
|
861
|
+
} catch (error) {
|
|
862
|
+
try { ctx.ui?.notify(`Callback delivery restored to hold: ${String(error)}`, "error"); }
|
|
863
|
+
catch { /* A failing UI must not retain the prior session's delivery mode. */ }
|
|
864
|
+
}
|
|
865
|
+
batcher.setWhileBusy(mode);
|
|
866
|
+
// Schedule rather than await delivery inside agent_settled: its handlers are
|
|
867
|
+
// still settling the previous run, and sendMessage may start a new one.
|
|
868
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function bindCallbackBatcher(host: CallbackBatchHost, ctx: CallbackSettingsContext): CallbackBatcher {
|
|
679
872
|
const state = globalState();
|
|
680
873
|
const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
|
|
681
874
|
const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
|
|
@@ -683,9 +876,7 @@ export function setCallbackBatchContext(
|
|
|
683
876
|
if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
|
|
684
877
|
state.byHost.set(host.events ?? host, batcher);
|
|
685
878
|
batcher.setHost(host);
|
|
686
|
-
|
|
687
|
-
// still settling the previous run, and sendMessage may start a new one.
|
|
688
|
-
batcher.setAvailability(() => ctx.isIdle());
|
|
879
|
+
return batcher;
|
|
689
880
|
}
|
|
690
881
|
|
|
691
882
|
function globalState(): SharedCallbackBatcherState {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import { isPermissionBlocker, permissionBlockerKey, type PermissionBlocker } from "./shared-permission-blocker.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Explicit, append-only incident dispositions (#315).
|
|
@@ -30,6 +31,7 @@ export interface FailureEvent {
|
|
|
30
31
|
disposition?: IncidentDisposition;
|
|
31
32
|
/** Disposition events only: why the incidents are classified this way. */
|
|
32
33
|
reason?: string;
|
|
34
|
+
permissionBlockers?: PermissionBlocker[];
|
|
33
35
|
}
|
|
34
36
|
export interface DispositionRecord {
|
|
35
37
|
eventId: string;
|
|
@@ -38,6 +40,7 @@ export interface DispositionRecord {
|
|
|
38
40
|
reason: string;
|
|
39
41
|
evidence?: string;
|
|
40
42
|
at: number;
|
|
43
|
+
permissionBlockers?: PermissionBlocker[];
|
|
41
44
|
}
|
|
42
45
|
export interface FailureObservation {
|
|
43
46
|
id: string;
|
|
@@ -56,6 +59,8 @@ export interface FailureObservation {
|
|
|
56
59
|
resolvedAt?: number;
|
|
57
60
|
/** Latest explicit disposition applied to this incident. */
|
|
58
61
|
disposition?: DispositionRecord;
|
|
62
|
+
/** Retained even after recovery; active surfaces filter by current actionability. */
|
|
63
|
+
permissionBlockers?: PermissionBlocker[];
|
|
59
64
|
}
|
|
60
65
|
export interface FailureState {
|
|
61
66
|
version: 1;
|
|
@@ -247,12 +252,26 @@ export function validateDisposition(state: FailureState, event: FailureEvent): s
|
|
|
247
252
|
if (current.status !== "unresolved") return `Incident ${id} is already disposed (${failureLabel(current).toLowerCase()})`;
|
|
248
253
|
if (event.disposition === "open" && current.disposition?.disposition === "open") return `Incident ${id} is already open`;
|
|
249
254
|
}
|
|
255
|
+
if (event.permissionBlockers !== undefined) {
|
|
256
|
+
if (event.disposition !== "open" || !Array.isArray(event.permissionBlockers) || !event.permissionBlockers.length ||
|
|
257
|
+
event.permissionBlockers.length > 20 || !event.permissionBlockers.every(isPermissionBlocker)) return "Invalid permission blocker metadata";
|
|
258
|
+
const bound = new Set<string>();
|
|
259
|
+
for (const blocker of event.permissionBlockers) {
|
|
260
|
+
const current = blocker.incidentId ? findIncident(state, blocker.incidentId) : undefined;
|
|
261
|
+
if (!current || !ids.includes(current.id) || current.category !== "tool" || current.operation !== blocker.operation ||
|
|
262
|
+
blocker.context !== "worker" || blocker.basis !== "agent-reported" || blocker.remoteOutcome !== "unknown" ||
|
|
263
|
+
blocker.policySnapshotId !== undefined || bound.has(current.id)) return "Permission blocker must name an unresolved failed worker attempt";
|
|
264
|
+
bound.add(current.id);
|
|
265
|
+
}
|
|
266
|
+
if (bound.size !== ids.length) return "Permission blocker metadata must cover every target";
|
|
267
|
+
}
|
|
250
268
|
return undefined;
|
|
251
269
|
}
|
|
252
270
|
|
|
253
271
|
/** Pure transition. Lifecycle is deliberately not an input or an output. */
|
|
254
272
|
export function reduceFailure(state: FailureState, event: FailureEvent, observedAt: number): FailureState {
|
|
255
273
|
if (state.seen.includes(event.id)) return state;
|
|
274
|
+
if (event.permissionBlockers !== undefined && event.kind !== "disposition") return state;
|
|
256
275
|
// Invalid dispositions fail closed: nothing changes and the id is not consumed.
|
|
257
276
|
if (event.kind === "disposition" && validateDisposition(state, event)) return state;
|
|
258
277
|
const next: FailureState = { ...state, seen: [...state.seen, event.id],
|
|
@@ -265,11 +284,13 @@ export function reduceFailure(state: FailureState, event: FailureEvent, observed
|
|
|
265
284
|
const at = event.at ?? observedAt;
|
|
266
285
|
const record: DispositionRecord = { eventId: event.id, disposition: event.disposition!, incidents: [...event.incidents!],
|
|
267
286
|
reason: text(event.reason, ""), ...(event.evidence ? { evidence: text(event.evidence, "") } : {}), at };
|
|
287
|
+
if (event.permissionBlockers) record.permissionBlockers = event.permissionBlockers.map(x => ({ ...x }));
|
|
268
288
|
for (const id of record.incidents) {
|
|
269
289
|
const [key, current] = Object.entries(next.observations).find(([, item]) => item.id === id)!;
|
|
270
290
|
const status = record.disposition === "recovered" ? "resolved" : record.disposition === "superseded" ? "superseded"
|
|
271
291
|
: record.disposition === "expected" ? "expected" : current.status;
|
|
272
292
|
next.observations[key] = { ...current, status, disposition: record,
|
|
293
|
+
...(record.permissionBlockers ? { permissionBlockers: record.permissionBlockers.filter(x => x.incidentId === id) } : {}),
|
|
273
294
|
...(status === "resolved" || status === "superseded" ? { resolvedAt: at } : {}) };
|
|
274
295
|
if (status === "resolved" || status === "superseded") next.resolved = { ...next.resolved, [id]: at };
|
|
275
296
|
}
|
|
@@ -298,6 +319,7 @@ export function reduceFailure(state: FailureState, event: FailureEvent, observed
|
|
|
298
319
|
lastObservedAt: observedAt, lastSequence: next.seen.length, at: event.at,
|
|
299
320
|
count: active ? previous.count + 1 : 1,
|
|
300
321
|
...(active && previous.disposition ? { disposition: previous.disposition } : {}),
|
|
322
|
+
...(active && previous.permissionBlockers ? { permissionBlockers: previous.permissionBlockers } : {}),
|
|
301
323
|
};
|
|
302
324
|
return next;
|
|
303
325
|
}
|
|
@@ -315,6 +337,16 @@ export function activeFailures(state: FailureState): FailureObservation[] {
|
|
|
315
337
|
export function actionableFailures(state: FailureState): FailureObservation[] {
|
|
316
338
|
return activeFailures(state).filter(needsAction);
|
|
317
339
|
}
|
|
340
|
+
/** Only currently actionable reports, not closed history or arbitrary tool-result claims. */
|
|
341
|
+
export function actionablePermissionBlockers(state: FailureState): PermissionBlocker[] {
|
|
342
|
+
const unique = new Map<string, PermissionBlocker>();
|
|
343
|
+
for (const incident of actionableFailures(state)) {
|
|
344
|
+
for (const blocker of incident.permissionBlockers ?? []) {
|
|
345
|
+
if (isPermissionBlocker(blocker)) unique.set(permissionBlockerKey(blocker), { ...blocker });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return [...unique.values()];
|
|
349
|
+
}
|
|
318
350
|
function closedOrder(a: FailureObservation, b: FailureObservation): number {
|
|
319
351
|
return (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt;
|
|
320
352
|
}
|
|
@@ -449,12 +481,20 @@ export function shortEvidence(evidence: string): string {
|
|
|
449
481
|
function failureRow(x: FailureObservation, detail: IncidentDetail = "compact"): string {
|
|
450
482
|
const time = x.at === undefined ? `observed ${new Date(x.firstObservedAt).toISOString()}` : new Date(x.at).toISOString();
|
|
451
483
|
const compact = detail !== "full";
|
|
484
|
+
const permission = Boolean(x.permissionBlockers?.length);
|
|
485
|
+
let annotation = "";
|
|
486
|
+
if (x.permissionBlockers?.length) {
|
|
487
|
+
const resources = x.permissionBlockers.map(b => b.resource).join(", ");
|
|
488
|
+
annotation = ` · Worker permission blocker: ${resources} (agent-reported; remote outcome unknown; not a foreground denial). No remote authorization or successful validation inferred.`;
|
|
489
|
+
if (needsAction(x)) annotation += " Changed worker settings require a fresh launch.";
|
|
490
|
+
}
|
|
452
491
|
// Full rows (raw evidence) show the journal summary verbatim; compact rows unwrap and cap it.
|
|
453
|
-
const summary = compact ? capUtf8(dropLoneSurrogates(unwrapToolResultText(x.summary)).replace(/\s+/g, " ").trim(), COMPACT_EXCERPT_BYTES) : x.summary;
|
|
492
|
+
const summary = compact && permission ? "Permission access failure" : compact ? capUtf8(dropLoneSurrogates(unwrapToolResultText(x.summary)).replace(/\s+/g, " ").trim(), COMPACT_EXCERPT_BYTES) : x.summary;
|
|
454
493
|
const reason = x.disposition ? text(x.disposition.reason, "") : "";
|
|
455
|
-
const disposition = x.disposition ? ` · ${x.disposition.disposition}: ${compact ? capUtf8(reason, COMPACT_EXCERPT_BYTES) : reason}` : "";
|
|
456
|
-
const evidence = x.evidence ? text(x.evidence, "") : "";
|
|
457
|
-
|
|
494
|
+
const disposition = x.disposition ? ` · ${x.disposition.disposition}${compact && permission ? "" : `: ${compact ? capUtf8(reason, COMPACT_EXCERPT_BYTES) : reason}`}` : "";
|
|
495
|
+
const evidence = x.evidence && !(compact && permission) ? text(x.evidence, "") : "";
|
|
496
|
+
const proof = !compact && permission && x.disposition?.evidence ? ` · disposition evidence: ${text(x.disposition.evidence, "")}` : "";
|
|
497
|
+
return `${failureLabel(x)} · ${time} · ${summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${disposition}${evidence ? ` · evidence: ${compact ? shortEvidence(evidence) : evidence}` : ""}${proof}${annotation}`;
|
|
458
498
|
}
|
|
459
499
|
|
|
460
500
|
/**
|
|
@@ -573,13 +613,15 @@ export interface FailureIncidentPage {
|
|
|
573
613
|
*/
|
|
574
614
|
export function failureRevision(state: FailureState): string {
|
|
575
615
|
return failureIdentity(Object.values(state.observations)
|
|
576
|
-
.map((item) => [item.id, item.status, item.category, item.count, item.lastSequence ?? 0, item.summary, item.evidence ?? ""
|
|
616
|
+
.map((item) => [item.id, item.status, item.category, item.count, item.lastSequence ?? 0, item.summary, item.evidence ?? "",
|
|
617
|
+
...(item.permissionBlockers ? [item.permissionBlockers.map(permissionBlockerKey).sort()] : [])])
|
|
577
618
|
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
|
|
578
619
|
}
|
|
579
620
|
|
|
580
621
|
/** Digest of the rows a cursor pages. Scope and detail are part of it: the same incidents render different bytes. */
|
|
581
622
|
function incidentRevision(state: FailureState, scope: IncidentScope = "actionable", detail: IncidentDetail = "compact"): string {
|
|
582
|
-
return failureIdentity(scope, detail, scopedFailures(state, scope).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? ""
|
|
623
|
+
return failureIdentity(scope, detail, scopedFailures(state, scope).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? "",
|
|
624
|
+
...(item.permissionBlockers ? [item.permissionBlockers.map(permissionBlockerKey).sort()] : [])])).slice(0, 16);
|
|
583
625
|
}
|
|
584
626
|
|
|
585
627
|
/** Cursors carry a digest of their resource/scope, not the scope text. */
|
|
@@ -999,6 +1041,8 @@ function readStoredState(path: string): FailureState {
|
|
|
999
1041
|
!Number.isFinite(row.observedAt) || Math.abs(row.observedAt) > 8.64e15 ||
|
|
1000
1042
|
(e.at !== undefined && (!Number.isFinite(e.at) || Math.abs(e.at) > 8.64e15)) ||
|
|
1001
1043
|
(e.incidents !== undefined && (!Array.isArray(e.incidents) || !e.incidents.every((id: unknown) => typeof id === "string"))) ||
|
|
1044
|
+
(e.permissionBlockers !== undefined && (e.kind !== "disposition" ||
|
|
1045
|
+
(!state.seen.includes(e.id) && validateDisposition(state, e) !== undefined))) ||
|
|
1002
1046
|
[e.summary, e.category, e.evidence, e.reason].some((v) => v !== undefined && typeof v !== "string")) throw new Error("invalid record");
|
|
1003
1047
|
state = reduceFailure(state, e, row.observedAt);
|
|
1004
1048
|
} catch { state = storageProblem(state, "Failure journal contains unreadable records; observations may be incomplete"); }
|
|
@@ -1021,6 +1065,7 @@ export function observeFailures(path: string, events: readonly FailureEvent[], n
|
|
|
1021
1065
|
}
|
|
1022
1066
|
// Rejected dispositions are never journaled or marked seen.
|
|
1023
1067
|
if (raw.kind === "disposition" && validateDisposition(state, raw)) continue;
|
|
1068
|
+
if (raw.permissionBlockers !== undefined && raw.kind !== "disposition") continue;
|
|
1024
1069
|
const event = { ...raw, ...(raw.summary ? { summary: text(raw.summary, "") } : {}),
|
|
1025
1070
|
...(raw.evidence ? { evidence: text(raw.evidence, "") } : {}), ...(raw.reason ? { reason: text(raw.reason, "") } : {}) };
|
|
1026
1071
|
try {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Generated from packages/failure-observations/permission-blocker.ts. Do not edit directly.
|
|
2
|
+
/** Privacy-safe wire contract. Context and identity must be assigned by a trusted adapter. */
|
|
3
|
+
export const PERMISSION_RESOURCES = Object.freeze([
|
|
4
|
+
"credential-files", "process-inspection", "project-files", "outside-project-files",
|
|
5
|
+
"runtime-control-files", "command-execution", "network-access", "unknown",
|
|
6
|
+
] as const);
|
|
7
|
+
export type PermissionResource = typeof PERMISSION_RESOURCES[number];
|
|
8
|
+
export interface PermissionBlocker {
|
|
9
|
+
version: 1;
|
|
10
|
+
kind: "permission-blocker";
|
|
11
|
+
context: "worker" | "foreground";
|
|
12
|
+
resource: PermissionResource;
|
|
13
|
+
basis: "policy-refusal" | "os-permission-error" | "agent-reported";
|
|
14
|
+
/** A stable hash or bounded logical identifier, never a command or path. */
|
|
15
|
+
operation: string;
|
|
16
|
+
remoteOutcome: "unknown" | "not-started";
|
|
17
|
+
incidentId?: string;
|
|
18
|
+
runId?: string;
|
|
19
|
+
policySnapshotId?: string;
|
|
20
|
+
}
|
|
21
|
+
const FIELDS = new Set(["version", "kind", "context", "resource", "basis", "operation", "remoteOutcome", "incidentId", "runId", "policySnapshotId"]);
|
|
22
|
+
const LOGICAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
23
|
+
// Responses SDK references join call_id and item_id with "|"; these are opaque evidence ids,
|
|
24
|
+
// not caller-chosen logical operations. Keep the same bound and exclude paths and prose.
|
|
25
|
+
const INCIDENT_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:|\-]{0,199}$/;
|
|
26
|
+
export function isPermissionResource(value: unknown): value is PermissionResource {
|
|
27
|
+
return typeof value === "string" && (PERMISSION_RESOURCES as readonly string[]).includes(value);
|
|
28
|
+
}
|
|
29
|
+
/** Pure and strict: no coercion, unknown fields, paths, argv, or free-form prose. */
|
|
30
|
+
export function isPermissionBlocker(value: unknown): value is PermissionBlocker {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
32
|
+
const prototype = Object.getPrototypeOf(value);
|
|
33
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
34
|
+
if (Reflect.ownKeys(value).some(key => typeof key !== "string" || !FIELDS.has(key))) return false;
|
|
35
|
+
const v = value as Record<string, unknown>;
|
|
36
|
+
if (v.version !== 1 || v.kind !== "permission-blocker" || !["worker", "foreground"].includes(v.context as string) ||
|
|
37
|
+
!isPermissionResource(v.resource) || !["policy-refusal", "os-permission-error", "agent-reported"].includes(v.basis as string) ||
|
|
38
|
+
!["unknown", "not-started"].includes(v.remoteOutcome as string) ||
|
|
39
|
+
typeof v.operation !== "string" || v.operation.length > 64 || !LOGICAL_ID.test(v.operation)) return false;
|
|
40
|
+
if (Object.hasOwn(v, "incidentId") && (typeof v.incidentId !== "string" || !INCIDENT_REFERENCE.test(v.incidentId))) return false;
|
|
41
|
+
return ["runId", "policySnapshotId"].every(key => !Object.hasOwn(v, key) ||
|
|
42
|
+
(typeof v[key] === "string" && LOGICAL_ID.test(v[key] as string)));
|
|
43
|
+
}
|
|
44
|
+
/** Order-independent identity without I/O or a platform-specific crypto dependency. */
|
|
45
|
+
export function permissionBlockerKey(blocker: PermissionBlocker): string {
|
|
46
|
+
return JSON.stringify([blocker.version, blocker.kind, blocker.context, blocker.resource, blocker.basis,
|
|
47
|
+
blocker.operation, blocker.remoteOutcome, blocker.incidentId ?? null, blocker.runId ?? null, blocker.policySnapshotId ?? null]);
|
|
48
|
+
}
|
package/shared-task-sandbox.ts
CHANGED
|
@@ -118,7 +118,8 @@ export function createTaskBashOperations(
|
|
|
118
118
|
const { trackDetachedChildPid, untrackDetachedChildPid } = await import(pathToFileURL(join(sdkUtils, "shell.js")).href);
|
|
119
119
|
if (shell.commandTransport === "stdin") throw new Error("Sandbox: this shell cannot be confined by the available backend.");
|
|
120
120
|
const scratch = plan.policy.runtimeWrite?.[0];
|
|
121
|
-
|
|
121
|
+
// Git inventory otherwise creates optional locks in an outside gitdir it cannot unlink (#419).
|
|
122
|
+
const taskEnv = { GIT_OPTIONAL_LOCKS: "0", ...process.env, ...options.env,
|
|
122
123
|
...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
|
|
123
124
|
if (options.signal?.aborted) throw new Error("aborted");
|
|
124
125
|
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
|
package/shared-task-tools.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - Guarded tools are harness adapters that go through the task's guarded file
|
|
6
6
|
* operations, so the file rules govern them. Today: apply_patch.
|
|
7
|
-
* - Trusted tools are
|
|
7
|
+
* - Trusted tools are extension tools admitted by name AND owning package.
|
|
8
8
|
* They run in the child Pi process, outside the file rules; a human ticks each
|
|
9
9
|
* one in /sandbox. Network tools are refused while Network is Off.
|
|
10
10
|
*/
|
|
@@ -36,15 +36,17 @@ export function defaultSubagentTools(): SubagentToolSettings {
|
|
|
36
36
|
|
|
37
37
|
/** Tools that reach the network; refused while the profile's Network access is Off. */
|
|
38
38
|
export function isNetworkTool(name: string): boolean {
|
|
39
|
-
return ["web_fetch", "web_search", "firecrawl_scrape", "firecrawl_extract", "mcp", "mcpScript",
|
|
39
|
+
return ["web_fetch", "web_search", "firecrawl_scrape", "firecrawl_extract", "mcp", "mcpScript", ...SSH_TOOL_NAMES].includes(name) ||
|
|
40
40
|
name.startsWith("mcp__");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** Names never offered as trusted: builtins and the guarded adapters. */
|
|
44
44
|
export const RESERVED_TOOL_NAMES = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls", "powershell", "apply_patch", "process_list"]);
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/** Harness tools stay excluded except for the explicitly supported SSH tools. */
|
|
47
47
|
const HARNESS_PACKAGES = /(^|[/:])pi-better-[a-z-]+$/;
|
|
48
|
+
const SSH_PACKAGE = /(^|[/:])pi-better-ssh$/;
|
|
49
|
+
const SSH_TOOL_NAMES = ["remote_bash", "ssh_profile", "ssh_mux"];
|
|
48
50
|
|
|
49
51
|
/** Strict decoding: a malformed tool list must not silently admit anything. */
|
|
50
52
|
export function parseSubagentTools(value: unknown): SubagentToolSettings {
|
|
@@ -72,6 +74,8 @@ export interface ToolSource {
|
|
|
72
74
|
export interface DiscoveredTool {
|
|
73
75
|
name: string;
|
|
74
76
|
package: string;
|
|
77
|
+
/** Explicit SDK provenance for display only; never a package or admission identity. */
|
|
78
|
+
source?: "builtin";
|
|
75
79
|
/**
|
|
76
80
|
* What to load and admit: the package root directory, or, for an extension
|
|
77
81
|
* file with no package manifest, that file itself. Loading a manifest-less
|
|
@@ -80,7 +84,7 @@ export interface DiscoveredTool {
|
|
|
80
84
|
root: string;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
/** The owning package of a registered tool, or undefined for
|
|
87
|
+
/** The owning package of a registered tool, or undefined for synthetic core/inline sources. */
|
|
84
88
|
export function toolPackage(tool: ToolSource): DiscoveredTool | undefined {
|
|
85
89
|
const info = tool.sourceInfo;
|
|
86
90
|
if (!info?.path || info.path.startsWith("<")) return undefined;
|
|
@@ -93,14 +97,19 @@ export function toolPackage(tool: ToolSource): DiscoveredTool | undefined {
|
|
|
93
97
|
return { name: tool.name, package: pkg, root: base };
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
/** Candidate trusted tools
|
|
100
|
+
/** Candidate trusted tools, excluding core builtins, guarded names and non-SSH harness tools. */
|
|
97
101
|
export function discoverTrustedTools(tools: readonly ToolSource[]): DiscoveredTool[] {
|
|
98
102
|
const found: DiscoveredTool[] = [];
|
|
99
103
|
for (const tool of tools) {
|
|
100
104
|
if (RESERVED_TOOL_NAMES.includes(tool.name)) continue;
|
|
101
105
|
const owner = toolPackage(tool);
|
|
102
|
-
if (!owner
|
|
103
|
-
|
|
106
|
+
if (!owner) continue;
|
|
107
|
+
const harnessOwners = [owner.package, owner.root].map((id) => id.replace(/\/+$/, ""))
|
|
108
|
+
.filter((id) => HARNESS_PACKAGES.test(id));
|
|
109
|
+
if (harnessOwners.length && !(SSH_TOOL_NAMES.includes(tool.name) && harnessOwners.every((id) => SSH_PACKAGE.test(id)))) continue;
|
|
110
|
+
if (!found.some((t) => t.name === owner.name && t.package === owner.package)) {
|
|
111
|
+
found.push(tool.sourceInfo?.source === "builtin" ? { ...owner, source: "builtin" } : owner);
|
|
112
|
+
}
|
|
104
113
|
}
|
|
105
114
|
return found.sort((a, b) => a.name.localeCompare(b.name) || a.package.localeCompare(b.package));
|
|
106
115
|
}
|
package/tools.ts
CHANGED
|
@@ -42,7 +42,10 @@ import {
|
|
|
42
42
|
unknownRunError,
|
|
43
43
|
type SubagentToolSession,
|
|
44
44
|
} from "./output-payload.ts";
|
|
45
|
-
import { readOutputControls } from "./shared-log-utils.ts";
|
|
45
|
+
import { budgetFor, readOutputControls } from "./shared-log-utils.ts";
|
|
46
|
+
import { readRunFailures } from "./failures.ts";
|
|
47
|
+
import { actionablePermissionBlockers } from "./shared-failure-observations.ts";
|
|
48
|
+
import type { PermissionBlocker } from "./shared-permission-blocker.ts";
|
|
46
49
|
import { describeTiming } from "./timing.ts";
|
|
47
50
|
import {
|
|
48
51
|
extractChildEventFactsFromLog,
|
|
@@ -112,6 +115,23 @@ const INCLUDE_DESCRIPTION = "Explicit opt-in facts omitted by default: \"cost\"
|
|
|
112
115
|
/** pi's tool-result text shape. */
|
|
113
116
|
export const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
|
|
114
117
|
|
|
118
|
+
/** Metadata has its own bounded allowance; it never embeds raw evidence or unbounded history. */
|
|
119
|
+
function permissionDetails(id: string, maxBytes: number): { permissionBlockers?: PermissionBlocker[]; permissionBlockersOmitted?: number } {
|
|
120
|
+
const all = actionablePermissionBlockers(readRunFailures(id));
|
|
121
|
+
if (!all.length) return {};
|
|
122
|
+
const permissionBlockers: PermissionBlocker[] = [];
|
|
123
|
+
const allowance = Math.min(4096, maxBytes);
|
|
124
|
+
if (Buffer.byteLength(JSON.stringify({ permissionBlockers, permissionBlockersOmitted: all.length })) > allowance) {
|
|
125
|
+
throw new Error("Permission blocker metadata cannot fit this budget; retry with a larger max_bytes.");
|
|
126
|
+
}
|
|
127
|
+
for (const blocker of all) {
|
|
128
|
+
if (permissionBlockers.length === 32 || Buffer.byteLength(JSON.stringify({ permissionBlockers: [...permissionBlockers, blocker],
|
|
129
|
+
permissionBlockersOmitted: all.length - permissionBlockers.length - 1 })) > allowance) break;
|
|
130
|
+
permissionBlockers.push(blocker);
|
|
131
|
+
}
|
|
132
|
+
return { permissionBlockers, ...(all.length > permissionBlockers.length ? { permissionBlockersOmitted: all.length - permissionBlockers.length } : {}) };
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
const SUBAGENT_RESULT_PREVIEW_LINES = 8;
|
|
116
136
|
|
|
117
137
|
function resultTextContent(result: unknown): string {
|
|
@@ -409,7 +429,9 @@ export function subagentOutputTool(Type: TypeModule, baseSession: SubagentToolSe
|
|
|
409
429
|
if (access.kind === "denied") return text(access.payload);
|
|
410
430
|
const scopeKey = requestScopeKey(p, session);
|
|
411
431
|
const healthLine = diagnosticLines(access.meta);
|
|
412
|
-
|
|
432
|
+
const result = text(assembleSubagentOutput(p.id, access.meta, p, healthLine, scopeKey));
|
|
433
|
+
const details = permissionDetails(p.id, budgetFor("log", p.maxBytes));
|
|
434
|
+
return Object.keys(details).length ? { ...result, details } : result;
|
|
413
435
|
},
|
|
414
436
|
} as ToolDefinition;
|
|
415
437
|
}
|
|
@@ -451,20 +473,24 @@ export function subagentResultTool(Type: TypeModule, baseSession: SubagentToolSe
|
|
|
451
473
|
}
|
|
452
474
|
if (access.kind === "denied") return subagentResultText(access.payload);
|
|
453
475
|
const meta = access.meta;
|
|
476
|
+
const resultText = (body: string) => {
|
|
477
|
+
const result = subagentResultText(body);
|
|
478
|
+
return { ...result, details: { ...result.details, ...permissionDetails(p.id, budgetFor("answer", p.maxBytes)) } };
|
|
479
|
+
};
|
|
454
480
|
const scopeKey = requestScopeKey(p, session);
|
|
455
481
|
const st = effectiveStatus(meta);
|
|
456
482
|
const healthLine = diagnosticLines(meta);
|
|
457
483
|
if (!isFinalResultStatus(st)) {
|
|
458
484
|
if (st === "orphaned") {
|
|
459
|
-
return
|
|
485
|
+
return resultText(assembleOrphanedResult(p.id, meta, healthLine, p, scopeKey));
|
|
460
486
|
}
|
|
461
|
-
return
|
|
487
|
+
return resultText(assembleRunningResult(p.id, meta, p, scopeKey, describeTiming({ ...meta, status: "running" })?.line));
|
|
462
488
|
}
|
|
463
489
|
const body = buildSubagentResultPayload(p.id, p, healthLine, scopeKey);
|
|
464
490
|
if (body === null) {
|
|
465
|
-
return
|
|
491
|
+
return resultText(assembleRunningResult(p.id, meta, p, scopeKey, describeTiming({ ...meta, status: "running" })?.line));
|
|
466
492
|
}
|
|
467
|
-
return
|
|
493
|
+
return resultText(body);
|
|
468
494
|
},
|
|
469
495
|
} as ToolDefinition;
|
|
470
496
|
}
|