pi-task-dispatcher 3.1.2 → 3.2.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.
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { spawn, type ChildProcess } from "node:child_process";
6
+ import { randomBytes } from "node:crypto";
6
7
  import * as fs from "node:fs";
7
8
  import * as os from "node:os";
8
9
  import * as path from "node:path";
@@ -214,6 +215,56 @@ interface GateBoundaryMessage {
214
215
  toolResults: number;
215
216
  }
216
217
 
218
+ type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
219
+
220
+ interface ToolNamespaceManifest {
221
+ name: string;
222
+ description?: string;
223
+ instructions?: string;
224
+ }
225
+
226
+ interface ToolAnnotationsManifest {
227
+ readOnlyHint?: boolean;
228
+ destructiveHint?: boolean;
229
+ idempotentHint?: boolean;
230
+ openWorldHint?: boolean;
231
+ }
232
+
233
+ /** A JSON-safe snapshot of one exact manager capability granted to a worker. */
234
+ interface DelegatedToolManifest {
235
+ name: string;
236
+ label: string;
237
+ description: string;
238
+ parameters: { [key: string]: JsonValue };
239
+ outputSchema?: JsonValue;
240
+ namespace?: ToolNamespaceManifest;
241
+ annotations?: ToolAnnotationsManifest;
242
+ promptGuidelines?: string[];
243
+ executionMode?: "sequential" | "parallel";
244
+ fingerprint: string;
245
+ }
246
+
247
+ /** The only model-facing fields that cross the private proxy IPC boundary. */
248
+ interface ProxyWireResult {
249
+ content: JsonValue[];
250
+ structuredContent?: JsonValue;
251
+ isError?: boolean;
252
+ terminate?: boolean;
253
+ }
254
+
255
+ interface ExecutorLease {
256
+ generation: number;
257
+ rootToolCallId: string;
258
+ ctx: ExtensionToolContext;
259
+ signal: AbortSignal | undefined;
260
+ }
261
+
262
+ interface ProxyRequestState {
263
+ lease: ExecutorLease;
264
+ controller: AbortController;
265
+ removeParentAbort: (() => void) | undefined;
266
+ }
267
+
217
268
  type EventUsage = Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
218
269
 
219
270
  const TEST_MODE = process.env.PI_TASK_DISPATCHER_TEST_MODE === "1";
@@ -226,10 +277,13 @@ const MODEL_TEXT_LIMIT = 5_000;
226
277
  const PRESENTATION_PREVIEW_ROWS = 5;
227
278
  const PRESENTATION_UPDATE_THROTTLE_MS = 100;
228
279
  const STDERR_LIMIT = 16_000;
280
+ const PROXY_PAYLOAD_LIMIT_BYTES = 16 * 1024 * 1024;
281
+ const PROXY_MANIFEST_LIMIT_BYTES = 8 * 1024 * 1024;
282
+ const PROXY_MAX_IN_FLIGHT_REQUESTS = 32;
283
+ const PROXY_MAX_REQUESTS_PER_JOB = 4_096;
284
+ const REVOKED_EXECUTOR_ROOT_LIMIT = 4_096;
229
285
  const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
230
286
  const ROLES: Role[] = ["mechanic", "assistant", "engineer", "designer"];
231
- const MUTATING_TOOLS = new Set(["edit", "write", "bash", "powershell", "codemode"]);
232
- const ISOLATED_WORKER_TOOLS = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls", "codemode"]);
233
287
 
