pi-ptc-subagents 0.1.0 → 0.1.2

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/dist/index.js CHANGED
@@ -1,26 +1,30 @@
1
+ import { a as describeValue, i as WORKER_FRAME_KIND, n as PTC_ERROR_KIND, o as isPtcHostFrame, r as PTC_LOG_LEVEL, s as isPtcWorkerFrame, t as HOST_FRAME_KIND } from "./protocol-DUnAP1Ro.js";
1
2
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, defineTool, formatSize, formatSkillsForPrompt, getAgentDir, truncateTail } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
4
  import { validateToolArguments } from "@earendil-works/pi-ai";
4
5
  import { spawn } from "node:child_process";
5
6
  import * as fs from "node:fs";
6
- import { readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import * as os from "node:os";
8
9
  import { tmpdir } from "node:os";
9
10
  import * as path from "node:path";
10
- import { join } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
11
12
  import { randomUUID } from "node:crypto";
12
- import { MessageChannel, MessagePort, Worker } from "node:worker_threads";
13
+ import { MessageChannel, Worker } from "node:worker_threads";
14
+ import diagnosticsChannel from "node:diagnostics_channel";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
16
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
17
  //#region src/runtime/dispatch.ts
15
18
  /**
16
- * pi.dispatch: a parallel binding that spawns a fresh pi subprocess per call.
19
+ * pi.dispatch: the parallel binding that spawns a fresh pi subprocess per call.
17
20
  *
18
- * ADR-0016 (2026-09-23) is the contract. This file is the type surface and the spawn
19
- * skeleton; the actual subprocess plumbing (agent-md resolution, JSON-line parser,
20
- * usage accumulator, signal propagation across worker_threads / child_process) is a
21
- * follow-up. The stubs here are typed against the contract so call sites can land
22
- * before the spawn path is finalised, and so a wrong shape is a compile error rather
23
- * than a runtime one.
21
+ * ADR-0016 (2026-09-23) is the contract, and this file is the whole implementation:
22
+ * agent-markdown discovery (`discoverAgent`, with the agentScope user/project split),
23
+ * the recursion-depth hint appended to the child's system prompt (`appendDepthHint`),
24
+ * the argv the child pi is launched with (`buildArgv`), and `dispatch()` itself —
25
+ * spawn, JSON-line event parsing, usage accumulation, the close-outcome decision
26
+ * (`decideCloseOutcome`), and SIGTERM → SIGKILL signal propagation when the run that
27
+ * issued the dispatch is cancelled.
24
28
  *
25
29
  * Behaviourally compatible with pi's examples/extensions/subagent/index.ts reference
26
30
  * (--mode json, -p, --no-session, --append-system-prompt <tmpfile>), but not cooperative:
@@ -60,15 +64,32 @@ function appendDepthHint(systemPrompt, depth, maxDepth) {
60
64
  ].join("\n");
61
65
  return systemPrompt.length === 0 ? hint : systemPrompt + "\n\n" + hint;
62
66
  }
63
- /** Default error returned when the depth limit is exceeded. */
64
- function dispatchDepthLimitReached(currentDepth) {
67
+ /** Result returned when the depth limit is exceeded. */
68
+ function dispatchDepthLimitReached() {
65
69
  return {
66
70
  text: "",
67
71
  status: "rejected",
68
72
  agentName: "unknown",
69
73
  durationMs: 0,
70
74
  exitCode: -1,
71
- errorMessage: "dispatch depth limit reached (current depth " + currentDepth + ", max-depth exceeded)"
75
+ errorMessage: "dispatch depth limit reached"
76
+ };
77
+ }
78
+ /**
79
+ * Verbatim ADR-0016 §2 message for the per-run dispatch concurrency cap. Exported as a
80
+ * named constant so the contract string has one definition and cannot drift (tests pin
81
+ * it character for character).
82
+ */
83
+ const DISPATCH_CONCURRENCY_LIMIT_MESSAGE = "dispatch concurrency limit reached";
84
+ /** Result returned when the per-run dispatch concurrency cap is exceeded (ADR-0016 §2). */
85
+ function dispatchConcurrencyLimitReached() {
86
+ return {
87
+ text: "",
88
+ status: "rejected",
89
+ agentName: "unknown",
90
+ durationMs: 0,
91
+ exitCode: -1,
92
+ errorMessage: DISPATCH_CONCURRENCY_LIMIT_MESSAGE
72
93
  };
73
94
  }
74
95
  /**
@@ -278,7 +299,7 @@ function cleanupTmp(tmp) {
278
299
  */
279
300
  async function dispatch(input, ctx) {
280
301
  const childDepth = ctx.depth + 1;
281
- if (childDepth > ctx.maxDepth) return dispatchDepthLimitReached(ctx.depth);
302
+ if (childDepth > ctx.maxDispatchDepth) return dispatchDepthLimitReached();
282
303
  const cwd = input.cwd ?? ctx.cwd;
283
304
  const agentScope = input.agentScope ?? "user";
284
305
  const start = Date.now();
@@ -291,7 +312,7 @@ async function dispatch(input, ctx) {
291
312
  exitCode: 1,
292
313
  errorMessage: "unknown agent: " + input.agent + " (agentScope=" + agentScope + ", cwd=" + cwd + ")"
293
314
  };
294
- const fullPrompt = appendDepthHint(agent.systemPrompt, childDepth, ctx.maxDepth);
315
+ const fullPrompt = appendDepthHint(agent.systemPrompt, childDepth, ctx.maxDispatchDepth);
295
316
  const tmp = await writePromptToTempFile(agent.name, fullPrompt);
296
317
  const argv = buildArgv(input, agent, tmp.filePath);
297
318
  return await new Promise((resolve) => {
@@ -338,7 +359,11 @@ async function dispatch(input, ctx) {
338
359
  "ignore",
339
360
  "pipe",
340
361
  "pipe"
341
- ]
362
+ ],
363
+ env: {
364
+ ...process.env,
365
+ PI_PTC_DEPTH: String(childDepth)
366
+ }
342
367
  });
343
368
  } catch (err) {
344
369
  finalize("rejected", "failed to spawn pi: " + (err instanceof Error ? err.message : String(err)));
@@ -436,6 +461,8 @@ const BUILTIN_BINDING_NAMES = [
436
461
  "find",
437
462
  "ls"
438
463
  ];
464
+ /** Parallel binding name (ADR-0016). */
465
+ const DISPATCH_BINDING_NAME = "pi.dispatch";
439
466
  /**
440
467
  * Bindings exposed when the caller does not pass an explicit name list.
441
468
  *
@@ -445,11 +472,6 @@ const BUILTIN_BINDING_NAMES = [
445
472
  * out would silently shrink the surface relative to DSH. Callers that want a read-only
446
473
  * PTC surface pass an explicit subset.
447
474
  */
448
- /** Parallel binding name (ADR-0016). Always bound alongside the builtin set;
449
- * opt-out is the callers responsibility via an explicit subset to
450
- * createBuiltinBindings (today the subset is restricted to builtin names,
451
- * so opt-out is effectively use a future flag). */
452
- const DISPATCH_BINDING_NAME = "pi.dispatch";
453
475
  const DEFAULT_BINDING_NAMES = BUILTIN_BINDING_NAMES;
454
476
  const BUILTIN_TOOL_FACTORIES = {
455
477
  read: createReadTool,
@@ -469,6 +491,7 @@ const BUILTIN_TOOL_FACTORIES = {
469
491
  */
470
492
  function createBuiltinBindings(options) {
471
493
  const names = options.names ?? DEFAULT_BINDING_NAMES;
494
+ const includeDispatch = options.includeDispatch ?? options.names === void 0;
472
495
  const table = /* @__PURE__ */ new Map();
473
496
  for (const name of names) {
474
497
  const factory = BUILTIN_TOOL_FACTORIES[name];
@@ -493,7 +516,7 @@ function createBuiltinBindings(options) {
493
516
  }
494
517
  });
495
518
  }
496
- if (names === DEFAULT_BINDING_NAMES) table.set(DISPATCH_BINDING_NAME, {
519
+ if (includeDispatch) table.set(DISPATCH_BINDING_NAME, {
497
520
  name: DISPATCH_BINDING_NAME,
498
521
  execute: async (args, context) => {
499
522
  return dispatch(args, {
@@ -501,7 +524,7 @@ function createBuiltinBindings(options) {
501
524
  callId: context.callId,
502
525
  cwd: options.cwd,
503
526
  depth: context.depth,
504
- maxDepth: context.maxDispatchDepth
527
+ maxDispatchDepth: context.maxDispatchDepth
505
528
  });
506
529
  }
507
530
  });
@@ -521,7 +544,10 @@ const DEFAULT_CONFIG = Object.freeze({
521
544
  maxItemsPerCall: 4096,
522
545
  graceMs: 3e3,
523
546
  maxOldGenerationSizeMb: 512,
524
- maxYoungGenerationSizeMb: 64
547
+ maxYoungGenerationSizeMb: 64,
548
+ poolSize: 4,
549
+ poolAcquireTimeoutMs: 3e4,
550
+ drainGraceMs: 5e3
525
551
  });
526
552
  /**
527
553
  * F1 — the only environment variables a PTC worker may inherit.
@@ -577,619 +603,344 @@ function effectiveTimeoutMs(requested, config = DEFAULT_CONFIG) {
577
603
  if (requested === void 0 || !Number.isFinite(requested) || requested <= 0) return config.timeoutMs;
578
604
  return Math.min(requested, config.maxTimeoutMs);
579
605
  }
580
- const HOST_FRAME_KIND = Object.freeze({
581
- connect: "connect",
582
- init: "init",
583
- callResult: "call-result",
584
- cancel: "cancel"
585
- });
586
- const WORKER_FRAME_KIND = Object.freeze({
587
- ready: "ready",
588
- call: "call",
589
- log: "log",
590
- narration: "narration",
591
- phase: "phase",
592
- result: "result",
593
- error: "error"
594
- });
595
- const PTC_LOG_LEVEL = Object.freeze({
596
- log: "log",
597
- info: "info",
598
- warn: "warn",
599
- error: "error",
600
- debug: "debug"
601
- });
602
- const PTC_ERROR_KIND = Object.freeze({
603
- /** Program parse error or thrown exception (includes `ReferenceError` from a helper that does not exist on this surface). */
604
- exception: "exception",
605
- /** Elapsed deadline expiry. */
606
- timeout: "timeout",
607
- /** Caller cancellation. */
608
- abort: "abort",
609
- /** Malformed or excessive control traffic. */
610
- protocol: "protocol",
611
- /** Early worker exit or a worker-level crash. */
612
- workerExit: "worker-exit",
613
- /** Completion value could not be materialized as lossless JSON. */
614
- invalidOutput: "invalid-output",
615
- /** Oversized outer result; collected logs are retained. */
616
- outputLimit: "output-limit"
617
- });
618
- function isRecord(value) {
619
- return typeof value === "object" && value !== null && !Array.isArray(value);
620
- }
621
- function isPtcLogLevel(value) {
622
- return typeof value === "string" && Object.values(PTC_LOG_LEVEL).some((level) => level === value);
623
- }
624
- function isPtcErrorKind(value) {
625
- return typeof value === "string" && Object.values(PTC_ERROR_KIND).some((kind) => kind === value);
626
- }
627
- function isPtcCancelReason(value) {
628
- return value === "timeout" || value === "abort";
629
- }
630
- function isPtcConnectFrame(value) {
631
- return isRecord(value) && value.kind === HOST_FRAME_KIND.connect && value.port instanceof MessagePort;
632
- }
633
- function isPtcInitFrame(value) {
634
- return isRecord(value) && value.kind === HOST_FRAME_KIND.init && typeof value.runId === "string" && (value.surface === "run_code" || value.surface === "workflow") && typeof value.code === "string" && Array.isArray(value.bindings) && value.bindings.every((name) => typeof name === "string") && Array.isArray(value.bindingCandidates) && value.bindingCandidates.every((name) => typeof name === "string") && typeof value.maxItemsPerCall === "number" && Number.isFinite(value.maxItemsPerCall) && typeof value.maxPendingCalls === "number" && Number.isFinite(value.maxPendingCalls);
635
- }
636
- function isPtcCallResultFrame(value) {
637
- if (!isRecord(value) || value.kind !== HOST_FRAME_KIND.callResult) return false;
638
- if (!Number.isInteger(value.callId) || typeof value.tool !== "string") return false;
639
- if (value.ok === true) return "value" in value;
640
- return value.ok === false && typeof value.message === "string";
641
- }
642
- function isPtcCancelFrame(value) {
643
- return isRecord(value) && value.kind === HOST_FRAME_KIND.cancel && isPtcCancelReason(value.reason);
644
- }
645
- function isPtcHostFrame(value) {
646
- return isPtcConnectFrame(value) || isPtcInitFrame(value) || isPtcCallResultFrame(value) || isPtcCancelFrame(value);
647
- }
648
- function isPtcReadyFrame(value) {
649
- return isRecord(value) && value.kind === WORKER_FRAME_KIND.ready;
650
- }
651
- function isPtcCallFrame(value) {
652
- return isRecord(value) && value.kind === WORKER_FRAME_KIND.call && Number.isInteger(value.callId) && typeof value.tool === "string" && value.tool.length > 0 && "args" in value;
653
- }
654
- function isPtcLogFrame(value) {
655
- return isRecord(value) && value.kind === WORKER_FRAME_KIND.log && isPtcLogLevel(value.level) && typeof value.text === "string";
656
- }
657
- function isPtcNarrationFrame(value) {
658
- return isRecord(value) && value.kind === WORKER_FRAME_KIND.narration && typeof value.message === "string";
659
- }
660
- function isPtcPhaseFrame(value) {
661
- return isRecord(value) && value.kind === WORKER_FRAME_KIND.phase && typeof value.title === "string";
662
- }
663
- function isPtcResultFrame(value) {
664
- return isRecord(value) && value.kind === WORKER_FRAME_KIND.result;
665
- }
666
- function isPtcErrorFrame(value) {
667
- if (!isRecord(value) || value.kind !== WORKER_FRAME_KIND.error) return false;
668
- const error = value.error;
669
- return isRecord(error) && isPtcErrorKind(error.kind) && typeof error.message === "string" && (error.stack === void 0 || typeof error.stack === "string");
670
- }
671
- function isPtcWorkerFrame(value) {
672
- return isPtcReadyFrame(value) || isPtcCallFrame(value) || isPtcLogFrame(value) || isPtcNarrationFrame(value) || isPtcPhaseFrame(value) || isPtcResultFrame(value) || isPtcErrorFrame(value);
673
- }
674
- function workerProtocolSpec() {
606
+ //#endregion
607
+ //#region src/runtime/worker-pool.ts
608
+ /**
609
+ * Per-turn worker pool: keeps `worker_threads` Workers warm across PTC runs so the
610
+ * second `ptc_run_code` of a turn does not pay the V8 spin-up + module-graph load cost
611
+ * the first one did (ADR-0017).
612
+ *
613
+ * The pool is created by the pi extension hook at turn entry, passed to
614
+ * `runPtcProgram()` via `RunPtcProgramOptions.pool`, and drained at turn exit. Two
615
+ * surfaces (`run_code`, `workflow`) do not share a pool: their worker programs are
616
+ * different (`installWorkflowHelpers` in `worker-main.ts`), and one pool per surface
617
+ * is the cleanest ownership story.
618
+ *
619
+ * Capacity model
620
+ * ──────────────
621
+ * "Resident workers" ≤ `size`, "in-flight calls" ≤ unbounded but queued when the pool
622
+ * is full. An acquire when the pool is at capacity joins a FIFO wait list; a waiter is
623
+ * served the instant an `acquire()` resolves with the released worker — the released
624
+ * worker is handed directly to the next waiter rather than going through `idle`. This
625
+ * matches the host's own `acquireDispatchSlot` semantics: the queue is the throughput
626
+ * story, not the rejection story.
627
+ *
628
+ * Waiters time out after `acquireTimeoutMs`; on timeout the run fails with
629
+ * `kind: workerExit` and a message naming the pool (ADR-0017 W-2 / §4).
630
+ *
631
+ * `drain()` — awaited by the pi extension hook at turn end — gives in-flight runs
632
+ * `drainGraceMs` (default 5 000 ms) to release their worker. Whatever is still in flight
633
+ * when the grace expires is terminated together with the idle workers: a worker whose
634
+ * `release()` never arrives (a stuck dispatcher promise, a crash no one observed) must
635
+ * bound the turn-end hook, not hang it. Drain therefore always resolves — expiry is not
636
+ * an error, because a turn boundary should not fail over one bad worker.
637
+ *
638
+ * Health
639
+ * ──────
640
+ * A worker that has emitted `exit` is retired — it never re-enters `idle`. The pool
641
+ * detects exit on the next `release()`: if `worker.threadId === -1` (Node marks a
642
+ * terminated worker with a sentinel threadId) the worker is dropped rather than kept
643
+ * idle. This keeps a worker that crashed between two runs from being handed out again.
644
+ */
645
+ /**
646
+ * Build the `Worker` options for one spawn (ADR-0005 F1–F3 hardening in one place).
647
+ *
648
+ * `runId` is optional and deliberately absent on the pooled path: a warm worker serves many runs,
649
+ * so its spawn-time `workerData` cannot carry any single run's identity (each run's `runId`
650
+ * travels in its `init` frame instead).
651
+ */
652
+ function workerSpawnOptions(options) {
675
653
  return {
676
- hostFrame: HOST_FRAME_KIND,
677
- workerFrame: WORKER_FRAME_KIND,
678
- logLevel: PTC_LOG_LEVEL,
679
- errorKind: PTC_ERROR_KIND
654
+ name: `ptc-${options.surface}`,
655
+ env: options.env,
656
+ workerData: options.runId === void 0 ? { env: options.env } : {
657
+ runId: options.runId,
658
+ env: options.env
659
+ },
660
+ resourceLimits: {
661
+ maxOldGenerationSizeMb: options.limits.maxOldGenerationSizeMb,
662
+ maxYoungGenerationSizeMb: options.limits.maxYoungGenerationSizeMb
663
+ }
680
664
  };
681
665
  }
682
- //#endregion
683
- //#region src/runtime/worker-main.ts
684
- function workerMain(deps) {
685
- const hostFrame = deps.protocol.hostFrame;
686
- const workerFrame = deps.protocol.workerFrame;
687
- const logLevel = deps.protocol.logLevel;
688
- const errorKind = deps.protocol.errorKind;
689
- /** Wrapper name used to compile a program body (`return …` is legal inside it). */
690
- const programName = "__ptcProgram";
691
- let control;
692
- let started = false;
693
- let cancelled;
694
- let nextCallId = 1;
695
- /** Call frames posted and not yet answered; capped by `maxPendingCalls` (ADR-0004). */
696
- let inFlightCalls = 0;
697
- let maxPendingCalls = Number.POSITIVE_INFINITY;
698
- const admissionWaiters = [];
699
- const pending = /* @__PURE__ */ new Map();
700
- const globals = () => globalThis;
701
- class ToolCallError extends Error {
702
- toolName;
703
- constructor(toolName, message) {
704
- super(message);
705
- this.name = "ToolCallError";
706
- this.toolName = toolName;
707
- }
708
- }
709
- const messageOf = (error) => error instanceof Error ? error.message : String(error);
666
+ /**
667
+ * Diagnostics channels the pool publishes to (ADR-0017 W-5, Addendum). Channels have
668
+ * zero subscribers by default — `diagnostics_channel.channel(...)` always returns the
669
+ * same instance, but `publish` short-circuits when no one is listening, so this is the
670
+ * documented cheap path.
671
+ */
672
+ const channels = {
673
+ acquireLatency: diagnosticsChannel.channel("ptc:pool:acquire-latency"),
674
+ resetTime: diagnosticsChannel.channel("ptc:worker:reset-time"),
675
+ imageBytes: diagnosticsChannel.channel("ptc:image:hoist-bytes")
676
+ };
677
+ function publish(channel, message) {
678
+ channel.publish(message);
679
+ }
680
+ /**
681
+ * A fixed-capacity FIFO-served worker pool. Not safe for concurrent `acquire()` calls
682
+ * beyond Node's microtask interleaving guarantees — callers (`runPtcProgram`) await
683
+ * `acquire` before scheduling anything that touches the pool, so the only concurrent
684
+ * surface is `release()` against an in-flight worker.
685
+ */
686
+ var WorkerPool = class {
687
+ #buildWorkerUrl;
688
+ #size;
689
+ #acquireTimeoutMs;
690
+ #drainGraceMs;
691
+ #workerOptions;
692
+ #idle = [];
693
+ #inFlight = /* @__PURE__ */ new Set();
694
+ #waiters = [];
710
695
  /**
711
- * Trim a stack down to what a reader can act on.
712
- *
713
- * Frames inside the worker bootstrap point at the `data:` URL the worker was spawned
714
- * from, which is tens of kilobytes of encoded source; dropping those lines and capping
715
- * the depth keeps the model-facing stack (and the output budget) sane.
696
+ * When each worker was last released (i.e., the previous run settled). Used by the
697
+ * dispatcher to publish `ptc:worker:reset-time` when a warm worker emits its first
698
+ * `ready` frame — the time between settle and reset-complete is the worker's
699
+ * per-run reset cost (ADR-0017 Addendum).
716
700
  */
717
- const stackOf = (error) => {
718
- if (!(error instanceof Error) || typeof error.stack !== "string") return void 0;
719
- const lines = error.stack.split("\n");
720
- const kept = [lines[0] ?? ""];
721
- for (const line of lines.slice(1)) {
722
- if (line.includes("data:text/javascript")) continue;
723
- kept.push(line);
724
- if (kept.length >= 6) break;
725
- }
726
- return kept.join("\n");
727
- };
728
- const describeValue = (value) => {
729
- if (value === null) return "null";
730
- if (Array.isArray(value)) return "an array";
731
- return `a ${typeof value}`;
732
- };
733
- const post = (frame) => {
734
- if (!control) return false;
735
- try {
736
- control.postMessage(frame);
737
- return true;
738
- } catch {
739
- return false;
740
- }
741
- };
742
- const postLog = (level, text) => {
743
- post({
744
- kind: workerFrame.log,
745
- level,
746
- text
747
- });
748
- };
749
- const postError = (kind, message, stack) => {
750
- post(stack === void 0 ? {
751
- kind: workerFrame.error,
752
- error: {
753
- kind,
754
- message
755
- }
756
- } : {
757
- kind: workerFrame.error,
758
- error: {
759
- kind,
760
- message,
761
- stack
762
- }
763
- });
764
- };
765
- const abortError = (reason) => {
766
- const error = /* @__PURE__ */ new Error(reason === "timeout" ? "PTC run timed out" : "PTC run was cancelled");
767
- error.name = "AbortError";
768
- return error;
769
- };
770
- const closedError = () => {
771
- const error = /* @__PURE__ */ new Error("the PTC control channel closed before the call completed");
772
- error.name = "AbortError";
773
- return error;
774
- };
701
+ #lastSettledAt = /* @__PURE__ */ new WeakMap();
702
+ #totalAcquires = 0;
703
+ #poolExhaustions = 0;
775
704
  /**
776
- * Admission control for host binding calls.
777
- *
778
- * "Maximum simultaneous host binding calls" is the worker's invariant to keep: a call
779
- * frame is only posted once fewer than `maxPendingCalls` are outstanding, so a wide
780
- * `Promise.all` / `parallel()` burst queues here rather than arriving at the host in one
781
- * tick. The host still counts arrivals as a backstop, but it must never see this budget
782
- * exceeded by a well-behaved program.
783
- *
784
- * Waiting for a slot is cancel-aware: `cancel` and a closed control port reject waiters
785
- * immediately, so an oversized burst unwinds at once instead of waiting out the grace
786
- * window before the host terminates the worker.
705
+ * `true` once `drain()` has resolved. After this point every `acquire()` rejects
706
+ * — the pool is single-use, the parent turn owns it for one cycle (ADR-0017 §1).
787
707
  */
788
- const acquireCallSlot = async () => {
789
- if (inFlightCalls < maxPendingCalls) {
790
- inFlightCalls += 1;
791
- return;
708
+ #drained = false;
709
+ constructor(options) {
710
+ if (typeof options.buildWorkerUrl !== "function") throw new TypeError("WorkerPool: buildWorkerUrl must be a function");
711
+ if (options.size !== void 0 && (typeof options.size !== "number" || !Number.isFinite(options.size) || options.size <= 0)) throw new TypeError(`WorkerPool: size must be a positive finite number, received ${String(options.size)}`);
712
+ if (options.acquireTimeoutMs !== void 0 && (typeof options.acquireTimeoutMs !== "number" || !Number.isFinite(options.acquireTimeoutMs) || options.acquireTimeoutMs <= 0)) throw new TypeError(`WorkerPool: acquireTimeoutMs must be a positive finite number, received ${String(options.acquireTimeoutMs)}`);
713
+ if (options.drainGraceMs !== void 0 && (typeof options.drainGraceMs !== "number" || !Number.isFinite(options.drainGraceMs) || options.drainGraceMs <= 0)) throw new TypeError(`WorkerPool: drainGraceMs must be a positive finite number, received ${String(options.drainGraceMs)}`);
714
+ this.#buildWorkerUrl = options.buildWorkerUrl;
715
+ this.#size = options.size ?? 4;
716
+ this.#acquireTimeoutMs = options.acquireTimeoutMs ?? 3e4;
717
+ this.#drainGraceMs = options.drainGraceMs ?? 5e3;
718
+ this.#workerOptions = options.workerOptions;
719
+ }
720
+ /**
721
+ * Acquire an idle worker, spawn a new one if below capacity, or queue until capacity
722
+ * opens up. Throws `Error("pool acquire timed out after ${acquireTimeoutMs} ms")` on
723
+ * timeout; the dispatcher wraps that message with a `pool acquire failed: ` prefix (ADR-0017 §4).
724
+ */
725
+ async acquire() {
726
+ if (this.#drained) throw new Error("pool has been drained and can no longer serve workers");
727
+ this.#totalAcquires += 1;
728
+ const startedAt = Date.now();
729
+ let idle = this.#idle.shift();
730
+ while (idle !== void 0 && !this.#isHealthy(idle)) idle = this.#idle.shift();
731
+ if (idle !== void 0) {
732
+ this.#inFlight.add(idle);
733
+ idle.ref();
734
+ publish(channels.acquireLatency, {
735
+ poolSize: this.#size,
736
+ waiters: this.#waiters.length,
737
+ durationMs: Date.now() - startedAt
738
+ });
739
+ return idle;
792
740
  }
793
- await new Promise((resolve, reject) => {
794
- admissionWaiters.push({
741
+ if (this.#inFlight.size < this.#size) {
742
+ const worker = this.#spawn();
743
+ this.#inFlight.add(worker);
744
+ publish(channels.acquireLatency, {
745
+ poolSize: this.#size,
746
+ waiters: this.#waiters.length,
747
+ durationMs: Date.now() - startedAt
748
+ });
749
+ return worker;
750
+ }
751
+ this.#poolExhaustions += 1;
752
+ return await new Promise((resolve, reject) => {
753
+ const timer = setTimeout(() => {
754
+ const index = this.#waiters.findIndex((entry) => entry.timer === timer);
755
+ if (index >= 0) this.#waiters.splice(index, 1);
756
+ reject(/* @__PURE__ */ new Error(`pool acquire timed out after ${this.#acquireTimeoutMs} ms`));
757
+ }, this.#acquireTimeoutMs);
758
+ this.#waiters.push({
795
759
  resolve,
796
- reject
760
+ reject,
761
+ timer,
762
+ startedAt
797
763
  });
798
764
  });
799
- };
800
- const releaseCallSlot = () => {
801
- const next = admissionWaiters.shift();
802
- if (next) {
803
- next.resolve();
804
- return;
805
- }
806
- inFlightCalls -= 1;
807
- };
808
- const rejectAdmissionWaiters = (error) => {
809
- while (admissionWaiters.length > 0) {
810
- const waiter = admissionWaiters.shift();
811
- if (waiter) waiter.reject(error);
812
- }
813
- };
814
- /** Settle every outstanding call and every admission wait, so nothing can hang. */
815
- const flushPendingCalls = (error) => {
816
- rejectAdmissionWaiters(error);
817
- for (const [callId, entry] of pending) {
818
- pending.delete(callId);
819
- entry.reject(error);
820
- }
821
- };
765
+ }
822
766
  /**
823
- * F3 — reinstall the frozen per-run environment carried in `workerData`.
824
- *
825
- * The spawn already passes the same snapshot as the worker's `env` option; rebuilding
826
- * `process.env` from `workerData` here is what makes the snapshot the authority: the
827
- * program's environment is the frozen record, never whatever `process.env` happened
828
- * to hold when a binding call crossed the wire.
767
+ * Hand a worker back to the pool. If a waiter is queued, hand the worker directly to
768
+ * them (no idle round-trip). Otherwise park the worker in `idle` — unless it has
769
+ * already exited, in which case retire it.
829
770
  */
830
- const installFrozenEnv = () => {
831
- const data = deps.workerData;
832
- if (typeof data !== "object" || data === null) return;
833
- const snapshot = data.env;
834
- if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) return;
835
- const env = snapshot;
836
- for (const key of Object.keys(process.env)) if (!(key in env)) delete process.env[key];
837
- for (const key of Object.keys(env)) {
838
- const value = env[key];
839
- if (typeof value === "string") process.env[key] = value;
771
+ release(worker) {
772
+ if (!this.#inFlight.delete(worker)) return;
773
+ if (!this.#isHealthy(worker)) return;
774
+ this.#lastSettledAt.set(worker, Date.now());
775
+ const waiter = this.#waiters.shift();
776
+ if (waiter) {
777
+ clearTimeout(waiter.timer);
778
+ this.#inFlight.add(worker);
779
+ publish(channels.acquireLatency, {
780
+ poolSize: this.#size,
781
+ waiters: this.#waiters.length,
782
+ durationMs: Date.now() - waiter.startedAt
783
+ });
784
+ waiter.resolve(worker);
785
+ return;
840
786
  }
841
- };
787
+ this.#idle.push(worker);
788
+ worker.unref();
789
+ }
842
790
  /**
843
- * Lossless-JSON materialization of the program's return value (R1 §3 `invalid-output`).
844
- *
845
- * Plain JSON only: class instances, `Date`/`Map`/`Set`, functions, symbols, bigints,
846
- * non-finite numbers and cycles are rejected with a path-qualified reason instead of
847
- * being silently lossy through the transport. `undefined` follows `JSON.stringify`:
848
- * object properties are dropped, array slots become `null`.
791
+ * Last time `release()` was called for `worker`. The dispatcher reads this to
792
+ * publish `ptc:worker:reset-time` on a warm `ready` frame: `now - lastSettledAt`
793
+ * is the worker's per-run reset cost (ADR-0017 Addendum). `undefined` for a worker
794
+ * the pool never released (cold-start).
849
795
  */
850
- const toJsonValue = (value) => {
851
- const seen = /* @__PURE__ */ new Set();
852
- const walk = (candidate, path) => {
853
- if (candidate === null) return {
854
- ok: true,
855
- value: null
856
- };
857
- if (typeof candidate === "string" || typeof candidate === "boolean") return {
858
- ok: true,
859
- value: candidate
860
- };
861
- if (typeof candidate === "undefined") return {
862
- ok: true,
863
- value: void 0
864
- };
865
- if (typeof candidate === "number") {
866
- if (!Number.isFinite(candidate)) return {
867
- ok: false,
868
- reason: `${path} is ${String(candidate)}, which is not representable as JSON`
869
- };
870
- return {
871
- ok: true,
872
- value: candidate
873
- };
874
- }
875
- if (typeof candidate !== "object") return {
876
- ok: false,
877
- reason: `${path} is ${describeValue(candidate)}; results must be lossless JSON`
878
- };
879
- if (Array.isArray(candidate)) {
880
- if (seen.has(candidate)) return {
881
- ok: false,
882
- reason: `${path} is a circular reference`
883
- };
884
- seen.add(candidate);
885
- const items = [];
886
- for (let index = 0; index < candidate.length; index += 1) {
887
- const item = walk(candidate[index], `${path}[${index}]`);
888
- if (!item.ok) return item;
889
- items.push(item.value === void 0 ? null : item.value);
890
- }
891
- seen.delete(candidate);
892
- return {
893
- ok: true,
894
- value: items
895
- };
896
- }
897
- const prototype = Object.getPrototypeOf(candidate);
898
- if (prototype !== Object.prototype && prototype !== null) {
899
- const ctor = candidate.constructor;
900
- return {
901
- ok: false,
902
- reason: `${path} is a ${ctor && typeof ctor.name === "string" && ctor.name.length > 0 ? ctor.name : "an object"}; results must be plain JSON objects`
903
- };
904
- }
905
- if (seen.has(candidate)) return {
906
- ok: false,
907
- reason: `${path} is a circular reference`
908
- };
909
- seen.add(candidate);
910
- const entries = {};
911
- for (const key of Object.keys(candidate)) {
912
- const item = walk(candidate[key], `${path}.${key}`);
913
- if (!item.ok) return item;
914
- if (item.value !== void 0) entries[key] = item.value;
915
- }
916
- seen.delete(candidate);
917
- return {
918
- ok: true,
919
- value: entries
920
- };
921
- };
922
- return walk(value, "result");
923
- };
924
- const formatArgs = (args) => args.map((arg) => typeof arg === "string" ? arg : deps.inspect(arg, {
925
- depth: 4,
926
- breakLength: Infinity,
927
- colors: false
928
- })).join(" ");
796
+ lastSettledAt(worker) {
797
+ return this.#lastSettledAt.get(worker);
798
+ }
929
799
  /**
930
- * Capture `console` instead of letting worker stdio reach the host terminal.
800
+ * Wait for every in-flight worker to be released — bounded by `drainGraceMs` — then
801
+ * terminate every resident worker and reject every queued waiter. After `drain()`
802
+ * returns the pool is unusable.
931
803
  *
932
- * Only the printing methods exist on this surface; everything routes to `log` frames
933
- * so the model (not the terminal) sees the program's output.
804
+ * The bound matters: `drain()` is awaited from the pi `turn_end` hook, so a worker
805
+ * whose `release()` never arrives (a stuck dispatcher promise, an unobserved crash)
806
+ * must not hold the turn open. Workers still in flight when the grace expires are
807
+ * terminated with the idle ones, and `drain()` resolves normally — a single bad worker
808
+ * is not a reason to fail the turn boundary.
934
809
  */
935
- const installConsole = () => {
936
- const write = (level) => {
937
- return (...args) => {
938
- postLog(level, formatArgs(args));
939
- };
940
- };
941
- globals().console = {
942
- log: write(logLevel.log),
943
- info: write(logLevel.info),
944
- warn: write(logLevel.warn),
945
- error: write(logLevel.error),
946
- debug: write(logLevel.debug)
810
+ async drain() {
811
+ while (this.#waiters.length > 0) {
812
+ const waiter = this.#waiters.shift();
813
+ if (!waiter) break;
814
+ clearTimeout(waiter.timer);
815
+ waiter.reject(/* @__PURE__ */ new Error("pool drained before acquire could be satisfied"));
816
+ }
817
+ const deadline = Date.now() + this.#drainGraceMs;
818
+ while (this.#inFlight.size > 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
819
+ const terminating = [...this.#idle, ...this.#inFlight];
820
+ this.#idle.length = 0;
821
+ this.#inFlight.clear();
822
+ await Promise.all(terminating.map((worker) => worker.terminate().catch(() => void 0)));
823
+ this.#drained = true;
824
+ }
825
+ /** Read-only snapshot of the pool's counters; useful for tests and diagnostics. */
826
+ stats() {
827
+ return {
828
+ resident: this.#inFlight.size + this.#idle.length,
829
+ inFlight: this.#inFlight.size,
830
+ waiters: this.#waiters.length,
831
+ totalAcquires: this.#totalAcquires,
832
+ poolExhaustions: this.#poolExhaustions
947
833
  };
948
- };
949
- /**
950
- * Route `process.emitWarning` output through `log` frames.
951
- *
952
- * Node's default warning printer writes to stderr, which is piped to the host terminal.
953
- * `ExperimentalWarning`s are dropped rather than forwarded: the PTC runtime itself uses
954
- * experimental Node APIs (type stripping, for one) and those warnings are about the
955
- * harness, not about the program — forwarding them would put Node-internals noise into
956
- * every run's logs.
957
- */
958
- const installWarningCapture = () => {
959
- if (typeof process.removeAllListeners !== "function" || typeof process.on !== "function") return;
960
- process.removeAllListeners("warning");
961
- process.on("warning", (warning) => {
962
- const record = typeof warning === "object" && warning !== null ? warning : void 0;
963
- const name = record && typeof record.name === "string" ? record.name : "Warning";
964
- if (name === "ExperimentalWarning") return;
965
- const detail = record && typeof record.message === "string" ? record.message : String(warning);
966
- postLog(logLevel.warn, `${name}: ${detail}`);
967
- });
968
- };
834
+ }
969
835
  /**
970
- * Compile a program body into a callable.
971
- *
972
- * The body is wrapped in an async function so `return` and `await` work at the top
973
- * level, then run through `stripTypeScriptTypes` so the same body may carry type
974
- * annotations (DSH's "type annotations are advisory, the code runs type-stripped").
975
- * JavaScript the TypeScript parser rejects falls back to the unstripped source; a
976
- * genuine syntax error then surfaces from the `Function` constructor instead.
836
+ * Spawn one worker and wire its retirement listener. Private: the pool owns worker
837
+ * lifecycle, so a caller outside `acquire()` has no business growing the pool (the
838
+ * dispatcher spawns directly itself when no pool is configured).
977
839
  */
978
- const compileProgram = (code) => {
979
- const wrapped = `async function ${programName}() {\n${code}\n}`;
980
- let source = wrapped;
981
- try {
982
- source = deps.stripTypes(wrapped, { mode: "strip" });
983
- } catch {
984
- source = wrapped;
985
- }
986
- return new Function(`${source}\nreturn ${programName}();`);
987
- };
988
- const runProgram = async (code) => {
989
- let program;
990
- try {
991
- program = compileProgram(code);
992
- } catch (error) {
993
- postError(errorKind.exception, `program failed to compile: ${messageOf(error)}`, stackOf(error));
994
- return;
995
- }
996
- try {
997
- const value = await program();
998
- const normalized = toJsonValue(value);
999
- if (!normalized.ok) {
1000
- postError(errorKind.invalidOutput, normalized.reason);
840
+ #spawn() {
841
+ const worker = new Worker(this.#buildWorkerUrl(), this.#workerOptions);
842
+ worker.once("exit", () => {
843
+ const idleIndex = this.#idle.indexOf(worker);
844
+ if (idleIndex >= 0) {
845
+ this.#idle.splice(idleIndex, 1);
1001
846
  return;
1002
847
  }
1003
- post(normalized.value === void 0 ? { kind: workerFrame.result } : {
1004
- kind: workerFrame.result,
1005
- value: normalized.value
1006
- });
1007
- } catch (error) {
1008
- postError(cancelled ?? errorKind.exception, messageOf(error), stackOf(error));
1009
- }
1010
- };
848
+ this.#inFlight.delete(worker);
849
+ });
850
+ return worker;
851
+ }
1011
852
  /**
1012
- * `tools.<name>(args)` — acquire an admission slot, post a call frame, and await the
1013
- * matching `call-result`.
853
+ * A worker is healthy when it has not exited. Node marks a terminated worker with
854
+ * `threadId === -1`; the `'exit'` event path also clears it from our lists, but the
855
+ * `threadId` check is the cheap synchronous check for the common release path.
1014
856
  *
1015
- * The slot is released on every exit path (settled, admission refused by `post`, cancel),
1016
- * which is what keeps a burst draining instead of deadlocking.
857
+ * Known window: `threadId` flips to `-1` **asynchronously**, so a worker whose
858
+ * `terminate()` has been called but whose exit has not been processed yet still reads as
859
+ * healthy (measured). That is unreachable in-tree today — `terminate()` is only called by
860
+ * `drain()` (after which the pool refuses every `acquire`) and by the dispatcher's
861
+ * unpooled path (whose worker never enters this pool) — so this is a note for whoever
862
+ * adds a third caller, not a bug to chase.
1017
863
  */
1018
- const makeBinding = (name) => {
1019
- return async (args) => {
1020
- if (cancelled) throw abortError(cancelled);
1021
- await acquireCallSlot();
1022
- try {
1023
- if (cancelled) throw abortError(cancelled);
1024
- const callId = nextCallId;
1025
- nextCallId += 1;
1026
- const promise = new Promise((resolve, reject) => {
1027
- pending.set(callId, {
1028
- resolve,
1029
- reject
1030
- });
1031
- });
1032
- if (!post({
1033
- kind: workerFrame.call,
1034
- callId,
1035
- tool: name,
1036
- args
1037
- })) {
1038
- pending.delete(callId);
1039
- throw new ToolCallError(name, `${name}() could not be called: the arguments are not transferable or the run has ended`);
1040
- }
1041
- return await promise;
1042
- } finally {
1043
- releaseCallSlot();
1044
- }
1045
- };
1046
- };
1047
- const settleCall = (frame) => {
1048
- const callId = frame.callId;
1049
- if (typeof callId !== "number") return;
1050
- const entry = pending.get(callId);
1051
- if (!entry) return;
1052
- pending.delete(callId);
1053
- if (frame.ok === true) {
1054
- entry.resolve(frame.value);
1055
- return;
1056
- }
1057
- entry.reject(new ToolCallError(typeof frame.tool === "string" ? frame.tool : "unknown", typeof frame.message === "string" ? frame.message : "binding call failed"));
1058
- };
1059
- const cancelPending = (reason) => {
1060
- cancelled = reason;
1061
- flushPendingCalls(abortError(reason));
1062
- };
1063
- const assertItemCount = (helper, count, maxItemsPerCall) => {
1064
- if (count > maxItemsPerCall) throw new RangeError(`${helper}() received ${count} items, exceeding maxItemsPerCall (${maxItemsPerCall})`);
1065
- };
1066
- const installWorkflowHelpers = (args, maxItemsPerCall) => {
1067
- const target = globals();
1068
- target.args = args === void 0 ? null : args;
1069
- target.log = (message) => {
1070
- if (typeof message !== "string") throw new TypeError(`log(message) expects a string, received ${describeValue(message)}`);
1071
- post({
1072
- kind: workerFrame.narration,
1073
- message
1074
- });
1075
- };
1076
- target.phase = (title) => {
1077
- if (typeof title !== "string") throw new TypeError(`phase(title) expects a string, received ${describeValue(title)}`);
1078
- post({
1079
- kind: workerFrame.phase,
1080
- title
1081
- });
1082
- };
1083
- target.parallel = async (thunks) => {
1084
- if (!Array.isArray(thunks)) throw new TypeError(`parallel(thunks) expects an array of functions, received ${describeValue(thunks)}`);
1085
- assertItemCount("parallel", thunks.length, maxItemsPerCall);
1086
- for (let index = 0; index < thunks.length; index += 1) if (typeof thunks[index] !== "function") throw new TypeError(`parallel(thunks) expects functions; item ${index} is ${describeValue(thunks[index])}`);
1087
- return await Promise.all(thunks.map(async (thunk) => {
1088
- try {
1089
- return await thunk();
1090
- } catch {
1091
- return null;
1092
- }
1093
- }));
1094
- };
1095
- target.pipeline = async (items, ...stages) => {
1096
- if (!Array.isArray(items)) throw new TypeError(`pipeline(items, ...stages) expects an array of items, received ${describeValue(items)}`);
1097
- assertItemCount("pipeline", items.length, maxItemsPerCall);
1098
- if (stages.length === 0) throw new TypeError("pipeline(items, ...stages) requires at least one stage function");
1099
- for (let index = 0; index < stages.length; index += 1) if (typeof stages[index] !== "function") throw new TypeError(`pipeline(items, ...stages) expects stage functions; stage ${index} is ${describeValue(stages[index])}`);
1100
- const stageFunctions = stages;
1101
- return await Promise.all(items.map(async (item, index) => {
1102
- try {
1103
- let current = item;
1104
- for (const stage of stageFunctions) current = await stage(current, item, index);
1105
- return current;
1106
- } catch {
1107
- return null;
1108
- }
1109
- }));
1110
- };
1111
- };
1112
- const startRun = (frame) => {
1113
- installFrozenEnv();
1114
- installConsole();
1115
- maxPendingCalls = typeof frame.maxPendingCalls === "number" && Number.isFinite(frame.maxPendingCalls) && frame.maxPendingCalls > 0 ? frame.maxPendingCalls : Number.POSITIVE_INFINITY;
1116
- const tools = {};
1117
- const bindingNames = Array.isArray(frame.bindings) ? frame.bindings.filter((name) => typeof name === "string") : [];
1118
- for (const name of bindingNames) tools[name] = makeBinding(name);
1119
- const available = bindingNames.join(", ") || "(none)";
1120
- const candidates = Array.isArray(frame.bindingCandidates) ? frame.bindingCandidates : [];
1121
- for (const name of candidates) {
1122
- if (typeof name !== "string" || tools[name]) continue;
1123
- tools[name] = async () => {
1124
- throw new ToolCallError(name, `no binding named "${name}" in this run; available bindings: ${available}`);
1125
- };
1126
- }
1127
- globals().tools = tools;
1128
- if (frame.surface === "workflow") {
1129
- const maxItemsPerCall = typeof frame.maxItemsPerCall === "number" ? frame.maxItemsPerCall : 0;
1130
- installWorkflowHelpers(frame.args, maxItemsPerCall);
1131
- }
1132
- runProgram(typeof frame.code === "string" ? frame.code : "");
1133
- };
1134
- const handleHostFrame = (raw) => {
1135
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return;
1136
- const frame = raw;
1137
- if (frame.kind === hostFrame.init) {
1138
- if (started) return;
1139
- started = true;
1140
- startRun(frame);
1141
- return;
1142
- }
1143
- if (frame.kind === hostFrame.callResult) {
1144
- settleCall(frame);
1145
- return;
1146
- }
1147
- if (frame.kind === hostFrame.cancel) cancelPending(frame.reason === "timeout" ? "timeout" : "abort");
1148
- };
1149
- deps.parentPort.on("message", (value) => {
1150
- if (typeof value !== "object" || value === null) return;
1151
- const frame = value;
1152
- if (frame.kind !== hostFrame.connect) return;
1153
- const port = frame.port;
1154
- if (typeof port !== "object" || port === null || typeof port.on !== "function") return;
1155
- control = port;
1156
- control.on("message", handleHostFrame);
1157
- control.on("close", () => {
1158
- flushPendingCalls(closedError());
1159
- });
1160
- installWarningCapture();
1161
- post({ kind: workerFrame.ready });
1162
- });
864
+ #isHealthy(worker) {
865
+ return worker.threadId !== -1;
866
+ }
867
+ };
868
+ /**
869
+ * Publish a frame-transfer event when the dispatcher's callResult postMessage carries
870
+ * image bytes. Called from the dispatcher, not the pool, but lives here so the channel
871
+ * name and payload shape stay one-file-defined (ADR-0017 Addendum).
872
+ */
873
+ function publishImageBytes(byteLength) {
874
+ publish(channels.imageBytes, { byteLength });
875
+ }
876
+ /**
877
+ * Publish the warm-worker's reset cost when its first `ready` frame arrives at the
878
+ * host. `previousSettledAt` is the wall-clock time the pool last released this worker;
879
+ * the gap is what the worker spent clearing per-run state and re-installing the surface.
880
+ */
881
+ function publishResetTime(durationMs) {
882
+ publish(channels.resetTime, { durationMs });
1163
883
  }
1164
884
  //#endregion
1165
885
  //#region src/runtime/worker-source.ts
1166
- /** Build the worker module source for one spawn. */
1167
- function buildWorkerSource(protocol) {
1168
- return [
1169
- `import { parentPort, workerData } from "node:worker_threads";`,
1170
- `import { inspect } from "node:util";`,
1171
- `import { stripTypeScriptTypes } from "node:module";`,
1172
- `const ptcDeps = ${JSON.stringify({ protocol })};`,
1173
- `(${workerMain.toString()})({ ...ptcDeps, parentPort, workerData, inspect, stripTypes: stripTypeScriptTypes });`
1174
- ].join("\n");
1175
- }
1176
- /** The worker entry as a `data:` module URL, ready for `new Worker(url, options)`. */
1177
- function buildWorkerUrl(protocol) {
1178
- return new URL(`data:text/javascript,${encodeURIComponent(buildWorkerSource(protocol))}`);
886
+ /**
887
+ * Worker source location.
888
+ *
889
+ * The previous bootstrap assembled a `data:text/javascript,…` URL from
890
+ * `Function.prototype.toString()` of `workerMain` plus a protocol literal —
891
+ * a single self-contained file but with no V8 code-cache reuse across spawns
892
+ * (ADR-0017 §7). The rolldown dual-entry build now emits `dist/worker.js`
893
+ * alongside `dist/index.js`; the host loads that file directly, which lets
894
+ * V8's code cache and Node's module cache survive across warm-reuse spawns.
895
+ *
896
+ * The two candidates the resolver tries:
897
+ *
898
+ * 1. `dist/worker.js` — the rolldown output, sibling of `dist/index.js`. The
899
+ * production path used by `pi install npm:pi-ptc-subagents`.
900
+ * 2. `src/runtime/worker-entry.ts` — the source, used by `vitest` runs
901
+ * (vitest's transformer handles `.ts` and the worker is launched in-process
902
+ * so the relative path resolves).
903
+ */
904
+ /**
905
+ * Resolve the worker entry's `file://` URL.
906
+ *
907
+ * The function takes no arguments: the protocol tables now travel through the
908
+ * worker file itself (rolldown bundles `./protocol.ts` into `dist/worker.js`),
909
+ * so the host has nothing to compose at URL-build time.
910
+ */
911
+ function buildWorkerUrl() {
912
+ const here = dirname(fileURLToPath(import.meta.url));
913
+ const candidates = [resolve(here, "worker.js"), resolve(here, "worker-entry.ts")];
914
+ for (const candidate of candidates) if (existsSync(candidate)) return pathToFileURL(candidate);
915
+ throw new Error(`worker entry not found; tried: ${candidates.join(", ")}`);
1179
916
  }
1180
917
  //#endregion
1181
918
  //#region src/runtime/dispatcher.ts
1182
919
  /**
1183
- * Host-side dispatcher: one `runPtcProgram()` call = one worker = one program.
920
+ * Host-side dispatcher: one `runPtcProgram()` call = one program in one worker.
921
+ *
922
+ * The worker's lifecycle depends on the path (ADR-0017 §1):
923
+ * - **cold** (no `pool`): the dispatcher spawns a worker for the run and terminates it when the
924
+ * run settles — one run, one worker;
925
+ * - **pooled** (`pool` set): the dispatcher acquires a warm worker from the turn's pool and
926
+ * releases it back when the run settles. The worker outlives the run — the turn's later runs
927
+ * reuse it, and the pool's `drain()` retires it at turn end, not this file.
1184
928
  *
1185
929
  * Responsibilities:
1186
930
  * - spawn a hardened worker (F1 env scrub, F2 V8 caps, F3 frozen per-run env in
1187
- * `workerData`, F4 `cwd` carried in the run config and handed to the bindings),
931
+ * `workerData`, F4 `cwd` carried in the run config and handed to the bindings) — the
932
+ * hardening itself is defined once, in `workerSpawnOptions` (`worker-pool.ts`),
1188
933
  * - complete the `MessageChannel` handshake and send the `init` frame,
1189
934
  * - route `tools.*` calls to the binding table — concurrently, with DSH's
1190
- * `maxParallelSubCalls` forwarding cap and `maxPendingCalls` admission control,
935
+ * `maxPendingCalls` admission control, the `maxParallelSubCalls` builtin
936
+ * forwarding cap (ADR-0004 consequence: the overflow FIFO-queues for a
937
+ * slot), and the per-run `dispatchConcurrency` hard cap on in-flight
938
+ * `pi.dispatch` calls (ADR-0016 §2: the overflow resolves immediately as
939
+ * rejected, it is never queued). The two caps have independent counters:
940
+ * neither throttles the other.
1191
941
  * - collect logs / narration / phases and enforce the joint output budget,
1192
- * - enforce the deadline, honour caller cancellation, and always tear the worker down.
942
+ * - enforce the deadline, honour caller cancellation, and settle the run: hand the worker
943
+ * back (pooled) or tear it down (cold).
1193
944
  *
1194
945
  * Everything the worker can influence is treated as untrusted input: every frame goes
1195
946
  * through the protocol guards, is size-checked, and can only ever end the run.
@@ -1197,10 +948,72 @@ function buildWorkerUrl(protocol) {
1197
948
  function messageOf(error) {
1198
949
  return error instanceof Error ? error.message : String(error);
1199
950
  }
1200
- /** Serialized byte size of a frame, the unit both the output and message budgets use. */
951
+ /** A leaf whose bytes JSON cannot express: it is billed by `byteLength`, not by its JSON text. */
952
+ function isBinaryLeaf(value) {
953
+ return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
954
+ }
955
+ /**
956
+ * Walk a value tree, calling `onBinary` on every binary leaf (`ArrayBuffer` and any
957
+ * `ArrayBufferView`). Cycles and shared sub-objects are visited once, so a pathological frame
958
+ * cannot wedge or inflate the walk.
959
+ *
960
+ * Binary leaves are billed here rather than by their JSON text for two reasons: `JSON.stringify`
961
+ * serialises an `ArrayBuffer` as `{}` (the size vanishes) and explodes a typed-array view into
962
+ * one key per element (the size is overstated by ~6 bytes per element). The walk therefore stops
963
+ * at the leaf and never recurses into a view's `.buffer`, which would count the same bytes twice.
964
+ * ADR-0017 W-6 spells out the refactor; this is its binary half.
965
+ */
966
+ function walkBinaryLeaves(value, onBinary) {
967
+ const seen = /* @__PURE__ */ new WeakSet();
968
+ const visit = (candidate) => {
969
+ if (candidate === null || candidate === void 0) return;
970
+ if (candidate instanceof ArrayBuffer) {
971
+ onBinary(candidate);
972
+ return;
973
+ }
974
+ if (ArrayBuffer.isView(candidate)) {
975
+ onBinary(candidate);
976
+ return;
977
+ }
978
+ if (typeof candidate !== "object") return;
979
+ if (seen.has(candidate)) return;
980
+ seen.add(candidate);
981
+ if (Array.isArray(candidate)) {
982
+ for (const item of candidate) visit(item);
983
+ return;
984
+ }
985
+ for (const key of Object.keys(candidate)) visit(candidate[key]);
986
+ };
987
+ visit(value);
988
+ }
989
+ /**
990
+ * Serialized byte size of a frame, the unit both the output and message budgets use.
991
+ *
992
+ * The count is the JSON text the frame becomes — every brace, bracket, separator, key name and
993
+ * scalar — plus the bytes of any binary leaf, which that text cannot express. Two passes:
994
+ *
995
+ * 1. the whole value through `JSON.stringify` with binary leaves substituted by `null`: every
996
+ * structural character, key name and scalar is billed the way the old helper billed it, and a
997
+ * cyclic value throws here (as before);
998
+ * 2. the binary leaves by `byteLength`, added on top.
999
+ *
1000
+ * A frame with binary leaves therefore counts 4 bytes (its substituted `null`) more than the JSON
1001
+ * text it would have if the bytes vanished. That residue is deliberate and the safe direction:
1002
+ * under-counting the payload is the hole ADR-0017 W-6 closes, and the worker's channel clones
1003
+ * those bytes for real.
1004
+ *
1005
+ * A frame with no binary leaf produces exactly the previous helper's number — the substituter is
1006
+ * a no-op when there is nothing to substitute — and a cyclic or otherwise unaccountable payload
1007
+ * still reports `Number.POSITIVE_INFINITY` so the caller fails the run.
1008
+ */
1201
1009
  function serializedBytes(value) {
1202
1010
  try {
1203
- return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
1011
+ const text = Buffer.byteLength(JSON.stringify(value, (_key, item) => isBinaryLeaf(item) ? null : item) ?? "", "utf8");
1012
+ let binary = 0;
1013
+ walkBinaryLeaves(value, (leaf) => {
1014
+ binary += leaf.byteLength;
1015
+ });
1016
+ return text + binary;
1204
1017
  } catch {
1205
1018
  return Number.POSITIVE_INFINITY;
1206
1019
  }
@@ -1214,7 +1027,6 @@ function serializedBytes(value) {
1214
1027
  */
1215
1028
  async function runPtcProgram(options) {
1216
1029
  const config = resolveConfig(options.config);
1217
- const protocol = workerProtocolSpec();
1218
1030
  const env = createWorkerEnv();
1219
1031
  const runId = options.runId ?? randomUUID();
1220
1032
  const timeoutMs = effectiveTimeoutMs(options.timeoutMs, config);
@@ -1230,63 +1042,97 @@ async function runPtcProgram(options) {
1230
1042
  message: "run cancelled before start"
1231
1043
  }
1232
1044
  };
1233
- return await new Promise((resolve) => {
1234
- const worker = new Worker(buildWorkerUrl(protocol), {
1235
- name: `ptc-${options.surface}`,
1045
+ let worker;
1046
+ try {
1047
+ if (options.pool) worker = await options.pool.acquire();
1048
+ else worker = new Worker(buildWorkerUrl(), workerSpawnOptions({
1049
+ surface: options.surface,
1236
1050
  env,
1237
- workerData: {
1238
- runId,
1239
- env
1240
- },
1241
- resourceLimits: {
1242
- maxOldGenerationSizeMb: config.maxOldGenerationSizeMb,
1243
- maxYoungGenerationSizeMb: config.maxYoungGenerationSizeMb
1051
+ limits: config,
1052
+ runId
1053
+ }));
1054
+ } catch (error) {
1055
+ return {
1056
+ logs,
1057
+ narrations,
1058
+ phases,
1059
+ error: {
1060
+ kind: PTC_ERROR_KIND.workerExit,
1061
+ message: `pool acquire failed: ${messageOf(error)}`
1244
1062
  }
1245
- });
1063
+ };
1064
+ }
1065
+ return await new Promise((resolve) => {
1246
1066
  const channel = new MessageChannel();
1247
1067
  const control = channel.port1;
1248
1068
  const workerPort = channel.port2;
1249
1069
  const bindingAbort = new AbortController();
1250
1070
  let settled = false;
1071
+ /** Set once a stop is under way; owns the terminal state from then on (`settleTerminal`). */
1251
1072
  let cancelling;
1073
+ /**
1074
+ * Whether the worker has emitted its first `ready` frame. A cancel that lands before it does
1075
+ * still arms the grace window (the settle time must be bounded even for a worker that never
1076
+ * becomes reachable); the `ready` handler restarts that window, so a merely slow spawn gets
1077
+ * its full `graceMs` from the moment it can actually react.
1078
+ */
1079
+ let workerReady = false;
1252
1080
  let pendingCalls = 0;
1253
1081
  let outputBytes = 0;
1082
+ /**
1083
+ * Concurrently in-flight `pi.dispatch` calls, counted so the per-run hard cap
1084
+ * (ADR-0016 §2) can reject the overflow immediately. There is deliberately no
1085
+ * waiter queue behind this counter: the N+1th concurrent call resolves as
1086
+ * rejected instead of waiting for a slot. Independent of `activeBuiltinCalls`.
1087
+ */
1254
1088
  let activeDispatches = 0;
1255
- const dispatchWaiters = [];
1256
- const runDepth = 0;
1089
+ /**
1090
+ * Concurrently in-flight builtin binding calls, counted against
1091
+ * `maxParallelSubCalls` (ADR-0004 consequence). Unlike the dispatch cap,
1092
+ * the overflow FIFO-queues for a slot — DSH's semantics for builtin fan-out.
1093
+ * Independent of `activeDispatches`.
1094
+ */
1095
+ let activeBuiltinCalls = 0;
1096
+ const builtinWaiters = [];
1097
+ const runDepth = options.depth ?? 0;
1098
+ /** Deadline timer: fires `timeoutMs` after the run started. A cancel does not disarm it. */
1257
1099
  let runTimer;
1100
+ /** The cancel's cooperative window: armed and re-armed by `armGraceTimer` only. */
1258
1101
  let graceTimer;
1259
1102
  const images = [];
1260
1103
  /**
1261
- * Hoist the images out of one successful binding result.
1104
+ * Capture the image blocks from one binding result.
1262
1105
  *
1263
- * DSH does this in its scheduler's commit step: a subtool result whose content carries an image
1264
- * block has that content attached to the caller's context after the run, so the picture reaches
1265
- * the model without travelling through the program's lossless-JSON return value (`dsh-tools`:
1266
- * `exec.deferContext(createUserMessage(...))`). The host sees every binding result before it is
1267
- * posted to the worker, so this is the same seam.
1106
+ * `PtcImage` has exactly one representation — base64 `data`, the same shape pi's own `read`
1107
+ * returns and the same shape the JSON-only worker channel carries. A binding that emits
1108
+ * `data: <base64>` passes through untouched (no decode/encode round trip); a binding that
1109
+ * produces raw bytes is normalised here, once, host-side.
1268
1110
  *
1269
- * Everything is hoisted: no count cap, no byte cap, no dedupe. How much context a run spends on
1270
- * images is the program's call, and hiding a cap behind a warning would make this layer a
1271
- * gatekeeper DSH does not have. pi's per-model resize (`inputLimits.images.resize`) still bounds
1272
- * what a single attachment costs at the provider.
1111
+ * The caller (`dispatchCall`) only keeps the captured list when `postCallResult` returned
1112
+ * true: a frame the port rejected was not a successful subtool result (ADR-0014 §2).
1273
1113
  */
1274
- const hoistImages = (value) => {
1114
+ const captureImages = (value) => {
1115
+ const captured = [];
1275
1116
  const content = value?.content;
1276
- if (!Array.isArray(content)) return;
1117
+ if (!Array.isArray(content)) return captured;
1277
1118
  for (const block of content) {
1278
1119
  if (block === null || typeof block !== "object") continue;
1279
1120
  const candidate = block;
1280
1121
  if (candidate.type !== "image") continue;
1281
- const data = typeof candidate.data === "string" ? candidate.data : "";
1282
- if (data.length === 0) continue;
1283
- images.push({
1122
+ let data;
1123
+ if (typeof candidate.data === "string" && candidate.data.length > 0) data = candidate.data;
1124
+ else if (candidate.bytes instanceof ArrayBuffer && candidate.bytes.byteLength > 0) data = Buffer.from(candidate.bytes).toString("base64");
1125
+ if (data === void 0) continue;
1126
+ captured.push({
1284
1127
  data,
1285
1128
  mimeType: typeof candidate.mimeType === "string" ? candidate.mimeType : "application/octet-stream"
1286
1129
  });
1287
1130
  }
1131
+ return captured;
1288
1132
  };
1289
1133
  const cancelMessage = (reason) => reason === "timeout" ? `run timed out after ${timeoutMs} ms` : "run cancelled";
1134
+ /** The error kind a stop reports: whichever reason asked for it. */
1135
+ const cancelErrorKind = (reason) => reason === "timeout" ? PTC_ERROR_KIND.timeout : PTC_ERROR_KIND.abort;
1290
1136
  const finish = (outcome) => {
1291
1137
  if (settled) return;
1292
1138
  settled = true;
@@ -1295,7 +1141,8 @@ async function runPtcProgram(options) {
1295
1141
  options.signal?.removeEventListener("abort", onAbortSignal);
1296
1142
  bindingAbort.abort();
1297
1143
  control.close();
1298
- worker.terminate();
1144
+ if (options.pool) options.pool.release(worker);
1145
+ else worker.terminate();
1299
1146
  resolve(outcome);
1300
1147
  };
1301
1148
  const fail = (kind, message, stack) => {
@@ -1350,22 +1197,22 @@ async function runPtcProgram(options) {
1350
1197
  fail(PTC_ERROR_KIND.outputLimit, `${source} exceeded the output budget: logs + result reached ${outputBytes} bytes (maxOutputBytes=${config.maxOutputBytes}); ${logs.length} log line(s) retained`);
1351
1198
  return false;
1352
1199
  };
1353
- const acquireDispatchSlot = async () => {
1354
- if (activeDispatches < config.maxParallelSubCalls) {
1355
- activeDispatches += 1;
1200
+ const acquireBuiltinSlot = async () => {
1201
+ if (activeBuiltinCalls < config.maxParallelSubCalls) {
1202
+ activeBuiltinCalls += 1;
1356
1203
  return;
1357
1204
  }
1358
1205
  await new Promise((slot) => {
1359
- dispatchWaiters.push(slot);
1206
+ builtinWaiters.push(slot);
1360
1207
  });
1361
1208
  };
1362
- const releaseDispatchSlot = () => {
1363
- const next = dispatchWaiters.shift();
1209
+ const releaseBuiltinSlot = () => {
1210
+ const next = builtinWaiters.shift();
1364
1211
  if (next) {
1365
1212
  next();
1366
1213
  return;
1367
1214
  }
1368
- activeDispatches -= 1;
1215
+ activeBuiltinCalls -= 1;
1369
1216
  };
1370
1217
  const handleCall = (frame) => {
1371
1218
  pendingCalls += 1;
@@ -1389,10 +1236,25 @@ async function runPtcProgram(options) {
1389
1236
  });
1390
1237
  return;
1391
1238
  }
1392
- await acquireDispatchSlot();
1393
- if (settled) {
1394
- releaseDispatchSlot();
1395
- return;
1239
+ const isDispatch = frame.tool === DISPATCH_BINDING_NAME;
1240
+ if (isDispatch) {
1241
+ if (activeDispatches >= config.dispatchConcurrency) {
1242
+ postCallResult({
1243
+ kind: HOST_FRAME_KIND.callResult,
1244
+ callId: frame.callId,
1245
+ tool: frame.tool,
1246
+ ok: true,
1247
+ value: dispatchConcurrencyLimitReached()
1248
+ });
1249
+ return;
1250
+ }
1251
+ activeDispatches += 1;
1252
+ } else {
1253
+ await acquireBuiltinSlot();
1254
+ if (settled) {
1255
+ releaseBuiltinSlot();
1256
+ return;
1257
+ }
1396
1258
  }
1397
1259
  try {
1398
1260
  const value = await binding.execute(frame.args, {
@@ -1401,13 +1263,23 @@ async function runPtcProgram(options) {
1401
1263
  depth: runDepth,
1402
1264
  maxDispatchDepth: config.maxDispatchDepth
1403
1265
  });
1404
- if (postCallResult({
1266
+ const captured = captureImages(value);
1267
+ const callFrame = {
1405
1268
  kind: HOST_FRAME_KIND.callResult,
1406
1269
  callId: frame.callId,
1407
1270
  tool: frame.tool,
1408
1271
  ok: true,
1409
1272
  value
1410
- })) hoistImages(value);
1273
+ };
1274
+ const callFrameBytes = serializedBytes(callFrame);
1275
+ if (callFrameBytes > config.maxMessageBytes) {
1276
+ fail(PTC_ERROR_KIND.protocol, `callResult frame of ${callFrameBytes} bytes exceeds maxMessageBytes (${config.maxMessageBytes})`);
1277
+ return;
1278
+ }
1279
+ if (postCallResult(callFrame)) for (const image of captured) {
1280
+ images.push(image);
1281
+ publishImageBytes(Buffer.byteLength(image.data, "base64"));
1282
+ }
1411
1283
  } catch (error) {
1412
1284
  postCallResult({
1413
1285
  kind: HOST_FRAME_KIND.callResult,
@@ -1417,7 +1289,8 @@ async function runPtcProgram(options) {
1417
1289
  message: messageOf(error)
1418
1290
  });
1419
1291
  } finally {
1420
- releaseDispatchSlot();
1292
+ if (isDispatch) activeDispatches -= 1;
1293
+ else releaseBuiltinSlot();
1421
1294
  }
1422
1295
  };
1423
1296
  /** `true` when the worker actually received the result (see the hoist at the call site). */
@@ -1448,7 +1321,7 @@ async function runPtcProgram(options) {
1448
1321
  narrations,
1449
1322
  phases,
1450
1323
  error: {
1451
- kind: cancelling === "timeout" ? PTC_ERROR_KIND.timeout : PTC_ERROR_KIND.abort,
1324
+ kind: cancelErrorKind(cancelling),
1452
1325
  message: cancelMessage(cancelling)
1453
1326
  }
1454
1327
  };
@@ -1470,6 +1343,17 @@ async function runPtcProgram(options) {
1470
1343
  }
1471
1344
  switch (frame.kind) {
1472
1345
  case WORKER_FRAME_KIND.ready:
1346
+ if (options.pool) {
1347
+ const previousSettled = options.pool.lastSettledAt(worker);
1348
+ if (previousSettled !== void 0) publishResetTime(Date.now() - previousSettled);
1349
+ }
1350
+ if (!workerReady) {
1351
+ workerReady = true;
1352
+ if (cancelling) {
1353
+ armGraceTimer(cancelling);
1354
+ return;
1355
+ }
1356
+ }
1473
1357
  sendInit();
1474
1358
  return;
1475
1359
  case WORKER_FRAME_KIND.call:
@@ -1512,21 +1396,34 @@ async function runPtcProgram(options) {
1512
1396
  function onAbortSignal() {
1513
1397
  beginCancel("abort");
1514
1398
  }
1399
+ /**
1400
+ * Arm the cooperative-cancel window: `graceMs` from now the run settles with `reason`, unless
1401
+ * the worker settles it first. Re-arming replaces the pending window.
1402
+ *
1403
+ * The invariant this guards: **from the moment a run starts, it settles in bounded time.**
1404
+ * The window is therefore armed by `beginCancel` itself, not by the worker's `ready` frame —
1405
+ * a worker that never becomes reachable (spawn failure, wedged module load, a thread the OS
1406
+ * stopped scheduling) can no longer leave the run pending forever.
1407
+ */
1408
+ const armGraceTimer = (reason) => {
1409
+ if (graceTimer) clearTimeout(graceTimer);
1410
+ graceTimer = setTimeout(() => {
1411
+ fail(cancelErrorKind(reason), cancelMessage(reason));
1412
+ }, config.graceMs);
1413
+ };
1515
1414
  function beginCancel(reason) {
1516
- if (settled || cancelling) return;
1517
- cancelling = reason;
1518
- if (runTimer) {
1519
- clearTimeout(runTimer);
1520
- runTimer = void 0;
1415
+ if (settled) return;
1416
+ if (cancelling) {
1417
+ fail(cancelErrorKind(cancelling), cancelMessage(cancelling));
1418
+ return;
1521
1419
  }
1420
+ cancelling = reason;
1522
1421
  bindingAbort.abort();
1523
1422
  sendControl({
1524
1423
  kind: HOST_FRAME_KIND.cancel,
1525
1424
  reason
1526
1425
  });
1527
- graceTimer = setTimeout(() => {
1528
- fail(reason === "timeout" ? PTC_ERROR_KIND.timeout : PTC_ERROR_KIND.abort, cancelMessage(reason));
1529
- }, config.graceMs);
1426
+ armGraceTimer(reason);
1530
1427
  }
1531
1428
  worker.on("error", (error) => {
1532
1429
  fail(PTC_ERROR_KIND.workerExit, `worker failed: ${messageOf(error)}`);
@@ -1726,6 +1623,20 @@ function renderModelValue(value) {
1726
1623
  * `details` stays raw for the TUI. See ADR-0012.
1727
1624
  */
1728
1625
  /**
1626
+ * Read the depth baseline pi-ptc was started with inside a child pi process.
1627
+ *
1628
+ * `dispatch()` stamps `PI_PTC_DEPTH` (the child's own depth) onto the spawned subprocess's
1629
+ * environment, and the extension entrypoint reads it back here so a child PTC run's binding
1630
+ * context starts at the dispatched depth instead of at 0 — otherwise the recursion bound in
1631
+ * `dispatch()` could never bite below the first level. Only a pure non-negative integer is
1632
+ * accepted; a missing or malformed value means "not a dispatched child" and yields 0.
1633
+ */
1634
+ function resolveDepthFromEnv(source = process.env) {
1635
+ const raw = source.PI_PTC_DEPTH;
1636
+ if (raw === void 0 || !/^\d+$/.test(raw)) return 0;
1637
+ return Number.parseInt(raw, 10);
1638
+ }
1639
+ /**
1729
1640
  * Binding names for one run: the built-ins this session actually has enabled (T7, #21).
1730
1641
  *
1731
1642
  * A PTC program must never reach further than the session it runs in — a session started
@@ -2396,8 +2307,9 @@ const PARAMETERS$1 = Type.Object({
2396
2307
  /**
2397
2308
  * Build the `ptc_run_code` tool definition.
2398
2309
  *
2399
- * Called once by the extension factory. Every run gets a fresh worker and a fresh binding table
2400
- * built against the run's own cwd, so two concurrent calls cannot share state.
2310
+ * Called once by the extension factory. Each execute builds its own binding table against the
2311
+ * run's own cwd and reads `getPool()` for the surface's (possibly absent) pool — see
2312
+ * `PtcRunCodeToolOptions` for what pooling does and does not change about isolation.
2401
2313
  */
2402
2314
  function createPtcRunCodeTool(options = {}) {
2403
2315
  return defineTool({
@@ -2411,17 +2323,21 @@ function createPtcRunCodeTool(options = {}) {
2411
2323
  const cwd = resolveToolCwd(ctx);
2412
2324
  const names = resolveBindingNames(options.getBindingSourceNames?.());
2413
2325
  const startedAt = Date.now();
2326
+ const pool = options.getPool?.();
2414
2327
  const outcome = await runPtcProgram({
2415
2328
  code: params.code,
2416
2329
  surface: "run_code",
2417
2330
  cwd,
2418
2331
  bindings: createBuiltinBindings({
2419
2332
  cwd,
2420
- names
2333
+ names,
2334
+ includeDispatch: true
2421
2335
  }),
2336
+ ...options.depth === void 0 ? {} : { depth: options.depth },
2422
2337
  ...params.timeoutMs === void 0 ? {} : { timeoutMs: params.timeoutMs },
2423
2338
  ...signal === void 0 ? {} : { signal },
2424
- ...options.config === void 0 ? {} : { config: options.config }
2339
+ ...options.config === void 0 ? {} : { config: options.config },
2340
+ ...pool === void 0 ? {} : { pool }
2425
2341
  });
2426
2342
  if (outcome.error !== void 0) throw codeRunFailedError(outcome);
2427
2343
  return renderToolResult({
@@ -2444,7 +2360,7 @@ function createPtcRunCodeTool(options = {}) {
2444
2360
  /**
2445
2361
  * `ptc_workflow` — the structured PTC surface.
2446
2362
  *
2447
- * Same worker, same bindings, same Node surface as `ptc_run_code`; what it adds is a plan
2363
+ * Same worker program, same bindings, same Node surface as `ptc_run_code`; what it adds is a plan
2448
2364
  * (`meta` with an ordered phase list), plain-JSON input (`args`, bound as the program's `args`
2449
2365
  * global) and the four workflow helpers: `log` / `phase` / `parallel` / `pipeline` (G1 #13 →
2450
2366
  * decision B — there is no `agent()`, not even a stub).
@@ -2491,11 +2407,6 @@ const PARAMETERS = Type.Object({
2491
2407
  script: Type.String({ description: "The program: the body of an async TypeScript function." }),
2492
2408
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Plain-JSON input bound to the program's `args` global. No functions, symbols, undefined values or cycles." }))
2493
2409
  });
2494
- function describeValue(value) {
2495
- if (value === null) return "null";
2496
- if (Array.isArray(value)) return "an array";
2497
- return `a ${typeof value}`;
2498
- }
2499
2410
  /**
2500
2411
  * Reject anything that is not plain JSON, with a path-qualified reason.
2501
2412
  *
@@ -2564,8 +2475,9 @@ function unlistedPhaseWarnings(outcome, declared) {
2564
2475
  /**
2565
2476
  * Build the `ptc_workflow` tool definition.
2566
2477
  *
2567
- * `args` is validated before `runPtcProgram` is called, so a malformed payload never spawns a
2568
- * worker. Everything after dispatch is identical to `ptc_run_code`, plus the phase roll-up.
2478
+ * `args` is validated before `runPtcProgram` is called, so a malformed payload never acquires or
2479
+ * spawns a worker. Everything after dispatch is identical to `ptc_run_code`, plus the phase
2480
+ * roll-up.
2569
2481
  */
2570
2482
  function createPtcWorkflowTool(options = {}) {
2571
2483
  return defineTool({
@@ -2580,17 +2492,21 @@ function createPtcWorkflowTool(options = {}) {
2580
2492
  const cwd = resolveToolCwd(ctx);
2581
2493
  const names = resolveBindingNames(options.getBindingSourceNames?.());
2582
2494
  const startedAt = Date.now();
2495
+ const pool = options.getPool?.();
2583
2496
  const outcome = await runPtcProgram({
2584
2497
  code: params.script,
2585
2498
  surface: "workflow",
2586
2499
  cwd,
2587
2500
  bindings: createBuiltinBindings({
2588
2501
  cwd,
2589
- names
2502
+ names,
2503
+ includeDispatch: true
2590
2504
  }),
2591
2505
  ...params.args === void 0 ? {} : { args: params.args },
2506
+ ...options.depth === void 0 ? {} : { depth: options.depth },
2592
2507
  ...signal === void 0 ? {} : { signal },
2593
- ...options.config === void 0 ? {} : { config: options.config }
2508
+ ...options.config === void 0 ? {} : { config: options.config },
2509
+ ...pool === void 0 ? {} : { pool }
2594
2510
  });
2595
2511
  if (outcome.error !== void 0) throw codeRunFailedError(outcome);
2596
2512
  const declared = params.meta.phases?.map((phase) => phase.name);
@@ -2611,6 +2527,61 @@ function createPtcWorkflowTool(options = {}) {
2611
2527
  });
2612
2528
  }
2613
2529
  //#endregion
2530
+ //#region src/runtime/turn-pools.ts
2531
+ /**
2532
+ * Per-turn pool holder: one `WorkerPool` per PTC surface, created lazily on first use
2533
+ * and retired together at turn end (ADR-0017 §1–§2).
2534
+ *
2535
+ * The pi extension owns one `TurnPools` per agent turn and hands each tool a getter for
2536
+ * its own surface's pool. Lazy creation is what makes the cold turn cheap: a turn that
2537
+ * never runs a PTC program never spawns a worker, and a turn that runs one `ptc_run_code`
2538
+ * pays exactly one cold spawn (which the pool then keeps warm for the rest of the turn).
2539
+ *
2540
+ * `run_code` and `workflow` do not share a pool (ADR-0017 §2): their worker surfaces differ
2541
+ * (`installWorkflowHelpers` is conditional in `worker-main.ts`), and one pool per surface
2542
+ * keeps that story simple. The workers themselves carry no surface state — each run's
2543
+ * `init` frame names its surface — so the split is about ownership, not correctness.
2544
+ */
2545
+ var TurnPools = class {
2546
+ #config;
2547
+ #pools = /* @__PURE__ */ new Map();
2548
+ constructor(options = {}) {
2549
+ this.#config = resolveConfig(options.config);
2550
+ }
2551
+ /**
2552
+ * The pool for one surface, created on first request. Warm workers from an earlier
2553
+ * run in this turn are served by the same pool (that is the point); a fresh
2554
+ * `TurnPools` is how a turn gets a fresh set.
2555
+ */
2556
+ get(surface) {
2557
+ const existing = this.#pools.get(surface);
2558
+ if (existing !== void 0) return existing;
2559
+ const env = createWorkerEnv();
2560
+ const pool = new WorkerPool({
2561
+ buildWorkerUrl,
2562
+ size: this.#config.poolSize,
2563
+ acquireTimeoutMs: this.#config.poolAcquireTimeoutMs,
2564
+ drainGraceMs: this.#config.drainGraceMs,
2565
+ workerOptions: workerSpawnOptions({
2566
+ surface,
2567
+ env,
2568
+ limits: this.#config
2569
+ })
2570
+ });
2571
+ this.#pools.set(surface, pool);
2572
+ return pool;
2573
+ }
2574
+ /**
2575
+ * Terminate every pool this holder created. Idempotent, and safe to call from a turn
2576
+ * boundary hook: a second call finds nothing to drain.
2577
+ */
2578
+ async drain() {
2579
+ const pools = [...this.#pools.values()];
2580
+ this.#pools.clear();
2581
+ await Promise.all(pools.map((pool) => pool.drain()));
2582
+ }
2583
+ };
2584
+ //#endregion
2614
2585
  //#region src/mode/ptc-mode.ts
2615
2586
  /**
2616
2587
  * PTC default mode — the session-level tool-restriction state machine.
@@ -2963,14 +2934,46 @@ function ptcSubagents(pi) {
2963
2934
  const mode = initialModeState();
2964
2935
  let briefingPending = false;
2965
2936
  /**
2937
+ * Per-turn worker pools (ADR-0017 §1–§2): one warm set per surface, created lazily by the
2938
+ * first PTC run of the turn and retired at the turn's end. The holder is swapped rather
2939
+ * than mutated so a `turn_end` drain can never race a run that is still holding a worker —
2940
+ * the outgoing holder owns everything the ending turn created.
2941
+ */
2942
+ let turnPools = new TurnPools();
2943
+ /**
2966
2944
  * Bindings come from the mode's base snapshot while PTC mode is on, and from the live loadout
2967
2945
  * otherwise (T7, #21). See `src/mode/ptc-mode.ts` for why the snapshot is required: the mode
2968
2946
  * hides the built-ins, which would otherwise empty the binding table and make every
2969
2947
  * `tools.<name>(...)` call fail.
2970
2948
  */
2971
2949
  const getBindingSourceNames = () => bindingSource(mode, pi.getActiveTools());
2972
- pi.registerTool(createPtcRunCodeTool({ getBindingSourceNames }));
2973
- pi.registerTool(createPtcWorkflowTool({ getBindingSourceNames }));
2950
+ /**
2951
+ * Depth baseline for PTC runs this pi process starts (ADR-0016 Recursive section).
2952
+ * Inside a pi process spawned by `pi.dispatch`, the env carries `PI_PTC_DEPTH`; a
2953
+ * normal pi session has none, and the parent turn's runs stay at depth 0.
2954
+ */
2955
+ const ptcDepth = resolveDepthFromEnv();
2956
+ pi.registerTool(createPtcRunCodeTool({
2957
+ getBindingSourceNames,
2958
+ getPool: () => turnPools.get("run_code"),
2959
+ depth: ptcDepth
2960
+ }));
2961
+ pi.registerTool(createPtcWorkflowTool({
2962
+ getBindingSourceNames,
2963
+ getPool: () => turnPools.get("workflow"),
2964
+ depth: ptcDepth
2965
+ }));
2966
+ /**
2967
+ * Retire the turn's pools. `drain()` terminates the warm workers, so nothing outlives the
2968
+ * turn that spawned it; the next PTC run starts a fresh, lazily-created set. Idle workers are
2969
+ * already `unref()`-ed by the pool, so a turn that ends without this hook firing can never
2970
+ * keep the process alive either.
2971
+ */
2972
+ pi.on("turn_end", async () => {
2973
+ const draining = turnPools;
2974
+ turnPools = new TurnPools();
2975
+ await draining.drain();
2976
+ });
2974
2977
  const paintStatus = (ctx) => {
2975
2978
  ctx.ui.setStatus(PTC_MODE_STATUS_KEY, mode.enabled ? ctx.ui.theme.fg("accent", MODE_STATUS_TEXT) : void 0);
2976
2979
  };
@@ -3122,6 +3125,6 @@ function ptcSubagents(pi) {
3122
3125
  });
3123
3126
  }
3124
3127
  //#endregion
3125
- export { BUILTIN_BINDING_NAMES, DEFAULT_BINDING_NAMES, DEFAULT_CONFIG, DEFAULT_HIDE_STRATEGY, HOST_FRAME_KIND, MAX_LINE_CHARS, MODE_REQUIRED_TOOL_NAMES, PTC_ERROR_KIND, PTC_LOG_LEVEL, PTC_MODE_CONFIG_FILE, PTC_MODE_ENTRY_TYPE, PTC_MODE_STATUS_KEY, PTC_MODE_TOOL_NAMES, PTC_SKILL_LOAD_INSTRUCTION, SKILL_READING_TOOL_NAMES, WORKER_ENV_ALLOW_LIST, WORKER_FRAME_KIND, bindingSource, buildModeInstruction, buildPtcSkillsSection, createBuiltinBindings, createWorkerEnv, decideModeEntry, ptcSubagents as default, detectExternalLoadoutChange, effectiveTimeoutMs, initialModeState, modeLoadout, readDefaultModeConfig, renderModelValue, resolveBaseOnStart, resolveConfig, runPtcProgram, sameToolSet, sanitizeText, skillsSectionDropped, stripAnsi };
3128
+ export { BUILTIN_BINDING_NAMES, DEFAULT_BINDING_NAMES, DEFAULT_CONFIG, DEFAULT_HIDE_STRATEGY, HOST_FRAME_KIND, MAX_LINE_CHARS, MODE_REQUIRED_TOOL_NAMES, PTC_ERROR_KIND, PTC_LOG_LEVEL, PTC_MODE_CONFIG_FILE, PTC_MODE_ENTRY_TYPE, PTC_MODE_STATUS_KEY, PTC_MODE_TOOL_NAMES, PTC_SKILL_LOAD_INSTRUCTION, SKILL_READING_TOOL_NAMES, TurnPools, WORKER_ENV_ALLOW_LIST, WORKER_FRAME_KIND, WorkerPool, bindingSource, buildModeInstruction, buildPtcSkillsSection, createBuiltinBindings, createWorkerEnv, decideModeEntry, ptcSubagents as default, detectExternalLoadoutChange, effectiveTimeoutMs, initialModeState, modeLoadout, readDefaultModeConfig, renderModelValue, resolveBaseOnStart, resolveConfig, runPtcProgram, sameToolSet, sanitizeText, skillsSectionDropped, stripAnsi };
3126
3129
 
3127
3130
  //# sourceMappingURL=index.js.map