@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 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.13.0",
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",
@@ -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 armApproved instead of arming a stale timer.
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
- const parent = paused.get(event.parentToolCallId);
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
- if (parent.children.size > 0) return;
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
- if (deadline === undefined) {
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 blocks the call; it cannot cancel side effects of
877
- // a handler that keeps running after its Promise loses.
878
- return await withTimeout(Promise.resolve().then(() => handler(...args)), remaining, label);
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 decision = await checkToolCall(event.toolName, event.input, deadline);
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
- // One list from here on: both sets are custom tools this package injects, and
1521
- // the scoping pass below keeps exactly the names it is given.
1522
- const injectedTools = [...nestedTools, ...supervisorTools];
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
- checkToolCall = async (name, input, deadline) => {
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) approvalTimer.armApproved = toolTimeouts.armApproved;
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) approvalTimer.armApproved = () => {};
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
+ }