234
288
  const DEFAULT_CONFIG: DispatcherConfig = {
235
289
  mechanic: {},
@@ -317,6 +371,81 @@ function isRecord(value: unknown): value is Record<string, unknown> {
317
371
  return typeof value === "object" && value !== null && !Array.isArray(value);
318
372
  }
319
373
 
374
+ /**
375
+ * Copy the JSON-visible surface of a schema or result without silently dropping unsupported values.
376
+ * TypeBox's own symbol markers are deliberately excluded: they are not model-facing JSON Schema.
377
+ */
378
+ function cloneJsonValue(value: unknown, label: string): JsonValue {
379
+ const seen = new WeakSet<object>();
380
+ const visit = (candidate: unknown, location: string): JsonValue => {
381
+ if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return candidate;
382
+ if (typeof candidate === "number") {
383
+ if (!Number.isFinite(candidate) || Object.is(candidate, -0)) throw new Error(`${label} is not JSON-serializable at ${location}`);
384
+ return candidate;
385
+ }
386
+ if (Array.isArray(candidate)) {
387
+ if (seen.has(candidate)) throw new Error(`${label} contains a cycle at ${location}`);
388
+ seen.add(candidate);
389
+ const result = candidate.map((item, index) => visit(item, `${location}[${index}]`));
390
+ seen.delete(candidate);
391
+ return result;
392
+ }
393
+ if (!isRecord(candidate)) throw new Error(`${label} is not JSON-serializable at ${location}`);
394
+ if (seen.has(candidate)) throw new Error(`${label} contains a cycle at ${location}`);
395
+ const prototype = Object.getPrototypeOf(candidate);
396
+ if (prototype !== Object.prototype && prototype !== null) throw new Error(`${label} has an unsupported object at ${location}`);
397
+ const unsupportedSymbols = Object.getOwnPropertySymbols(candidate)
398
+ .filter((symbol) => Object.prototype.propertyIsEnumerable.call(candidate, symbol))
399
+ .filter((symbol) => !String(symbol).startsWith("Symbol(TypeBox."));
400
+ if (unsupportedSymbols.length > 0) throw new Error(`${label} has an unsupported symbol at ${location}`);
401
+ seen.add(candidate);
402
+ const result: { [key: string]: JsonValue } = {};
403
+ for (const key of Object.keys(candidate)) {
404
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
405
+ if (!descriptor || !("value" in descriptor)) throw new Error(`${label} has an accessor at ${location}.${key}`);
406
+ result[key] = visit(descriptor.value, `${location}.${key}`);
407
+ }
408
+ seen.delete(candidate);
409
+ return result;
410
+ };
411
+ return visit(value, "$" );
412
+ }
413
+
414
+ /** Produce a deterministic compatibility fingerprint without changing the model-facing field order. */
415
+ function stableJson(value: JsonValue): string {
416
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
417
+ if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
418
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
419
+ }
420
+
421
+ /** Reject an otherwise JSON-safe proxy value before it can create an unbounded IPC message. */
422
+ function assertJsonByteLimit(value: JsonValue, label: string, maximum: number): void {
423
+ const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
424
+ if (bytes > maximum) throw new Error(`${label} exceeds the ${maximum}-byte proxy limit`);
425
+ }
426
+
427
+ /** Return a failure result that is safe to send over the proxy channel. */
428
+ function proxyErrorResult(message: string): ProxyWireResult {
429
+ return { content: [{ type: "text", text: message }], isError: true };
430
+ }
431
+
432
+ /** Strip parent-only details and usage before a nested manager result reaches the child session. */
433
+ function serializeProxyResult(value: unknown, forceError = false): ProxyWireResult {
434
+ if (!isRecord(value) || !Array.isArray(value.content)) return proxyErrorResult("Manager tool returned an invalid result.");
435
+ try {
436
+ const result: ProxyWireResult = { content: cloneJsonValue(value.content, "manager tool content") as JsonValue[] };
437
+ if (Object.prototype.hasOwnProperty.call(value, "structuredContent")) {
438
+ result.structuredContent = cloneJsonValue(value.structuredContent, "manager tool structured content");
439
+ }
440
+ if (value.isError === true || forceError) result.isError = true;
441
+ if (value.terminate === true) result.terminate = true;
442
+ assertJsonByteLimit(result as unknown as JsonValue, "manager tool result", PROXY_PAYLOAD_LIMIT_BYTES);
443
+ return result;
444
+ } catch (error) {
445
+ return proxyErrorResult(`Manager tool result could not be serialized: ${error instanceof Error ? error.message : String(error)}`);
446
+ }
447
+ }
448
+
320
449
  /** Create a fresh usage accumulator for one worker job. */
321
450
  function emptyUsage(): UsageTotals {
322
451
  return {
@@ -536,6 +665,132 @@ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
536
665
  `;
537
666
  }
538
667
 
668
+ /** Build a child-local proxy for exactly the manager tools granted to this job. */
669
+ function proxyExtensionSource(manifests: readonly DelegatedToolManifest[], nonce: string): string {
670
+ const tools = manifests
671
+ .filter((manifest) => manifest.name !== "codemode")
672
+ .map(({ fingerprint: _fingerprint, ...manifest }) => manifest);
673
+ const configuration = JSON.stringify({ nonce, tools });
674
+ return `/** Route selected worker tool calls through the parent manager's current ExtensionToolContext. */
675
+ import { Type } from "typebox";
676
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
677
+
678
+ type WireResult = { content: unknown[]; structuredContent?: unknown; isError?: boolean; terminate?: boolean };
679
+ type ProxyTool = {
680
+ name: string;
681
+ label: string;
682
+ description: string;
683
+ parameters: Record<string, unknown>;
684
+ outputSchema?: unknown;
685
+ namespace?: { name: string; description?: string; instructions?: string };
686
+ annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
687
+ promptGuidelines?: string[];
688
+ executionMode?: "sequential" | "parallel";
689
+ };
690
+ const configuration = ${configuration} as { nonce: string; tools: ProxyTool[] };
691
+ const payloadLimitBytes = ${PROXY_PAYLOAD_LIMIT_BYTES};
692
+ const maximumInFlightRequests = ${PROXY_MAX_IN_FLIGHT_REQUESTS};
693
+ const textEncoder = new TextEncoder();
694
+ const pending = new Map<string, { resolve: (result: any) => void; onUpdate: ((result: any) => void) | undefined; signal: AbortSignal | undefined; onAbort: (() => void) | undefined }>();
695
+ let requestSequence = 0;
696
+
697
+ function failure(message: string): any {
698
+ return { content: [{ type: "text", text: message }], details: undefined, isError: true };
699
+ }
700
+
701
+ function resultFromWire(value: unknown): any {
702
+ if (!value || typeof value !== "object" || Array.isArray(value)) return failure("Dispatcher proxy received an invalid manager result.");
703
+ try {
704
+ const encoded = JSON.stringify(value);
705
+ if (encoded === undefined || textEncoder.encode(encoded).byteLength > payloadLimitBytes) return failure("Dispatcher proxy received an oversized manager result.");
706
+ } catch {
707
+ return failure("Dispatcher proxy received a non-serializable manager result.");
708
+ }
709
+ const wire = value as WireResult;
710
+ if (!Array.isArray(wire.content)) return failure("Dispatcher proxy received a manager result without content.");
711
+ const result: any = { content: wire.content, details: undefined };
712
+ if (Object.prototype.hasOwnProperty.call(wire, "structuredContent")) result.structuredContent = wire.structuredContent;
713
+ if (wire.isError === true) result.isError = true;
714
+ if (wire.terminate === true) result.terminate = true;
715
+ return result;
716
+ }
717
+
718
+ function send(message: Record<string, unknown>): void {
719
+ if (typeof process.send !== "function") throw new Error("Task dispatcher proxy IPC is unavailable.");
720
+ process.send(message);
721
+ }
722
+
723
+ function finish(requestId: string, result: any): void {
724
+ const request = pending.get(requestId);
725
+ if (!request) return;
726
+ pending.delete(requestId);
727
+ if (request.signal && request.onAbort) request.signal.removeEventListener("abort", request.onAbort);
728
+ request.resolve(result);
729
+ }
730
+
731
+ process.on("message", (incoming: unknown) => {
732
+ if (!incoming || typeof incoming !== "object" || Array.isArray(incoming)) return;
733
+ const message = incoming as Record<string, unknown>;
734
+ if (message.nonce !== configuration.nonce || typeof message.requestId !== "string") return;
735
+ if (message.type === "task_dispatcher_proxy_update") {
736
+ const request = pending.get(message.requestId);
737
+ if (!request) return;
738
+ try { request.onUpdate?.(resultFromWire(message.result)); } catch { /* A renderer must not affect proxy control flow. */ }
739
+ return;
740
+ }
741
+ if (message.type === "task_dispatcher_proxy_result") finish(message.requestId, resultFromWire(message.result));
742
+ });
743
+ process.on("disconnect", () => {
744
+ for (const requestId of [...pending.keys()]) finish(requestId, failure("Task dispatcher parent IPC disconnected."));
745
+ });
746
+
747
+ export default function taskDispatcherProxy(pi: ExtensionAPI): void {
748
+ for (const manifest of configuration.tools) {
749
+ pi.registerTool({
750
+ name: manifest.name,
751
+ label: manifest.label,
752
+ description: manifest.description,
753
+ parameters: Type.Unsafe(manifest.parameters) as any,
754
+ ...(manifest.outputSchema === undefined ? {} : { outputSchema: Type.Unsafe(manifest.outputSchema) as any }),
755
+ ...(manifest.namespace === undefined ? {} : { namespace: manifest.namespace }),
756
+ ...(manifest.annotations === undefined ? {} : { annotations: manifest.annotations }),
757
+ ...(manifest.promptGuidelines === undefined ? {} : { promptGuidelines: manifest.promptGuidelines }),
758
+ ...(manifest.executionMode === undefined ? {} : { executionMode: manifest.executionMode }),
759
+ exposure: "direct",
760
+ defaultActive: true,
761
+ async execute(_toolCallId, args, signal, onUpdate) {
762
+ if (signal?.aborted) return failure("Worker proxy call was already aborted.");
763
+ if (pending.size >= maximumInFlightRequests) return failure("Worker proxy has too many in-flight manager requests.");
764
+ let serializedArgs: unknown;
765
+ try {
766
+ const encoded = JSON.stringify(args);
767
+ if (encoded === undefined) throw new Error("arguments are not JSON-serializable");
768
+ if (textEncoder.encode(encoded).byteLength > payloadLimitBytes) throw new Error("arguments exceed the " + payloadLimitBytes + "-byte proxy limit");
769
+ serializedArgs = JSON.parse(encoded);
770
+ } catch (error) {
771
+ return failure("Worker proxy arguments could not be serialized: " + (error instanceof Error ? error.message : String(error)));
772
+ }
773
+ const requestId = "proxy-" + (++requestSequence);
774
+ return await new Promise<any>((resolve) => {
775
+ const onAbort = () => {
776
+ try { send({ type: "task_dispatcher_proxy_cancel", nonce: configuration.nonce, requestId }); } catch { /* Parent teardown wins. */ }
777
+ finish(requestId, failure("Worker proxy call was aborted."));
778
+ };
779
+ pending.set(requestId, { resolve, onUpdate, signal, onAbort });
780
+ signal?.addEventListener("abort", onAbort, { once: true });
781
+ try {
782
+ send({ type: "task_dispatcher_proxy_request", nonce: configuration.nonce, requestId, toolName: manifest.name, args: serializedArgs });
783
+ } catch (error) {
784
+ finish(requestId, failure("Task dispatcher proxy request failed: " + (error instanceof Error ? error.message : String(error))));
785
+ }
786
+ });
787
+ },
788
+ });
789
+ }
790
+ }
791
+ `;
792
+ }
793
+
539
794
  /** Build the worker-only gate extension that stops the agent loop after persisted tool results. */
540
795
  function gateSource(): string {
541
796
  return `/** Pause this worker after each persisted tool batch so only the parent dispatcher can resume it. */
@@ -560,6 +815,33 @@ function isTerminal(status: JobStatus): boolean {
560
815
  }
561
816
 
562
817
  let nextJobIndex = 1;
818
+ const revokedExecutorRoots = new Set<string>();
819
+ let revokedExecutorGuardSaturated = false;
820
+
821
+ /** Bound stale-lease tracking and fail every otherwise-unattributed nested call after saturation. */
822
+ function revokeExecutorRoot(rootToolCallId: string): void {
823
+ if (revokedExecutorGuardSaturated) return;
824
+ if (revokedExecutorRoots.size >= REVOKED_EXECUTOR_ROOT_LIMIT) {
825
+ revokedExecutorRoots.clear();
826
+ revokedExecutorGuardSaturated = true;
827
+ return;
828
+ }
829
+ revokedExecutorRoots.add(rootToolCallId);
830
+ }
831
+
832
+ /** Reject a nested manager call attributable to a revoked lease. */
833
+ function revokedExecutorDecision(parentToolCallId: string | undefined, toolName: string): { block: boolean; reason: string } | undefined {
834
+ if (!parentToolCallId) return undefined;
835
+ if (revokedExecutorGuardSaturated) {
836
+ return { block: true, reason: `Nested manager tool ${toolName} was rejected because stale-lease tracking reached its fail-closed limit.` };
837
+ }
838
+ for (const rootToolCallId of revokedExecutorRoots) {
839
+ if (parentToolCallId === rootToolCallId || parentToolCallId.startsWith(`${rootToolCallId}/`)) {
840
+ return { block: true, reason: `Nested manager tool ${toolName} was rejected because its executor lease is no longer active.` };
841
+ }
842
+ }
843
+ return undefined;
844
+ }
563
845
 
564
846
  /** Supervise one reusable in-memory Pi worker and expose only boundary-sized state. */
565
847
  class WorkerJob {
@@ -567,6 +849,7 @@ class WorkerJob {
567
849
  readonly role: Role;
568
850
  readonly task: string;
569
851
  readonly tools: string[];
852
+ readonly manifests: readonly DelegatedToolManifest[];
570
853
  readonly model: string;
571
854
  readonly thinking: ThinkingLevel;
572
855
  readonly cwd: string;
@@ -576,6 +859,10 @@ class WorkerJob {
576
859
  readonly usage = emptyUsage();
577
860
  readonly actions: WorkerAction[] = [];
578
861
  readonly context: ContextMetrics;
862
+ private readonly pi: ExtensionAPI;
863
+ private readonly manifestByName: ReadonlyMap<string, DelegatedToolManifest>;
864
+ private readonly callableIdentityByName: ReadonlyMap<string, unknown>;
865
+ private readonly proxyNonce = randomBytes(24).toString("hex");
579
866
 
580
867
  status: JobStatus = "starting";
581
868
  sequence = 0;
@@ -587,6 +874,7 @@ class WorkerJob {
587
874
 
588
875
  private child: ChildProcess | undefined;
589
876
  private temporaryDirectory: string | undefined;
877
+ private temporaryRemoval: Promise<void> | undefined;
590
878
  private stdoutBuffer = "";
591
879
  private stderr = "";
592
880
  private requestSequence = 0;
@@ -610,25 +898,35 @@ class WorkerJob {
610
898
  private progressUpdate: ReturnType<typeof setTimeout> | undefined;
611
899
  private pausedLease: ReturnType<typeof setTimeout> | undefined;
612
900
  private closePromise: Promise<void> | undefined;
901
+ private executorLease: ExecutorLease | undefined;
902
+ private executorLeaseSequence = 0;
903
+ private readonly proxyRequests = new Map<string, ProxyRequestState>();
904
+ private readonly seenProxyRequests = new Set<string>();
613
905
 
614
906
  constructor(
615
907
  role: Role,
616
908
  task: string,
617
- tools: string[],
909
+ manifests: DelegatedToolManifest[],
618
910
  config: ResolvedWorkerRoleConfig,
619
911
  cwd: string,
620
912
  policy: DispatcherPolicy,
621
913
  budget: CalculatedBudget,
914
+ pi: ExtensionAPI,
915
+ callableTools: ExtensionToolContext["tools"],
622
916
  ) {
623
917
  this.id = `${role}-${nextJobIndex++}`;
624
918
  this.role = role;
625
919
  this.task = task;
626
- this.tools = tools;
920
+ this.manifests = manifests.map((manifest) => structuredClone(manifest));
921
+ this.tools = this.manifests.map((manifest) => manifest.name);
922
+ this.manifestByName = new Map(this.manifests.map((manifest) => [manifest.name, manifest]));
923
+ this.callableIdentityByName = new Map(callableTools.map((tool) => [tool.name, tool]));
627
924
  this.model = config.model;
628
925
  this.thinking = config.thinking;
629
926
  this.cwd = cwd;
630
927
  this.policy = policy;
631
928
  this.budget = budget;
929
+ this.pi = pi;
632
930
  this.context = {
633
931
  contextWindow: budget.contextWindow,
634
932
  peakPercent: 0,
@@ -640,12 +938,14 @@ class WorkerJob {
640
938
  }
641
939
 
642
940
  /** Start the isolated child, initialize compaction policy, and execute the first supervised stage. */
643
- async start(signal: AbortSignal | undefined, onProgress: (snapshot: WorkerSnapshot) => void): Promise<WorkerSnapshot> {
941
+ async start(rootToolCallId: string, signal: AbortSignal | undefined, onProgress: (snapshot: WorkerSnapshot) => void, ctx: ExtensionToolContext): Promise<WorkerSnapshot> {
644
942
  this.temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
645
943
  const rolePromptPath = path.join(this.temporaryDirectory, `${this.role}.md`);
944
+ const proxyPath = path.join(this.temporaryDirectory, "tool-proxies.ts");
646
945
  const gatePath = path.join(this.temporaryDirectory, "supervision-gate.ts");
647
946
  const codemodeOnlyPath = path.join(this.temporaryDirectory, "codemode-only.ts");
648
947
  await fs.promises.writeFile(rolePromptPath, ROLE_PROMPTS[this.role], { encoding: "utf8", mode: 0o600 });
948
+ await fs.promises.writeFile(proxyPath, proxyExtensionSource(this.manifests, this.proxyNonce), { encoding: "utf8", mode: 0o600 });
649
949
  await fs.promises.writeFile(gatePath, gateSource(), { encoding: "utf8", mode: 0o600 });
650
950
  if (this.tools.includes("codemode")) {
651
951
  await fs.promises.writeFile(codemodeOnlyPath, codemodeOnlySource(), { encoding: "utf8", mode: 0o600 });
@@ -659,6 +959,7 @@ class WorkerJob {
659
959
  "--thinking", this.thinking,
660
960
  ...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
661
961
  "--append-system-prompt", rolePromptPath,
962
+ "--extension", proxyPath,
662
963
  ...(this.tools.includes("codemode") ? ["--extension", codemodeOnlyPath] : []),
663
964
  "--extension", gatePath,
664
965
  ];
@@ -676,23 +977,32 @@ class WorkerJob {
676
977
  } finally {
677
978
  this.commitActiveElapsed();
678
979
  }
679
- return await this.runStages(`Delegated ${this.role} task:\n${this.task}`, 1, signal, onProgress);
980
+ return await this.runStages(`Delegated ${this.role} task:\n${this.task}`, 1, rootToolCallId, signal, onProgress, ctx);
680
981
  }
681
982
 
682
983
  /** Continue or revise work for a bounded number of action batches. */
683
984
  async continue(
684
985
  instructions: string | undefined,
685
986
  actionBatches: number,
987
+ rootToolCallId: string,
686
988
  signal: AbortSignal | undefined,
687
989
  onProgress: (snapshot: WorkerSnapshot) => void,
990
+ ctx: ExtensionToolContext,
688
991
  ): Promise<WorkerSnapshot> {
689
992
  if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
993
+ try {
994
+ this.revalidateManifest(ctx);
995
+ } catch (error) {
996
+ const reason = `Delegated tool manifest revalidation failed: ${error instanceof Error ? error.message : String(error)}`;
997
+ await this.stop("hard_stopped", reason);
998
+ throw new Error(reason);
999
+ }
690
1000
  this.clearPausedLease();
691
1001
  const message = instructions
692
1002
  ? `The manager revised the delegated task with these controlling instructions:\n${instructions}\nContinue from completed work without repeating it.`
693
1003
  : "Continue the delegated task from the completed tool results. Do not repeat completed work.";
694
1004
  try {
695
- return await this.runStages(message, actionBatches, signal, onProgress);
1005
+ return await this.runStages(message, actionBatches, rootToolCallId, signal, onProgress, ctx);
696
1006
  } catch (error) {
697
1007
  if (this.status === "paused") this.schedulePausedLease();
698
1008
  throw error;
@@ -752,6 +1062,7 @@ class WorkerJob {
752
1062
  /** End the job from parent-observed state without asking the worker to cooperate. */
753
1063
  async stop(status: Extract<JobStatus, "checkpointed" | "taken_over" | "cancelled" | "hard_stopped">, reason: string): Promise<WorkerSnapshot> {
754
1064
  this.clearPausedLease();
1065
+ this.clearExecutorLease();
755
1066
  this.status = status;
756
1067
  this.phase = reason;
757
1068
  this.recommendation = status === "taken_over" ? "manager owns remaining work" : "closed";
@@ -810,6 +1121,7 @@ class WorkerJob {
810
1121
  async dispose(): Promise<void> {
811
1122
  if (this.closePromise) return await this.closePromise;
812
1123
  this.clearPausedLease();
1124
+ this.clearExecutorLease();
813
1125
  if (this.progressUpdate) clearTimeout(this.progressUpdate);
814
1126
  this.progressUpdate = undefined;
815
1127
  this.closePromise = (async () => {
@@ -820,7 +1132,13 @@ class WorkerJob {
820
1132
  new Promise<void>((resolve) => child.once("close", () => resolve())),
821
1133
  new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
822
1134
  ]);
823
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
1135
+ if (child.exitCode === null && child.signalCode === null) {
1136
+ child.kill("SIGKILL");
1137
+ await Promise.race([
1138
+ new Promise<void>((resolve) => child.once("close", () => resolve())),
1139
+ new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
1140
+ ]);
1141
+ }
824
1142
  }
825
1143
  await this.removeTemporaryDirectory();
826
1144
  })();
@@ -850,12 +1168,20 @@ class WorkerJob {
850
1168
  });
851
1169
  child.on("message", (message) => {
852
1170
  this.lastProgressAt = Date.now();
853
- if (!isRecord(message) || message.type !== "task_dispatcher_boundary") return;
854
- const boundary = message as unknown as GateBoundaryMessage;
855
- this.boundaryObserved = true;
856
- this.sequence++;
857
- this.phase = `completed tool batch at worker turn ${boundary.turnIndex}`;
858
- this.boundaryWaiter?.resolve();
1171
+ if (!isRecord(message)) return;
1172
+ if (message.type === "task_dispatcher_boundary") {
1173
+ const boundary = message as unknown as GateBoundaryMessage;
1174
+ this.boundaryObserved = true;
1175
+ this.sequence++;
1176
+ this.phase = `completed tool batch at worker turn ${boundary.turnIndex}`;
1177
+ this.boundaryWaiter?.resolve();
1178
+ return;
1179
+ }
1180
+ if (message.type === "task_dispatcher_proxy_request") {
1181
+ void this.handleProxyRequest(message);
1182
+ return;
1183
+ }
1184
+ if (message.type === "task_dispatcher_proxy_cancel") void this.handleProxyCancel(message);
859
1185
  });
860
1186
  child.on("error", (error) => {
861
1187
  this.diagnostic = error.message;
@@ -925,7 +1251,7 @@ class WorkerJob {
925
1251
  toolCallId: event.toolCallId,
926
1252
  toolName: event.toolName,
927
1253
  label: formatActivity(event.toolName, event.args),
928
- mutating: MUTATING_TOOLS.has(event.toolName),
1254
+ mutating: this.manifestByName.get(event.toolName)?.annotations?.readOnlyHint !== true,
929
1255
  status: "running",
930
1256
  startedAt: Date.now(),
931
1257
  parentToolCallId: event.parentToolCallId,
@@ -1078,10 +1404,14 @@ class WorkerJob {
1078
1404
  private async runStages(
1079
1405
  initialMessage: string,
1080
1406
  actionBatches: number,
1407
+ rootToolCallId: string,
1081
1408
  signal: AbortSignal | undefined,
1082
1409
  onProgress: (snapshot: WorkerSnapshot) => void,
1410
+ ctx: ExtensionToolContext,
1083
1411
  ): Promise<WorkerSnapshot> {
1084
- let message = initialMessage;
1412
+ const lease = this.openExecutorLease(rootToolCallId, ctx, signal);
1413
+ try {
1414
+ let message = initialMessage;
1085
1415
  for (let batch = 0; batch < actionBatches; batch++) {
1086
1416
  this.assertBudget();
1087
1417
  const actionBaseline = this.actionSequence;
@@ -1165,7 +1495,10 @@ class WorkerJob {
1165
1495
  this.schedulePausedLease();
1166
1496
  return this.snapshot();
1167
1497
  }
1168
- throw new Error("Unreachable supervised stage state.");
1498
+ throw new Error("Unreachable supervised stage state.");
1499
+ } finally {
1500
+ this.clearExecutorLease(lease);
1501
+ }
1169
1502
  }
1170
1503
 
1171
1504
  /** Wait for settlement while enforcing job deadline and no-progress policy externally. */
@@ -1229,6 +1562,191 @@ class WorkerJob {
1229
1562
  return "continue, revise, compact, checkpoint, cancel, or take over";
1230
1563
  }
1231
1564
 
1565
+ /** Rebuild the grant from this invocation's callable manager tools before a worker can resume. */
1566
+ private revalidateManifest(ctx: ExtensionToolContext): void {
1567
+ const current = buildDelegatedToolManifests(this.tools, this.pi, ctx);
1568
+ if (current.length !== this.manifests.length) throw new Error("the selected tool count changed");
1569
+ const currentIdentities = new Map(ctx.tools.map((tool) => [tool.name, tool]));
1570
+ for (let index = 0; index < current.length; index++) {
1571
+ const expected = this.manifests[index];
1572
+ const observed = current[index];
1573
+ if (expected.name !== observed.name || expected.fingerprint !== observed.fingerprint) {
1574
+ throw new Error(`selected tool ${expected.name} is no longer definition-compatible`);
1575
+ }
1576
+ if (expected.name !== "codemode" && this.callableIdentityByName.get(expected.name) !== currentIdentities.get(expected.name)) {
1577
+ throw new Error(`selected tool ${expected.name} no longer has the original callable identity`);
1578
+ }
1579
+ }
1580
+ }
1581
+
1582
+ /** Enforce selected capabilities during a lease and reject delayed calls from every revoked lease. */
1583
+ authorizeNestedToolCall(_toolCallId: string, parentToolCallId: string | undefined, toolName: string): { block: boolean; reason: string } | undefined {
1584
+ if (!parentToolCallId) return undefined;
1585
+ const lease = this.executorLease;
1586
+ if (lease && (parentToolCallId === lease.rootToolCallId || parentToolCallId.startsWith(`${lease.rootToolCallId}/`))) {
1587
+ if (toolName === "codemode" || !this.manifestByName.has(toolName)) {
1588
+ return { block: true, reason: `Nested manager tool ${toolName} is outside this delegation's selected capability grant.` };
1589
+ }
1590
+ return undefined;
1591
+ }
1592
+ return revokedExecutorDecision(parentToolCallId, toolName);
1593
+ }
1594
+
1595
+ /** Grant parent execution only while a start or continuation call actively owns the worker. */
1596
+ private openExecutorLease(rootToolCallId: string, ctx: ExtensionToolContext, signal: AbortSignal | undefined): ExecutorLease {
1597
+ if (this.executorLease) throw new Error("A manager executor lease is already active for this worker.");
1598
+ const lease: ExecutorLease = { generation: ++this.executorLeaseSequence, rootToolCallId, ctx, signal };
1599
+ this.executorLease = lease;
1600
+ return lease;
1601
+ }
1602
+
1603
+ /** Revoke parent execution before exposing an idle, paused, or terminal worker state. */
1604
+ private clearExecutorLease(expected?: ExecutorLease): void {
1605
+ if (expected && this.executorLease !== expected) return;
1606
+ const lease = this.executorLease;
1607
+ if (!lease) return;
1608
+ this.executorLease = undefined;
1609
+ revokeExecutorRoot(lease.rootToolCallId);
1610
+ for (const [requestId, request] of this.proxyRequests) {
1611
+ if (request.lease !== lease) continue;
1612
+ this.proxyRequests.delete(requestId);
1613
+ request.removeParentAbort?.();
1614
+ request.removeParentAbort = undefined;
1615
+ request.controller.abort();
1616
+ }
1617
+ }
1618
+
1619
+ /** Deliver a nonce-bound manager result or streaming update to the private child proxy. */
1620
+ private sendProxy(message: Record<string, unknown>): void {
1621
+ try {
1622
+ if (this.child?.connected) this.child.send(message);
1623
+ } catch {
1624
+ // Child teardown wins over a late nested-tool result.
1625
+ }
1626
+ }
1627
+
1628
+ /** Reject a malformed, stale, replayed, cross-job, or otherwise unauthorized proxy call. */
1629
+ private rejectProxyRequest(requestId: unknown, reason: string): void {
1630
+ if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
1631
+ this.sendProxy({
1632
+ type: "task_dispatcher_proxy_result",
1633
+ nonce: this.proxyNonce,
1634
+ requestId,
1635
+ result: proxyErrorResult(reason),
1636
+ });
1637
+ }
1638
+
1639
+ /** Start one parent-side nested tool call while preserving manager hooks, validation, and cancellation. */
1640
+ private async handleProxyRequest(message: Record<string, unknown>): Promise<void> {
1641
+ const requestId = message.requestId;
1642
+ if (message.nonce !== this.proxyNonce) {
1643
+ this.rejectProxyRequest(requestId, "Worker proxy request was rejected because its job nonce is invalid.");
1644
+ return;
1645
+ }
1646
+ if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
1647
+ if (this.seenProxyRequests.has(requestId)) {
1648
+ this.rejectProxyRequest(requestId, "Worker proxy request was rejected because its request id was replayed.");
1649
+ return;
1650
+ }
1651
+ if (this.seenProxyRequests.size >= PROXY_MAX_REQUESTS_PER_JOB) {
1652
+ this.rejectProxyRequest(requestId, "Worker proxy request was rejected because the per-job request limit was reached.");
1653
+ return;
1654
+ }
1655
+ this.seenProxyRequests.add(requestId);
1656
+ const lease = this.executorLease;
1657
+ if (!lease || this.status !== "running") {
1658
+ this.rejectProxyRequest(requestId, "Worker proxy request was rejected because no bounded manager executor lease is active.");
1659
+ return;
1660
+ }
1661
+ if (typeof message.toolName !== "string" || message.toolName === "codemode" || !this.manifestByName.has(message.toolName)) {
1662
+ this.rejectProxyRequest(requestId, "Worker proxy request was rejected because the requested tool is not in this job's selected capability grant.");
1663
+ return;
1664
+ }
1665
+ if (this.proxyRequests.size >= PROXY_MAX_IN_FLIGHT_REQUESTS) {
1666
+ this.rejectProxyRequest(requestId, "Worker proxy request was rejected because too many manager executions are in flight.");
1667
+ return;
1668
+ }
1669
+ try {
1670
+ this.revalidateManifest(lease.ctx);
1671
+ } catch (error) {
1672
+ const reason = `Delegated tool grant changed before ${message.toolName} execution: ${error instanceof Error ? error.message : String(error)}`;
1673
+ this.rejectProxyRequest(requestId, reason);
1674
+ void this.stop("hard_stopped", reason);
1675
+ return;
1676
+ }
1677
+ let args: JsonValue;
1678
+ try {
1679
+ args = cloneJsonValue(message.args, `worker proxy arguments for ${message.toolName}`);
1680
+ assertJsonByteLimit(args, `worker proxy arguments for ${message.toolName}`, PROXY_PAYLOAD_LIMIT_BYTES);
1681
+ } catch (error) {
1682
+ this.rejectProxyRequest(requestId, `Worker proxy request arguments are invalid: ${error instanceof Error ? error.message : String(error)}`);
1683
+ return;
1684
+ }
1685
+ const controller = new AbortController();
1686
+ const abortFromParent = () => controller.abort();
1687
+ if (lease.signal?.aborted) controller.abort();
1688
+ else lease.signal?.addEventListener("abort", abortFromParent, { once: true });
1689
+ const request: ProxyRequestState = {
1690
+ lease,
1691
+ controller,
1692
+ removeParentAbort: lease.signal ? () => lease.signal?.removeEventListener("abort", abortFromParent) : undefined,
1693
+ };
1694
+ this.proxyRequests.set(requestId, request);
1695
+ void (async () => {
1696
+ try {
1697
+ const outcome = await lease.ctx.executeTool(message.toolName as string, args, {
1698
+ signal: controller.signal,
1699
+ onUpdate: (partial) => {
1700
+ if (this.executorLease !== lease || this.proxyRequests.get(requestId) !== request) return;
1701
+ this.sendProxy({
1702
+ type: "task_dispatcher_proxy_update",
1703
+ nonce: this.proxyNonce,
1704
+ requestId,
1705
+ result: serializeProxyResult(partial),
1706
+ });
1707
+ },
1708
+ });
1709
+ if (this.executorLease === lease && this.proxyRequests.get(requestId) === request) {
1710
+ this.sendProxy({
1711
+ type: "task_dispatcher_proxy_result",
1712
+ nonce: this.proxyNonce,
1713
+ requestId,
1714
+ result: serializeProxyResult(outcome.result, outcome.isError),
1715
+ });
1716
+ }
1717
+ } catch (error) {
1718
+ if (this.executorLease === lease && this.proxyRequests.get(requestId) === request) {
1719
+ this.sendProxy({
1720
+ type: "task_dispatcher_proxy_result",
1721
+ nonce: this.proxyNonce,
1722
+ requestId,
1723
+ result: proxyErrorResult(`Manager execution of ${message.toolName as string} failed: ${error instanceof Error ? error.message : String(error)}`),
1724
+ });
1725
+ }
1726
+ } finally {
1727
+ request.removeParentAbort?.();
1728
+ request.removeParentAbort = undefined;
1729
+ if (this.proxyRequests.get(requestId) === request) this.proxyRequests.delete(requestId);
1730
+ }
1731
+ })();
1732
+ }
1733
+
1734
+ /** Propagate a child-side proxy cancellation to the current manager nested-tool signal. */
1735
+ private handleProxyCancel(message: Record<string, unknown>): void {
1736
+ const requestId = message.requestId;
1737
+ if (message.nonce !== this.proxyNonce) {
1738
+ this.rejectProxyRequest(requestId, "Worker proxy cancellation was rejected because its job nonce is invalid.");
1739
+ return;
1740
+ }
1741
+ if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
1742
+ const request = this.proxyRequests.get(requestId);
1743
+ if (!request || request.lease !== this.executorLease || this.status !== "running") {
1744
+ this.rejectProxyRequest(requestId, "Worker proxy cancellation was rejected because the request is unknown or no longer active.");
1745
+ return;
1746
+ }
1747
+ request.controller.abort();
1748
+ }
1749
+
1232
1750
  /** Correlate one RPC request; an uncertain timeout closes the worker rather than permitting a racing command. */
1233
1751
  private async request(label: string, payload: Record<string, unknown>, timeoutMs: number): Promise<ParsedEvent> {
1234
1752
  const child = this.child;
@@ -1256,6 +1774,7 @@ class WorkerJob {
1256
1774
 
1257
1775
  /** Reject all consumers when the worker process can no longer produce state. */
1258
1776
  private failPending(error: Error): void {
1777
+ this.clearExecutorLease();
1259
1778
  for (const pending of this.pendingRequests.values()) pending.reject(error);
1260
1779
  this.pendingRequests.clear();
1261
1780
  this.settleWaiter?.reject(error);
@@ -1278,9 +1797,12 @@ class WorkerJob {
1278
1797
 
1279
1798
  /** Remove runtime-only worker instructions after process termination without touching workspace artifacts. */
1280
1799
  private async removeTemporaryDirectory(): Promise<void> {
1800
+ if (this.temporaryRemoval) return await this.temporaryRemoval;
1281
1801
  const directory = this.temporaryDirectory;
1282
1802
  this.temporaryDirectory = undefined;
1283
- if (directory) await fs.promises.rm(directory, { recursive: true, force: true });
1803
+ if (!directory) return;
1804
+ this.temporaryRemoval = fs.promises.rm(directory, { recursive: true, force: true });
1805
+ return await this.temporaryRemoval;
1284
1806
  }
1285
1807
 
1286
1808
  /** Coalesce token and partial-result bursts before rebuilding and serializing the complete snapshot. */
@@ -1353,20 +1875,123 @@ function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext
1353
1875
  };
1354
1876
  }
1355
1877
 
1356
- /** Restrict delegations to active, non-recursive isolated-worker tools selected by the manager. */
1357
- function validateDelegatedTools(requested: string[], pi: ExtensionAPI): string[] {
1358
- const tools = [...new Set(requested.map((tool) => tool.trim()).filter(Boolean))];
1878
+ /** Build exact child manifests from manager-callable tools plus the explicitly active child-local codemode. */
1879
+ function buildDelegatedToolManifests(requested: string[], pi: ExtensionAPI, ctx: ExtensionToolContext): DelegatedToolManifest[] {
1880
+ const malformed = requested.filter((name) => name.trim() !== name || name.length === 0);
1881
+ if (malformed.length > 0) throw new Error(`Delegated tool names must be exact nonempty names: ${malformed.join(", ")}`);
1882
+ const duplicates = requested.filter((name, index) => requested.indexOf(name) !== index);
1883
+ if (duplicates.length > 0) throw new Error(`Delegated tool names must not be repeated: ${[...new Set(duplicates)].join(", ")}`);
1359
1884
  const forbidden = new Set([...Object.values(ROLE_TOOL_NAMES), "delegate_control", "delegate_status"]);
1360
- const recursive = tools.filter((tool) => forbidden.has(tool));
1361
- if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated: ${recursive.join(", ")}`);
1885
+ const recursive = requested.filter((name) => forbidden.has(name));
1886
+ if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated recursively: ${recursive.join(", ")}`);
1887
+ if (typeof ctx.executeTool !== "function") throw new Error("The current manager tool context does not expose ctx.executeTool().");
1888
+ const callable = new Map(ctx.tools.map((tool) => [tool.name, tool]));
1889
+ const metadata = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
1362
1890
  const active = new Set(pi.getActiveTools());
1363
- const unavailable = tools.filter((tool) => !active.has(tool));
1364
- if (unavailable.length > 0) throw new Error(`Delegated tools are not active for the manager: ${unavailable.join(", ")}`);
1365
- const unsupportedTools = tools.filter((tool) => !ISOLATED_WORKER_TOOLS.has(tool));
1366
- if (unsupportedTools.length > 0) {
1367
- throw new Error(`Isolated workers do not support these tools: ${unsupportedTools.join(", ")}`);
1891
+ const unavailable = requested.filter((name) => name === "codemode"
1892
+ ? !active.has(name) || !metadata.has(name)
1893
+ : !callable.has(name));
1894
+ if (unavailable.length > 0) {
1895
+ const detail = unavailable.map((name) => {
1896
+ if (name === "codemode" && metadata.has(name)) return `${name} (registered but not active in the manager)`;
1897
+ return metadata.has(name)
1898
+ ? `${name} (registered but not callable through ctx.executeTool())`
1899
+ : `${name} (not registered or callable through ctx.executeTool())`;
1900
+ });
1901
+ throw new Error(`Requested delegated tools are unavailable: ${detail.join(", ")}`);
1368
1902
  }
1369
- return tools;
1903
+ const manifests = requested.map((name) => {
1904
+ const localCodemode = name === "codemode";
1905
+ const tool = callable.get(name);
1906
+ const info = metadata.get(name);
1907
+ if (!info) throw new Error(`Delegated tool ${name} does not expose registered public metadata.`);
1908
+ const label = localCodemode ? "Codemode" : tool?.label;
1909
+ const description = localCodemode ? info.description : tool?.description;
1910
+ if (typeof label !== "string" || typeof description !== "string") {
1911
+ throw new Error(`Delegated tool ${name} does not expose serializable label and description metadata.`);
1912
+ }
1913
+ const parameters = cloneJsonValue(localCodemode ? info.parameters : tool?.parameters, `${name} parameter schema`);
1914
+ if (!isRecord(parameters)) throw new Error(`Delegated tool ${name} parameter schema must be a JSON object.`);
1915
+ const outputSchema = localCodemode || tool?.outputSchema === undefined
1916
+ ? undefined
1917
+ : cloneJsonValue(tool.outputSchema, `${name} output schema`);
1918
+ const rawSourceInfo = info.sourceInfo;
1919
+ if (!isRecord(rawSourceInfo)
1920
+ || typeof rawSourceInfo.path !== "string"
1921
+ || typeof rawSourceInfo.source !== "string"
1922
+ || !new Set(["user", "project", "temporary"]).has(rawSourceInfo.scope as string)
1923
+ || !new Set(["package", "top-level"]).has(rawSourceInfo.origin as string)
1924
+ || (rawSourceInfo.baseDir !== undefined && typeof rawSourceInfo.baseDir !== "string")) {
1925
+ throw new Error(`Delegated tool ${name} has unsupported source metadata.`);
1926
+ }
1927
+ const sourceInfo = cloneJsonValue({
1928
+ path: rawSourceInfo.path,
1929
+ source: rawSourceInfo.source,
1930
+ scope: rawSourceInfo.scope,
1931
+ origin: rawSourceInfo.origin,
1932
+ ...(rawSourceInfo.baseDir === undefined ? {} : { baseDir: rawSourceInfo.baseDir }),
1933
+ }, `${name} source metadata`);
1934
+ let namespace: ToolNamespaceManifest | undefined;
1935
+ if (info?.namespace !== undefined) {
1936
+ if (!isRecord(info.namespace) || typeof info.namespace.name !== "string" || info.namespace.name.length === 0) {
1937
+ throw new Error(`Callable tool ${name} has unsupported namespace metadata.`);
1938
+ }
1939
+ if (info.namespace.description !== undefined && typeof info.namespace.description !== "string") throw new Error(`Callable tool ${name} has unsupported namespace description metadata.`);
1940
+ if (info.namespace.instructions !== undefined && typeof info.namespace.instructions !== "string") throw new Error(`Callable tool ${name} has unsupported namespace instructions metadata.`);
1941
+ namespace = {
1942
+ name: info.namespace.name,
1943
+ ...(info.namespace.description === undefined ? {} : { description: info.namespace.description }),
1944
+ ...(info.namespace.instructions === undefined ? {} : { instructions: info.namespace.instructions }),
1945
+ };
1946
+ }
1947
+ let annotations: ToolAnnotationsManifest | undefined;
1948
+ if (info?.annotations !== undefined) {
1949
+ if (!isRecord(info.annotations)) throw new Error(`Callable tool ${name} has unsupported annotations metadata.`);
1950
+ annotations = {};
1951
+ for (const key of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const) {
1952
+ const value = info.annotations[key];
1953
+ if (value !== undefined && typeof value !== "boolean") throw new Error(`Callable tool ${name} has unsupported ${key} metadata.`);
1954
+ if (value !== undefined) annotations[key] = value;
1955
+ }
1956
+ }
1957
+ let promptGuidelines: string[] | undefined;
1958
+ if (info?.promptGuidelines !== undefined) {
1959
+ if (!Array.isArray(info.promptGuidelines) || info.promptGuidelines.some((guideline) => typeof guideline !== "string")) {
1960
+ throw new Error(`Callable tool ${name} has unsupported prompt guideline metadata.`);
1961
+ }
1962
+ promptGuidelines = [...info.promptGuidelines];
1963
+ }
1964
+ const executionMode = localCodemode ? "sequential" : tool?.executionMode;
1965
+ if (executionMode !== undefined && executionMode !== "sequential" && executionMode !== "parallel") {
1966
+ throw new Error(`Delegated tool ${name} has unsupported execution-mode metadata.`);
1967
+ }
1968
+ const comparable = cloneJsonValue({
1969
+ name,
1970
+ label,
1971
+ description,
1972
+ parameters,
1973
+ ...(outputSchema === undefined ? {} : { outputSchema }),
1974
+ ...(namespace === undefined ? {} : { namespace }),
1975
+ ...(annotations === undefined ? {} : { annotations }),
1976
+ ...(promptGuidelines === undefined ? {} : { promptGuidelines }),
1977
+ ...(executionMode === undefined ? {} : { executionMode }),
1978
+ sourceInfo,
1979
+ }, `${name} delegated manifest`);
1980
+ return {
1981
+ name,
1982
+ label,
1983
+ description,
1984
+ parameters,
1985
+ ...(outputSchema === undefined ? {} : { outputSchema }),
1986
+ ...(namespace === undefined ? {} : { namespace }),
1987
+ ...(annotations === undefined ? {} : { annotations }),
1988
+ ...(promptGuidelines === undefined ? {} : { promptGuidelines }),
1989
+ ...(executionMode === undefined ? {} : { executionMode }),
1990
+ fingerprint: stableJson(comparable),
1991
+ };
1992
+ });
1993
+ assertJsonByteLimit(manifests as unknown as JsonValue, "delegated tool manifest", PROXY_MANIFEST_LIMIT_BYTES);
1994
+ return manifests;
1370
1995
  }
1371
1996
 
1372
1997
  /** Format context pressure against the worker model's own physical context window. */
@@ -1673,7 +2298,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
1673
2298
  renderResult(result, { expanded, isPartial }, theme, context) {
1674
2299
  return renderWorkerResult(result as any, expanded, isPartial, theme, context);
1675
2300
  },
1676
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
2301
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1677
2302
  try {
1678
2303
  if (activeJob && !isTerminal(activeJob.status)) throw new Error(`${activeJob.id} is still ${activeJob.status}.`);
1679
2304
  if (activeJob) {
@@ -1681,16 +2306,16 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
1681
2306
  activeJob = undefined;
1682
2307
  }
1683
2308
  const config = loadConfig();
1684
- const tools = validateDelegatedTools(params.tools, pi);
2309
+ const manifests = buildDelegatedToolManifests(params.tools, pi, ctx);
1685
2310
  const resolved = resolveWorkerConfig(config[role], ctx);
1686
2311
  const budget = calculateBudget(role, params.workload, resolved.contextWindow, resolved.maxTokens, config.policy);
1687
- const job = new WorkerJob(role, params.task, tools, resolved.role, ctx.cwd, config.policy, budget);
2312
+ const job = new WorkerJob(role, params.task, manifests, resolved.role, ctx.cwd, config.policy, budget, pi, ctx.tools);
1688
2313
  activeJob = job;
1689
2314
  const progress = (snapshot: WorkerSnapshot) => {
1690
2315
  updateWorkerStatus(ctx, snapshot);
1691
2316
  onUpdate?.({ content: [{ type: "text", text: formatSnapshot(snapshot) }], details: snapshot });
1692
2317
  };
1693
- const snapshot = await job.start(signal, progress);
2318
+ const snapshot = await job.start(toolCallId, signal, progress, ctx);
1694
2319
  updateWorkerStatus(ctx, snapshot);
1695
2320
  return snapshotResult(snapshot, snapshot.status === "failed" || snapshot.status === "hard_stopped");
1696
2321
  } catch (error) {
@@ -1734,7 +2359,7 @@ function registerControl(pi: ExtensionAPI): void {
1734
2359
  renderResult(result, { expanded, isPartial }, theme, context) {
1735
2360
  return renderWorkerResult(result as any, expanded, isPartial, theme, context);
1736
2361
  },
1737
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
2362
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1738
2363
  try {
1739
2364
  const job = activeJob;
1740
2365
  if (!job || job.id !== params.jobId) throw new Error(`No live delegation matches ${params.jobId}.`);
@@ -1757,7 +2382,7 @@ function registerControl(pi: ExtensionAPI): void {
1757
2382
  updateWorkerStatus(ctx, state);
1758
2383
  onUpdate?.({ content: [{ type: "text", text: formatSnapshot(state) }], details: state });
1759
2384
  };
1760
- snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, signal, progress);
2385
+ snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, toolCallId, signal, progress, ctx);
1761
2386
  }
1762
2387
  snapshot.managerAction = params.action;
1763
2388
  updateWorkerStatus(ctx, snapshot);
@@ -1807,8 +2432,13 @@ export default function taskDispatcher(pi: ExtensionAPI): void {
1807
2432
  for (const role of ROLES) registerDelegate(pi, role);
1808
2433
  registerControl(pi);
1809
2434
  registerStatus(pi);
2435
+ pi.on("tool_call", (event) => activeJob
2436
+ ? activeJob.authorizeNestedToolCall(event.toolCallId, event.parentToolCallId, event.toolName)
2437
+ : revokedExecutorDecision(event.parentToolCallId, event.toolName));
1810
2438
  pi.on("session_shutdown", async () => {
1811
2439
  if (activeJob) await activeJob.dispose();
1812
2440
  activeJob = undefined;
2441
+ revokedExecutorRoots.clear();
2442
+ revokedExecutorGuardSaturated = false;
1813
2443
  });
1814
2444
  }