@signalridge/pi-subagents 1.13.0 → 1.14.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/CHANGELOG.md +16 -0
- package/README.md +8 -0
- package/package.json +2 -1
- package/src/agent-runner.ts +150 -35
- package/src/operation-approvals.ts +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.14.0
|
|
4
|
+
### Minor Changes
|
|
5
|
+
|
|
6
|
+
- 0ec64d1: Gate admitted child edit, write, and bash operations through a synchronously captured root human approval service. Preserve tool admission and legacy ask-tools policy, reject canonical shadowing and duplicate child approval resources, retain ownership across resume, and exclude human review time without resetting execution budgets. Managed protocol and journal formats remain v4.
|
|
7
|
+
|
|
8
|
+
### Patch Changes
|
|
9
|
+
|
|
10
|
+
- 74b65a5: Bound edit call rendering without IO or diff work; retain captured service liveness and exact-name admission through post-approval IO; reject live child canonical conflicts even when SDK metadata still wins. Replace delegated bash dispatch with an owned POSIX backend guarded after CWD preparation, preserving the SDK output layer while owning process-group/IPC cleanup. Preserve native editor paste/cursor/Undo state with an admitted non-capturing UI bridge, and hide unfocused reviews so foreign native questions remain visible and answerable.
|
|
11
|
+
|
|
12
|
+
Refuse Bun/compiled application and unknown interpreter capabilities before approval or subprocess startup instead of executing the host application as a supervisor. Use lossless hard wrapping, whole-grapheme and native-normalized Thai/Lao escaping, and explicit compact-preview whitespace/backslashes so spacing marks and distinct commands remain inspectable through actual terminal output.
|
|
13
|
+
- 0925139: Bound synchronous file-review diff work before generation, capture bash execution options and environments, require exact per-name canonical review bindings, and close owned review overlays by identity. Recheck cooperative adapter admission after child tool-call normalizers and carry the same captured approval-service and branch-owner guard through background startup to command dispatch. Preserve existing v1 approval and v4 managed contracts; document review limits and trusted-extension boundaries.
|
|
14
|
+
- Updated dependencies [74b65a5]
|
|
15
|
+
- Updated dependencies [0ec64d1]
|
|
16
|
+
- Updated dependencies [0925139]
|
|
17
|
+
- @signalridge/pi-approval-core@1.3.0
|
|
18
|
+
|
|
3
19
|
## 1.13.0
|
|
4
20
|
### Minor Changes
|
|
5
21
|
|
package/README.md
CHANGED
|
@@ -114,6 +114,14 @@ Foreground parent-mode calls are rejected **before spawn**: a main model blocked
|
|
|
114
114
|
|
|
115
115
|
Install the changed package and reload/restart Pi to use this feature; an already running older installed package does not acquire new tools or parameters from checkout edits.
|
|
116
116
|
|
|
117
|
+
### Root-owned operation approvals
|
|
118
|
+
|
|
119
|
+
When a root approval service is present at child creation, admitted canonical `edit`, `write`, and `bash` tools use `@signalridge/pi-approval-core` reviewed SDK definitions. Each operation awaits the root human service with its exact final inputs, file diff or command, working directory, and a distinct `child:<agent-id>` source. Ordinary parent clarification answers never authorize operations. No service means existing behavior is unchanged; malformed or duplicate services refuse before child allocation. A captured service cannot reconnect after retirement or replacement.
|
|
120
|
+
|
|
121
|
+
Tool admission, denylist, `ask_tools`, project trust, and isolation still apply first: approval adds no tools, including to internal zero-tool generation. The gate survives in-memory resume, steering, and nested `ctx.executeTool` calls. Noncanonical command/mutation-shaped tools require exactly one live cooperative adapter matching the effective schema identity. Known command or path-plus-edit/content schema properties are classified before invocation even when optional. Admission is also rechecked after each downstream public `tool_call` handler against the native mutable arguments and the originally captured execution name/schema; a normalizer cannot add a command or rename the event to bypass it. These post-handler checks never repeat `ask_tools` human prompts and retain the same shared execution deadline. Conflicting canonical extension tools are refused rather than silently replacing the reviewed binding. While root review is pending, only human waiting time is excluded from the child's default tool timeout; execution retains its remaining budget and native bash timeouts still apply after approval. Cancellation and divergent root/child branch ownership deny execution.
|
|
122
|
+
|
|
123
|
+
Inherited `pi-approvals` resources are excluded before factory loading when the root service is captured, including package aliases. Explicit selection is rejected with guidance to integrate approvals in the root session. Children do not load another approval extension or forward their UI to impersonate a human; no UI/model mode is enabled automatically. Known-shape admission does not interpret arbitrary extension semantics or unknown schemas; a timed-out hook can still perform its own side effects outside the executor. This is an in-process trusted extension integration, not an OS sandbox for arbitrary extension code. Managed protocol and journal formats remain v4.
|
|
124
|
+
|
|
117
125
|
### Scheduling
|
|
118
126
|
|
|
119
127
|
Add a `schedule` field to register the agent to fire later instead of running now:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-subagents",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
|
|
5
5
|
"author": "tintinweb and signalridge contributors",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"@sinclair/typebox": "*"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"@signalridge/pi-approval-core": "^1.3.0",
|
|
34
35
|
"croner": "^10.0.1",
|
|
35
36
|
"@signalridge/pi-subagents-protocol": "^1.6.0",
|
|
36
37
|
"@signalridge/pi-ui": "^1.4.0",
|
package/src/agent-runner.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
SessionManager,
|
|
22
22
|
SettingsManager,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { type ApprovalEvents, captureApprovalService, queryApprovalAdapter, requiresApprovalAdapter, schemaRequiresApprovalAdapter } from "@signalridge/pi-approval-core";
|
|
24
25
|
import { type AgentTierResolutionSnapshot, resolveAgentTier } from "./agent-tiers.js";
|
|
25
26
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
26
27
|
import { createAskGate } from "./ask-tools.js";
|
|
@@ -42,9 +43,11 @@ import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resol
|
|
|
42
43
|
import { parentModelSessionOptions } from "./model-runtime-bridge.js";
|
|
43
44
|
import { checkModelScope } from "./model-scope.js";
|
|
44
45
|
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
|
|
46
|
+
import { createChildApprovalTools } from "./operation-approvals.js";
|
|
45
47
|
import { ANSWER_SUBAGENT_QUESTION, type ParentQuestionHandler } from "./parent-questions.js";
|
|
46
48
|
import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
|
|
47
49
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
50
|
+
import { captureQuestionOrigin } from "./question-origin.js";
|
|
48
51
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
49
52
|
import { preloadSkills } from "./skill-loader.js";
|
|
50
53
|
import { createSupervisorTool } from "./supervisor.js";
|
|
@@ -396,9 +399,11 @@ const TOOL_CALL_ABORT_GRACE_MS = 25;
|
|
|
396
399
|
interface ApprovalTimer {
|
|
397
400
|
isInteractiveAsk: (name: string) => boolean;
|
|
398
401
|
armApproved: (toolCallId: string, toolName: string) => void;
|
|
402
|
+
pauseHuman: (toolCallId: string) => () => void;
|
|
399
403
|
}
|
|
400
404
|
// The policy survives runAgent for resumed turns; the current prompt owns its
|
|
401
|
-
// abort timers, so resume replaces
|
|
405
|
+
// abort timers, so resume replaces both approval callbacks instead of using
|
|
406
|
+
// a stale execution budget.
|
|
402
407
|
const approvalTimers = new WeakMap<AgentSession, ApprovalTimer>();
|
|
403
408
|
let defaultToolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS;
|
|
404
409
|
export function getDefaultToolTimeoutMs(): number { return defaultToolTimeoutMs; }
|
|
@@ -427,7 +432,8 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, label: string):
|
|
|
427
432
|
function createToolTimeouts(session: AgentSession, grace: number, isInteractiveAsk: (name: string) => boolean) {
|
|
428
433
|
const pending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
429
434
|
const due = new Map<string, number>();
|
|
430
|
-
const paused = new Map<string, { remaining: number; children: Set<string
|
|
435
|
+
const paused = new Map<string, { remaining: number; children: Set<string>; human: number }>();
|
|
436
|
+
let disposed = false;
|
|
431
437
|
const clear = (id: string): void => {
|
|
432
438
|
const handle = pending.get(id);
|
|
433
439
|
if (handle) clearTimeout(handle);
|
|
@@ -436,7 +442,7 @@ function createToolTimeouts(session: AgentSession, grace: number, isInteractiveA
|
|
|
436
442
|
paused.delete(id);
|
|
437
443
|
};
|
|
438
444
|
const arm = (id: string, name: string, delay = defaultToolTimeoutMs + grace): void => {
|
|
439
|
-
if (defaultToolTimeoutMs <= 0 || name === "contact_supervisor") return;
|
|
445
|
+
if (disposed || defaultToolTimeoutMs <= 0 || name === "contact_supervisor") return;
|
|
440
446
|
due.set(id, performance.now() + delay);
|
|
441
447
|
const handle = setTimeout(() => {
|
|
442
448
|
pending.delete(id);
|
|
@@ -450,25 +456,42 @@ function createToolTimeouts(session: AgentSession, grace: number, isInteractiveA
|
|
|
450
456
|
handle.unref?.();
|
|
451
457
|
pending.set(id, handle);
|
|
452
458
|
};
|
|
459
|
+
const pause = (id: string) => {
|
|
460
|
+
const existing = paused.get(id);
|
|
461
|
+
if (existing) return existing;
|
|
462
|
+
const handle = pending.get(id);
|
|
463
|
+
const deadline = due.get(id);
|
|
464
|
+
if (!handle || deadline === undefined) return undefined;
|
|
465
|
+
clearTimeout(handle);
|
|
466
|
+
pending.delete(id);
|
|
467
|
+
due.delete(id);
|
|
468
|
+
const state = { remaining: Math.max(0, deadline - performance.now()), children: new Set<string>(), human: 0 };
|
|
469
|
+
paused.set(id, state);
|
|
470
|
+
return state;
|
|
471
|
+
};
|
|
472
|
+
const resume = (id: string): void => {
|
|
473
|
+
const state = paused.get(id);
|
|
474
|
+
if (!state || state.children.size || state.human) return;
|
|
475
|
+
paused.delete(id);
|
|
476
|
+
arm(id, "", state.remaining);
|
|
477
|
+
};
|
|
453
478
|
return {
|
|
454
479
|
armApproved: (id: string, name: string): void => arm(id, name),
|
|
480
|
+
pauseHuman: (id: string): (() => void) => {
|
|
481
|
+
const state = pause(id);
|
|
482
|
+
if (!state) return () => {};
|
|
483
|
+
state.human++;
|
|
484
|
+
let finished = false;
|
|
485
|
+
return () => {
|
|
486
|
+
if (finished) return;
|
|
487
|
+
finished = true;
|
|
488
|
+
state.human--;
|
|
489
|
+
resume(id);
|
|
490
|
+
};
|
|
491
|
+
},
|
|
455
492
|
start: (event: { toolCallId: string; toolName: string; parentToolCallId?: string }): void => {
|
|
456
493
|
if (event.parentToolCallId) {
|
|
457
|
-
|
|
458
|
-
if (parent) parent.children.add(event.toolCallId);
|
|
459
|
-
else {
|
|
460
|
-
const handle = pending.get(event.parentToolCallId);
|
|
461
|
-
const deadline = due.get(event.parentToolCallId);
|
|
462
|
-
if (handle && deadline !== undefined) {
|
|
463
|
-
clearTimeout(handle);
|
|
464
|
-
pending.delete(event.parentToolCallId);
|
|
465
|
-
due.delete(event.parentToolCallId);
|
|
466
|
-
paused.set(event.parentToolCallId, {
|
|
467
|
-
remaining: Math.max(0, deadline - performance.now()),
|
|
468
|
-
children: new Set([event.toolCallId]),
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
}
|
|
494
|
+
pause(event.parentToolCallId)?.children.add(event.toolCallId);
|
|
472
495
|
}
|
|
473
496
|
if (!isInteractiveAsk(event.toolName)) arm(event.toolCallId, event.toolName);
|
|
474
497
|
},
|
|
@@ -478,11 +501,10 @@ function createToolTimeouts(session: AgentSession, grace: number, isInteractiveA
|
|
|
478
501
|
const parent = paused.get(event.parentToolCallId);
|
|
479
502
|
if (!parent) return;
|
|
480
503
|
parent.children.delete(event.toolCallId);
|
|
481
|
-
|
|
482
|
-
paused.delete(event.parentToolCallId);
|
|
483
|
-
arm(event.parentToolCallId, "", parent.remaining);
|
|
504
|
+
resume(event.parentToolCallId);
|
|
484
505
|
},
|
|
485
506
|
dispose: (): void => {
|
|
507
|
+
disposed = true;
|
|
486
508
|
for (const handle of pending.values()) clearTimeout(handle);
|
|
487
509
|
pending.clear();
|
|
488
510
|
due.clear();
|
|
@@ -844,9 +866,11 @@ function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string
|
|
|
844
866
|
* reads instead, leaving registration/unsubscription and all metadata intact.
|
|
845
867
|
* Each read wraps the CURRENT callbacks before Pi takes its event snapshot.
|
|
846
868
|
*/
|
|
869
|
+
type ToolCallAdmission = () => ToolCallEventResult | undefined;
|
|
847
870
|
function guardExtensionToolCalls(
|
|
848
871
|
extension: Extension,
|
|
849
872
|
deadlines: WeakMap<object, number>,
|
|
873
|
+
admissions: WeakMap<object, ToolCallAdmission>,
|
|
850
874
|
): Extension {
|
|
851
875
|
type Handler = NonNullable<ReturnType<Extension["handlers"]["get"]>>[number];
|
|
852
876
|
const guarded = new WeakMap<Handler, Handler>();
|
|
@@ -864,18 +888,23 @@ function guardExtensionToolCalls(
|
|
|
864
888
|
return { block: true, reason: "Invalid subagent tool_call event." };
|
|
865
889
|
}
|
|
866
890
|
const deadline = deadlines.get(call);
|
|
867
|
-
|
|
891
|
+
const admission = admissions.get(call);
|
|
892
|
+
if (deadline === undefined || !admission) {
|
|
868
893
|
return { block: true, reason: "Subagent tool policy was bypassed." };
|
|
869
894
|
}
|
|
870
|
-
if (deadline === Infinity) return handler(...args);
|
|
871
895
|
const toolName = Reflect.get(call, "toolName");
|
|
872
896
|
const label = `tool_call for "${typeof toolName === "string" ? toolName : "unknown"}"`;
|
|
873
897
|
const remaining = Math.ceil(deadline - performance.now());
|
|
874
898
|
if (remaining <= 0) return { block: true, reason: `${label} timed out before the extension handler.` };
|
|
875
899
|
try {
|
|
876
|
-
// This race
|
|
877
|
-
|
|
878
|
-
|
|
900
|
+
// This race cannot cancel side effects in a timed-out handler.
|
|
901
|
+
const result = deadline === Infinity ? await handler(...args)
|
|
902
|
+
: await withTimeout(Promise.resolve().then(() => handler(...args)), remaining, label);
|
|
903
|
+
if (deadline <= performance.now()) return { block: true, reason: `${label} timed out` };
|
|
904
|
+
// Later handlers mutate the native args object. Check after EACH
|
|
905
|
+
// handler using the original execution name/schema, not its mutable
|
|
906
|
+
// event.toolName. This admission never invokes askGate again.
|
|
907
|
+
return admission() ?? result;
|
|
879
908
|
} catch (error) {
|
|
880
909
|
// Pi does not catch tool_call handler failures. A timeout must
|
|
881
910
|
// block the tool, not leave a rejected event or an awaited hook.
|
|
@@ -908,6 +937,11 @@ export async function runAgent(
|
|
|
908
937
|
// an already-cancelled caller. This matters for tool-call signals: the
|
|
909
938
|
// caller may be cancelled before this async function gets its first turn.
|
|
910
939
|
throwIfAborted(options.signal);
|
|
940
|
+
// Capture the ROOT peer synchronously, before discovery or resource allocation.
|
|
941
|
+
// Only absence is optional; malformed/duplicate peers must fail closed here.
|
|
942
|
+
const approvalCapture = options.pi.events ? captureApprovalService(options.pi.events) : undefined;
|
|
943
|
+
const approvalService = approvalCapture?.service;
|
|
944
|
+
const approvalRootOrigin = approvalService ? captureQuestionOrigin(ctx) : undefined;
|
|
911
945
|
if (options.supervisorQuestions === "parent" && !options.parentQuestion) {
|
|
912
946
|
throw new Error('Parent clarification requires a manager-issued background channel. Use Agent with run_in_background: true and supervisor_questions: "parent".');
|
|
913
947
|
}
|
|
@@ -1144,11 +1178,15 @@ export async function runAgent(
|
|
|
1144
1178
|
const approvalTimer: ApprovalTimer = {
|
|
1145
1179
|
isInteractiveAsk: (name) => interactiveAskNames.has(name.toLowerCase()),
|
|
1146
1180
|
armApproved: () => {},
|
|
1181
|
+
pauseHuman: () => () => {},
|
|
1147
1182
|
};
|
|
1148
1183
|
// The policy starts a single monotonic deadline for each public tool_call
|
|
1149
1184
|
// event. All subsequently selected extension handlers share its remainder,
|
|
1150
1185
|
// including calls initiated by another tool through ctx.executeTool().
|
|
1151
1186
|
const toolCallDeadlines = new WeakMap<object, number>();
|
|
1187
|
+
const toolCallAdmissions = new WeakMap<object, ToolCallAdmission>();
|
|
1188
|
+
let captureAdmission: (name: string, input: unknown) => ToolCallAdmission =
|
|
1189
|
+
() => () => ({ block: true, reason: "Subagent tool policy is not ready." });
|
|
1152
1190
|
const extensionsOverride = (base: LoadExtensionsResult): LoadExtensionsResult => {
|
|
1153
1191
|
const policy = base.extensions.find((e) => e.path === `<inline:${TOOL_POLICY_EXTENSION}>`);
|
|
1154
1192
|
if (!policy) throw new Error("Subagent tool policy extension failed to load.");
|
|
@@ -1171,7 +1209,7 @@ export async function runAgent(
|
|
|
1171
1209
|
return {
|
|
1172
1210
|
...base,
|
|
1173
1211
|
extensions: [policy, ...selected.filter((e) => e !== policy)
|
|
1174
|
-
.map((extension) => guardExtensionToolCalls(extension, toolCallDeadlines))],
|
|
1212
|
+
.map((extension) => guardExtensionToolCalls(extension, toolCallDeadlines, toolCallAdmissions))],
|
|
1175
1213
|
};
|
|
1176
1214
|
};
|
|
1177
1215
|
|
|
@@ -1179,12 +1217,14 @@ export async function runAgent(
|
|
|
1179
1217
|
// ctx.executeTool calls. A policy factory also loads with noExtensions: true,
|
|
1180
1218
|
// without loading any discovered extension. Deny until the child policy is
|
|
1181
1219
|
// installed, including calls an extension might make during session_start.
|
|
1182
|
-
let checkToolCall: (name: string, input: unknown, deadline: number) => Promise<ToolCallEventResult | undefined> =
|
|
1220
|
+
let checkToolCall: (name: string, input: unknown, deadline: number, admission: ToolCallAdmission) => Promise<ToolCallEventResult | undefined> =
|
|
1183
1221
|
async () => ({ block: true, reason: "Subagent tool policy is not ready." });
|
|
1222
|
+
let childApprovalEvents: ApprovalEvents | undefined;
|
|
1184
1223
|
const extensionFactories: InlineExtension[] = [{
|
|
1185
1224
|
name: TOOL_POLICY_EXTENSION,
|
|
1186
1225
|
hidden: true,
|
|
1187
1226
|
factory: (pi) => {
|
|
1227
|
+
childApprovalEvents = pi.events;
|
|
1188
1228
|
pi.on("tool_call", async (event, hookCtx) => {
|
|
1189
1229
|
if (hookCtx?.signal?.aborted) return { block: true, reason: "Subagent tool call was aborted." };
|
|
1190
1230
|
const waitingForHuman = approvalTimer.isInteractiveAsk(event.toolName);
|
|
@@ -1195,7 +1235,9 @@ export async function runAgent(
|
|
|
1195
1235
|
? performance.now() + defaultToolTimeoutMs
|
|
1196
1236
|
: Infinity;
|
|
1197
1237
|
toolCallDeadlines.set(event, deadline);
|
|
1198
|
-
const
|
|
1238
|
+
const admission = captureAdmission(event.toolName, event.input);
|
|
1239
|
+
toolCallAdmissions.set(event, admission);
|
|
1240
|
+
const decision = await checkToolCall(event.toolName, event.input, deadline, admission);
|
|
1199
1241
|
// Abort cannot interrupt an already-open human dialog on these hosts.
|
|
1200
1242
|
// Do not execute a tool if that dialog eventually approves a stopped run.
|
|
1201
1243
|
if (hookCtx?.signal?.aborted) return { block: true, reason: "Subagent tool call was aborted." };
|
|
@@ -1279,6 +1321,10 @@ export async function runAgent(
|
|
|
1279
1321
|
.flatMap(resource => resolvedNames.get(resource.path) ?? []));
|
|
1280
1322
|
let warnedBackground = false;
|
|
1281
1323
|
const admitChildExtension = (resource: ResolvedResource, intentional: boolean): boolean => {
|
|
1324
|
+
if (approvalService && resolvedNames.get(resource.path)?.includes("pi-approvals")) {
|
|
1325
|
+
if (intentional) throw new Error(`pi-approvals cannot load in agent "${type}"; integrate approvals in the root session instead (requested extension "${resource.path}").`);
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1282
1328
|
if (!resolvedNames.get(resource.path)?.includes("pi-background")) return true;
|
|
1283
1329
|
const reason = `pi-background cannot load in agent "${type}": managed child sessions do not support asynchronous model wakes after completion/worktree cleanup`;
|
|
1284
1330
|
if (intentional) throw new Error(`${reason} (requested extension "${resource.path}").`);
|
|
@@ -1517,9 +1563,34 @@ export async function runAgent(
|
|
|
1517
1563
|
},
|
|
1518
1564
|
})
|
|
1519
1565
|
: [];
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
|
|
1566
|
+
// Reviewed replacements are SDK tools, not inherited extension permissions.
|
|
1567
|
+
// Admit only canonical names already allowed by the child policy.
|
|
1568
|
+
let approvalSession: AgentSession | undefined;
|
|
1569
|
+
// SDK winner metadata alone misses a losing late extension registration.
|
|
1570
|
+
// Inspect live public maps at bind, admission and every execute guard.
|
|
1571
|
+
const hasCanonicalConflict = (name: string): boolean =>
|
|
1572
|
+
loader.getExtensions().extensions.some(extension => extension.tools.has(name));
|
|
1573
|
+
const reviewedTools = approvalCapture && approvalService && approvalRootOrigin && toolNames.some(name => ["edit", "write", "bash"].includes(name) && !disallowedSet?.has(name))
|
|
1574
|
+
? createChildApprovalTools({
|
|
1575
|
+
service: approvalService, root: ctx, rootOrigin: approvalRootOrigin, agentId: options.agentId, settings: settingsManager,
|
|
1576
|
+
assertServiceCurrent: approvalCapture.assertCurrent,
|
|
1577
|
+
isToolCurrent: (name) => approvalSession?.getActiveToolNames().includes(name) === true,
|
|
1578
|
+
waiting: (id) => approvalSession ? approvalTimers.get(approvalSession)?.pauseHuman(id) ?? (() => {}) : () => {},
|
|
1579
|
+
isBound: (): boolean => {
|
|
1580
|
+
const tools = approvalSession?.getAllTools();
|
|
1581
|
+
return [...reviewedNames].every(name => !hasCanonicalConflict(name) && tools?.find(tool => tool.name === name)?.sourceInfo.path === `<sdk:${name}>`);
|
|
1582
|
+
},
|
|
1583
|
+
}).filter(tool => toolNames.includes(tool.name) && !disallowedSet?.has(tool.name))
|
|
1584
|
+
: [];
|
|
1585
|
+
const reviewedNames = new Set(reviewedTools.map(tool => tool.name));
|
|
1586
|
+
for (const extension of loader.getExtensions().extensions) {
|
|
1587
|
+
for (const name of reviewedNames) {
|
|
1588
|
+
if (extension.tools.has(name)) {
|
|
1589
|
+
throw new Error(`Approval-required tool "${name}" was shadowed by "${extension.path}"; remove the conflicting child extension.`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
const injectedTools = [...nestedTools, ...supervisorTools, ...reviewedTools];
|
|
1523
1594
|
const nestedToolNames = new Set(injectedTools.map(tool => tool.name));
|
|
1524
1595
|
const askGate = createAskGate({
|
|
1525
1596
|
askTools: agentConfig?.askTools ?? [],
|
|
@@ -1637,6 +1708,7 @@ export async function runAgent(
|
|
|
1637
1708
|
}
|
|
1638
1709
|
|
|
1639
1710
|
const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
|
|
1711
|
+
approvalSession = session;
|
|
1640
1712
|
approvalTimers.set(session, approvalTimer);
|
|
1641
1713
|
// Install the forwarding listener immediately after session creation, before
|
|
1642
1714
|
// bindExtensions/session_start can do asynchronous work. The old placement
|
|
@@ -1650,12 +1722,42 @@ export async function runAgent(
|
|
|
1650
1722
|
const inScope = noExtensions ? undefined : extensionToolNamesInScope(session, {
|
|
1651
1723
|
loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, resolvedNames,
|
|
1652
1724
|
});
|
|
1653
|
-
|
|
1725
|
+
captureAdmission = (name, input) => {
|
|
1726
|
+
// Pi executes these captured args, even if a handler reassigns event.input.
|
|
1727
|
+
const captured = session.getAllTools().find(tool => tool.name === name);
|
|
1728
|
+
const requiresSchema = !!approvalService && !!captured && schemaRequiresApprovalAdapter(captured.parameters);
|
|
1729
|
+
return () => {
|
|
1730
|
+
if (!approvalService) return undefined;
|
|
1731
|
+
if (reviewedNames.has(name)) {
|
|
1732
|
+
if (hasCanonicalConflict(name) || session.getAllTools().find(tool => tool.name === name)?.sourceInfo.path !== `<sdk:${name}>`)
|
|
1733
|
+
return { block: true, reason: `Approval-required tool "${name}" was shadowed; remove the conflicting child extension.` };
|
|
1734
|
+
return undefined;
|
|
1735
|
+
}
|
|
1736
|
+
const effective = session.getAllTools().find(tool => tool.name === name);
|
|
1737
|
+
if (requiresApprovalAdapter(input) || requiresSchema) {
|
|
1738
|
+
try {
|
|
1739
|
+
if (!captured || !effective || !childApprovalEvents || effective.parameters !== captured.parameters ||
|
|
1740
|
+
effective.description !== captured.description || effective.sourceInfo.path !== captured.sourceInfo.path)
|
|
1741
|
+
throw new Error("Child approval binding changed");
|
|
1742
|
+
queryApprovalAdapter(childApprovalEvents, name, captured.parameters);
|
|
1743
|
+
} catch {
|
|
1744
|
+
return { block: true, reason: `Tool "${name}" requires exactly one live cooperative approval adapter; unreviewed fallback is blocked.` };
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
return undefined;
|
|
1748
|
+
};
|
|
1749
|
+
};
|
|
1750
|
+
checkToolCall = async (name, input, deadline, admission) => {
|
|
1654
1751
|
const run = async (): Promise<ToolCallEventResult | undefined> => {
|
|
1655
1752
|
// Scope first, then approval: never prompt for a tool this child cannot use.
|
|
1656
1753
|
if (inScope && !inScope().has(name)) {
|
|
1657
1754
|
return { block: true, reason: `Tool "${name}" is not available to this subagent.` };
|
|
1658
1755
|
}
|
|
1756
|
+
if (reviewedNames.has(name) && (hasCanonicalConflict(name) || session.getAllTools().find(tool => tool.name === name)?.sourceInfo.path !== `<sdk:${name}>`)) {
|
|
1757
|
+
return { block: true, reason: `Approval-required tool "${name}" was shadowed; only its reviewed SDK binding may execute.` };
|
|
1758
|
+
}
|
|
1759
|
+
const denied = admission();
|
|
1760
|
+
if (denied) return denied;
|
|
1659
1761
|
return askGate?.(name, input);
|
|
1660
1762
|
};
|
|
1661
1763
|
if (deadline === Infinity) return run();
|
|
@@ -1686,6 +1788,11 @@ export async function runAgent(
|
|
|
1686
1788
|
},
|
|
1687
1789
|
});
|
|
1688
1790
|
throwIfAborted(options.signal);
|
|
1791
|
+
for (const name of reviewedNames) {
|
|
1792
|
+
if (hasCanonicalConflict(name) || session.getAllTools().find(tool => tool.name === name)?.sourceInfo.path !== `<sdk:${name}>`) {
|
|
1793
|
+
throw new Error(`Approval-required tool "${name}" was shadowed; remove the conflicting child extension.`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1689
1796
|
|
|
1690
1797
|
// With `allowedToolNames` unset, the ACTIVE set still needs managing: Pi
|
|
1691
1798
|
// activates default built-ins and eligible extension tools at registration.
|
|
@@ -1761,6 +1868,7 @@ export async function runAgent(
|
|
|
1761
1868
|
// `contact_supervisor` is excluded: it intentionally waits for a human.
|
|
1762
1869
|
const toolTimeouts = createToolTimeouts(session, noExtensions ? 0 : TOOL_CALL_ABORT_GRACE_MS, approvalTimer.isInteractiveAsk);
|
|
1763
1870
|
approvalTimer.armApproved = toolTimeouts.armApproved;
|
|
1871
|
+
approvalTimer.pauseHuman = toolTimeouts.pauseHuman;
|
|
1764
1872
|
// Own finalized output before observer dispatch: a synchronous callback
|
|
1765
1873
|
// failure must remain visible without erasing the answer it interrupted.
|
|
1766
1874
|
const collector = collectResponseText(session);
|
|
@@ -1819,6 +1927,7 @@ export async function runAgent(
|
|
|
1819
1927
|
await session.prompt(prompt);
|
|
1820
1928
|
} finally {
|
|
1821
1929
|
approvalTimer.armApproved = () => {};
|
|
1930
|
+
approvalTimer.pauseHuman = () => () => {};
|
|
1822
1931
|
unsubTurns();
|
|
1823
1932
|
collector.unsubscribe();
|
|
1824
1933
|
toolTimeouts.dispose();
|
|
@@ -1890,7 +1999,10 @@ export async function resumeAgent(
|
|
|
1890
1999
|
const approvalTimer = approvalTimers.get(session);
|
|
1891
2000
|
const toolTimeouts = createToolTimeouts(session, TOOL_CALL_ABORT_GRACE_MS,
|
|
1892
2001
|
approvalTimer?.isInteractiveAsk ?? (() => false));
|
|
1893
|
-
if (approvalTimer)
|
|
2002
|
+
if (approvalTimer) {
|
|
2003
|
+
approvalTimer.armApproved = toolTimeouts.armApproved;
|
|
2004
|
+
approvalTimer.pauseHuman = toolTimeouts.pauseHuman;
|
|
2005
|
+
}
|
|
1894
2006
|
const unsubResumeTimeout = session.subscribe((event: AgentSessionEvent) => {
|
|
1895
2007
|
if (event.type === "tool_execution_start") toolTimeouts.start(event);
|
|
1896
2008
|
else if (event.type === "tool_execution_end") toolTimeouts.end(event);
|
|
@@ -1900,7 +2012,10 @@ export async function resumeAgent(
|
|
|
1900
2012
|
throwIfAborted(options.signal);
|
|
1901
2013
|
await session.prompt(prompt);
|
|
1902
2014
|
} finally {
|
|
1903
|
-
if (approvalTimer)
|
|
2015
|
+
if (approvalTimer) {
|
|
2016
|
+
approvalTimer.armApproved = () => {};
|
|
2017
|
+
approvalTimer.pauseHuman = () => () => {};
|
|
2018
|
+
}
|
|
1904
2019
|
collector.unsubscribe();
|
|
1905
2020
|
unsubEvents();
|
|
1906
2021
|
unsubResumeTimeout();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { defineTool, type ExtensionContext, type ExtensionToolContext, type SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type ApprovalService, createApprovalToolDefinitions } from "@signalridge/pi-approval-core";
|
|
4
|
+
import { captureQuestionOrigin } from "./question-origin.js";
|
|
5
|
+
|
|
6
|
+
/** Human service capture and native context leases live for the entire child session. */
|
|
7
|
+
export function createChildApprovalTools(options: {
|
|
8
|
+
service: ApprovalService;
|
|
9
|
+
assertServiceCurrent: () => void;
|
|
10
|
+
root: ExtensionContext;
|
|
11
|
+
rootOrigin: (current: ExtensionContext) => boolean;
|
|
12
|
+
agentId?: string;
|
|
13
|
+
settings: SettingsManager;
|
|
14
|
+
waiting: (toolCallId: string) => () => void;
|
|
15
|
+
isBound: () => boolean;
|
|
16
|
+
isToolCurrent: (name: string) => boolean;
|
|
17
|
+
}): ToolDefinition[] {
|
|
18
|
+
const leases = new WeakMap<ExtensionToolContext, () => boolean>();
|
|
19
|
+
const [edit, write, bash] = createApprovalToolDefinitions({
|
|
20
|
+
source: `child:${options.agentId ?? randomUUID()}`,
|
|
21
|
+
shellPath: options.settings.getShellPath(),
|
|
22
|
+
commandPrefix: options.settings.getShellCommandPrefix(),
|
|
23
|
+
isToolCurrent: (_ctx, name) => options.isToolCurrent(name),
|
|
24
|
+
isCurrent: (ctx) => {
|
|
25
|
+
let lease = leases.get(ctx);
|
|
26
|
+
if (!lease) {
|
|
27
|
+
const childOrigin = captureQuestionOrigin(ctx);
|
|
28
|
+
const invocationRoot = captureQuestionOrigin(options.root);
|
|
29
|
+
lease = () => {
|
|
30
|
+
if (ctx.signal?.aborted || !options.isBound() || !options.rootOrigin(options.root) ||
|
|
31
|
+
!invocationRoot(options.root) || !childOrigin(ctx)) return false;
|
|
32
|
+
options.assertServiceCurrent();
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
leases.set(ctx, lease);
|
|
36
|
+
}
|
|
37
|
+
return lease();
|
|
38
|
+
},
|
|
39
|
+
request: async (operation, owner) => {
|
|
40
|
+
const resume = options.waiting(operation.toolCallId);
|
|
41
|
+
try {
|
|
42
|
+
await options.service.request(operation, owner);
|
|
43
|
+
} finally {
|
|
44
|
+
resume();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return [defineTool(edit), defineTool(write), defineTool(bash)];
|
|
49
|
+
}
|