pi-ptc-subagents 0.1.1 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] - 2026-09-23
9
+
10
+ ### Fixed
11
+
12
+ - **`pi.dispatch` is registered in production again.** The injection check compared the
13
+ binding-name array by reference against `DEFAULT_BINDING_NAMES`; production resolves
14
+ names through a `.filter()` that always returns a fresh array, so the comparison never
15
+ held and shipped sessions had no `pi.dispatch` at all. Injection now keys off a new
16
+ `includeDispatch` option (defaulting to "the caller passed no explicit `names`"), and
17
+ both shipped tools pass it explicitly. ADR-0016.
18
+ - **The dispatch concurrency cap honours `dispatchConcurrency` (default 8) and rejects
19
+ immediately instead of queueing.** The dispatcher used to read `maxParallelSubCalls`
20
+ (default 10) and FIFO-queue the overflow; per ADR-0016 §2 the N+1th concurrent
21
+ `pi.dispatch` now resolves at once with `{ status: "rejected", errorMessage:
22
+ "dispatch concurrency limit reached" }`. The cap applies to `pi.dispatch` only and
23
+ has its own counter; builtin binding fan-out keeps DSH's `maxParallelSubCalls` (10)
24
+ FIFO-queueing semantics (ADR-0004), so in-flight builtin calls never consume
25
+ dispatch slots.
26
+ - **The depth-limit rejection message is verbatim again.** The program receives
27
+ exactly `dispatch depth limit reached`, matching what the child's
28
+ `<pi-ptc-context>` hint promises — the diagnostic suffix is gone.
29
+ - **`maxDispatchDepth` bounds recursion again.** Every run reported depth 0 and
30
+ children never inherited it, so the depth check could never fire. `dispatch()` now
31
+ stamps `PI_PTC_DEPTH` on the child subprocess's environment, the extension
32
+ entrypoint reads it back, and `runPtcProgram()` accepts a `depth` baseline that
33
+ reaches the binding context. ADR-0016 Recursive section.
34
+
8
35
  ## [0.1.1] - 2026-09-23
9
36
 
10
37
  PTC runs inside one agent turn no longer pay a cold start each. Nothing changes in
package/dist/index.d.ts CHANGED
@@ -201,16 +201,24 @@ interface PtcConfig {
201
201
  maxMessageBytes: number;
202
202
  /** Admission control for simultaneously in-flight worker→host binding calls (ADR-0004). */
203
203
  maxPendingCalls: number;
204
- /** Concurrent binding dispatches; DSH's `maxParallelSubCalls` (ADR-0004 consequence).
205
- * Renamed in spirit by ADR-0016 section 2: the cap that really matters for
206
- * resource safety is the per-run `dispatchConcurrency` below. This field
207
- * is kept for backward compatibility (and for the in-process builtin
208
- * binding fan-out) but is no longer the authoritative limit on the
209
- * parallel binding `pi.dispatch`. */
204
+ /**
205
+ * Concurrent builtin binding dispatches; DSH's `maxParallelSubCalls`
206
+ * (ADR-0004 consequence), mirrored verbatim (10). The overflow
207
+ * FIFO-queues for a slot instead of failing. This is the authoritative
208
+ * cap for the builtin fan-out path — independent of
209
+ * `dispatchConcurrency`, with its own counter: neither cap throttles
210
+ * the other.
211
+ */
210
212
  maxParallelSubCalls: number;
211
- /** Per-run hard cap on concurrently in-flight `pi.dispatch(...)` calls.
212
- * Default 8, matches pi's `subagent` extension `MAX_PARALLEL_TASKS`.
213
- * ADR-0016 section 2. */
213
+ /**
214
+ * Per-run hard cap on concurrently in-flight `pi.dispatch(...)` calls,
215
+ * enforced by the dispatcher: the next concurrent call resolves
216
+ * immediately with `{ status: "rejected", errorMessage: "dispatch
217
+ * concurrency limit reached" }` — never queued, never spawned.
218
+ * Default 8, matches pi's `subagent` extension `MAX_PARALLEL_TASKS`.
219
+ * ADR-0016 section 2. Independent of `maxParallelSubCalls`: builtin
220
+ * calls never consume a dispatch slot and vice versa.
221
+ */
214
222
  dispatchConcurrency: number;
215
223
  /** Maximum recursion depth for `pi.dispatch`. The child PTC run spawned by
216
224
  * the (depth+1)-th dispatch is allowed only when childDepth <= maxDispatchDepth.
@@ -227,9 +235,10 @@ interface PtcConfig {
227
235
  /** V8 young-generation cap handed to `new Worker({ resourceLimits })` (F2). */
228
236
  maxYoungGenerationSizeMb: number;
229
237
  /**
230
- * Per-turn worker pool capacity (ADR-0017 §3). Decoupled from `maxParallelSubCalls`
231
- * because pool capacity is "resident workers" while `maxParallelSubCalls` is
232
- * "in-flight calls" — two different ceilings. Default 4.
238
+ * Per-turn worker pool capacity (ADR-0017 §3). Decoupled from
239
+ * `dispatchConcurrency` because pool capacity is "resident workers" while
240
+ * `dispatchConcurrency` is "in-flight calls" — two different ceilings.
241
+ * Default 4.
233
242
  */
234
243
  poolSize: number;
235
244
  /**
@@ -361,6 +370,15 @@ export declare function renderModelValue(value: PtcJsonValue): string;
361
370
  /** pi's built-in tools that can be exposed as bindings, in native order. */
362
371
  export declare const BUILTIN_BINDING_NAMES: readonly ["read", "bash", "edit", "write", "grep", "find", "ls"];
363
372
  type BuiltinBindingName = (typeof BUILTIN_BINDING_NAMES)[number];
373
+ /**
374
+ * Bindings exposed when the caller does not pass an explicit name list.
375
+ *
376
+ * `bash` is included on purpose: DSH's PTC preset still mounts `tool-bash`/`tool-pwsh`
377
+ * (R1 §2 — PTC mode hides the tools from the wire list and re-exposes the same registry
378
+ * as bindings), so a PTC program written against DSH may run shell commands. Leaving it
379
+ * out would silently shrink the surface relative to DSH. Callers that want a read-only
380
+ * PTC surface pass an explicit subset.
381
+ */
364
382
  export declare const DEFAULT_BINDING_NAMES: readonly BuiltinBindingName[];
365
383
  interface BindingContext {
366
384
  /** Aborted when the run is cancelled, times out, or settles. */
@@ -382,6 +400,16 @@ interface CreateBuiltinBindingsOptions {
382
400
  cwd: string;
383
401
  /** Subset of {@link BUILTIN_BINDING_NAMES}; defaults to all of them (bash included). */
384
402
  names?: readonly string[];
403
+ /**
404
+ * Whether the parallel binding `pi.dispatch` (ADR-0016) joins the table. Defaults to
405
+ * "did the caller curate the surface": `true` when `names` is omitted, `false` when an
406
+ * explicit list is passed (R3's read-only PTC surface pattern). Deciding on whether
407
+ * `names` was provided — not on array identity with {@link DEFAULT_BINDING_NAMES} —
408
+ * matters because production callers resolve names through a `.filter()` that always
409
+ * returns a fresh array. The two shipped tools pass `true` explicitly: the dispatch
410
+ * binding is part of every production surface.
411
+ */
412
+ includeDispatch?: boolean;
385
413
  }
386
414
  /**
387
415
  * Build the binding table for one run.
@@ -483,6 +511,14 @@ interface RunPtcProgramOptions {
483
511
  config?: Partial<PtcConfig>;
484
512
  /** Cancels the run; the worker gets a cooperative cancel window before termination. */
485
513
  signal?: AbortSignal;
514
+ /**
515
+ * Depth of this run in the `pi.dispatch` recursion chain (ADR-0016 Recursive section):
516
+ * 0 for the parent turn's run, 1+ for a run inside a child spawned by `pi.dispatch`.
517
+ * Handed to the binding context so the dispatch binding can bound recursion. The
518
+ * extension entrypoint derives it from `PI_PTC_DEPTH`; direct library use defaults
519
+ * to 0.
520
+ */
521
+ depth?: number;
486
522
  /** Identifier carried to the worker; generated when omitted. */
487
523
  runId?: string;
488
524
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/mode/ptc-mode.ts","../src/mode/skills-section.ts","../src/runtime/limits.ts","../src/runtime/protocol.ts","../src/tools/text.ts","../src/runtime/bindings.ts","../src/runtime/worker-pool.ts","../src/runtime/dispatcher.ts","../src/runtime/turn-pools.ts","../src/index.ts"],"mappings":";;;;qBA2Ca;;;;;;;;;;qBAWA;;qBAGA;;qBAGA;;qBAGA;;KAGD;;;;;;;;;;;;;qBAcC,uBAAuB;;UAGnB;EACf;;EAEA;;EAEA;;;wBAIc,oBAAoB;;;;;UAQnB;EACf;EACA;;;UAQe;EACf;EACA;EACA;;;;;;;;;wBAUc,sBAAsB,mBAAmB;;KAwC7C;;KAGA;EACN;EAAa;EAAyB;;EACtC;EAAc,QAAQ;;;UAGX;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA,OAAO;;;wBASO,YAAY,sBAAsB;;;;;;;;wBAclC,YAAY,2BAA2B,MAAM;;;;;;;;;;;;;;;wBAqB7C,gBAAgB,OAAO,iBAAiB;;;;;;;wBA4BxC,cAAc,OAAO,cAAc;;;;;wBAQnC,4BACd,OAAO,cACP;;;;;;;;;;;wBAgBc,mBACd,WAAW,gCACX;EACG;EAAyB;;;;;;;;;wBAoBd,qBAAqB,6BAA6B,MAAM;;;;qBCvR3D;;qBAOA;;;;;;;wBASG,qBAAqB;;;;;;;;;;wBAarB,sBACd,iBAAiB,SACjB,UAAS,QAAQ,SAAS;;;;;;;;;;;;;;;;;;KCtChB;UAEK;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;EAOA;;;;EAIA;;;;;EAKA;;;EAGA;;EAEA;;EAEA;;EAEA;;;;;;EAMA;;;;;;;EAOA;;;;;;;;;EASA;;qBAGW,gBAAgB,SAAS;;;;;;;qBAwBzB;;;;;;;wBAeG,gBAAgB,SAAQ,OAAO,aAA2B;;;;;;;wBAe1D,cAAc,YAAW,QAAQ,aAAkB;;;;;;;wBAmBnD,mBACd,+BACA,SAAQ;;;;cCvIJ;WACJ;WACA;WACA;WACA;;qBAEW,wBAAwB;;cAG/B;WACJ;WACA;WACA;WACA;WACA;WACA;WACA;;qBAEW,0BAA0B;cAGjC;WACJ;WACA;WACA;WACA;WACA;;qBAEW,sBAAsB;cAG7B;;WAEJ;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;qBAEW,uBAAuB;KACxB,uBAAuB,6BAA6B;;UAK/C;GACA,cAAA;;KAEL,kDAAkD,iBAAiB;UAE9D;EACf,MAAM;EACN;EACA;;;;;qBCvDW;;wBAwBG,UAAU;;;;;;;;;wBAgBV,aAAa;;;;;;wBA6Ib,iBAAiB,OAAO;;;;qBC7K3B;KASD,6BAA6B;qBAiB5B,gCAAgC;UAE5B;;EAEf,SAAS;;EAET;;EAEA;;EAEA;;UAGe;WACN;EACT,QAAQ,eAAe,SAAS,iBAAiB;;KAGvC,eAAe,oBAAoB;UAkC9B;;EAEf;;EAEA;;;;;;;;;wBAUc,sBAAsB,SAAS,+BAA+B;;;;KCjFlE,0BAA0B,6BAA6B;UAiClD;;;;;;EAMf,sBAAsB;;EAEtB;;EAEA;;;;;;EAMA;;EAEA,eAAe;;UAqCA;EACf;EACA;EACA;EACA;EACA;;;;;;;;qBASW;;EAyBX,YAAY,SAAS;;;;;;EA4CrB,WAAiB,QAAQ;;;;;;EA4DzB,QAAQ,QAAQ;;;;;;;EAuChB,cAAc,QAAQ;;;;;;;;;;;;EAetB,SAAe;;EA4Bf,SAAS;;;;UC7SM;;EAEf;EACA,SAAS;;EAET;;EAEA,UAAU;;EAEV;;EAEA;;EAEA,SAAS,QAAQ;;EAEjB,SAAS;;EAET;;;;;;;;EAQA,OAAO;;;;;;;;;;;;;;UAeQ;EACf;EACA;;UAGe;;EAEf;;EAEA;;EAEA;;EAEA,QAAQ;;;;;;;;;;;EAWR,SAAS;;EAET,QAAQ;;;;;;;;;wBAgGY,cAAc,SAAS,uBAAuB,QAAQ;;;UCtM3D;;EAEf,SAAS,QAAQ;;qBAGN;;EAIX,YAAY,UAAS;;;;;;EASrB,IAAI,SAAS,aAAa;;;;;EAuC1B,SAAe;;;;wBCyDO,aAAa,IAAI"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/mode/ptc-mode.ts","../src/mode/skills-section.ts","../src/runtime/limits.ts","../src/runtime/protocol.ts","../src/tools/text.ts","../src/runtime/bindings.ts","../src/runtime/worker-pool.ts","../src/runtime/dispatcher.ts","../src/runtime/turn-pools.ts","../src/index.ts"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as isPtcHostFrame, i as WORKER_FRAME_KIND, n as PTC_ERROR_KIND, o as isPtcWorkerFrame, r as PTC_LOG_LEVEL, t as HOST_FRAME_KIND } from "./protocol-DLR50XxW.js";
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";
2
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";
3
3
  import { Type } from "typebox";
4
4
  import { validateToolArguments } from "@earendil-works/pi-ai";
@@ -16,14 +16,15 @@ import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
17
  //#region src/runtime/dispatch.ts
18
18
  /**
19
- * 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.
20
20
  *
21
- * ADR-0016 (2026-09-23) is the contract. This file is the type surface and the spawn
22
- * skeleton; the actual subprocess plumbing (agent-md resolution, JSON-line parser,
23
- * usage accumulator, signal propagation across worker_threads / child_process) is a
24
- * follow-up. The stubs here are typed against the contract so call sites can land
25
- * before the spawn path is finalised, and so a wrong shape is a compile error rather
26
- * 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.
27
28
  *
28
29
  * Behaviourally compatible with pi's examples/extensions/subagent/index.ts reference
29
30
  * (--mode json, -p, --no-session, --append-system-prompt <tmpfile>), but not cooperative:
@@ -63,15 +64,32 @@ function appendDepthHint(systemPrompt, depth, maxDepth) {
63
64
  ].join("\n");
64
65
  return systemPrompt.length === 0 ? hint : systemPrompt + "\n\n" + hint;
65
66
  }
66
- /** Default error returned when the depth limit is exceeded. */
67
- function dispatchDepthLimitReached(currentDepth) {
67
+ /** Result returned when the depth limit is exceeded. */
68
+ function dispatchDepthLimitReached() {
68
69
  return {
69
70
  text: "",
70
71
  status: "rejected",
71
72
  agentName: "unknown",
72
73
  durationMs: 0,
73
74
  exitCode: -1,
74
- 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
75
93
  };
76
94
  }
77
95
  /**
@@ -281,7 +299,7 @@ function cleanupTmp(tmp) {
281
299
  */
282
300
  async function dispatch(input, ctx) {
283
301
  const childDepth = ctx.depth + 1;
284
- if (childDepth > ctx.maxDepth) return dispatchDepthLimitReached(ctx.depth);
302
+ if (childDepth > ctx.maxDispatchDepth) return dispatchDepthLimitReached();
285
303
  const cwd = input.cwd ?? ctx.cwd;
286
304
  const agentScope = input.agentScope ?? "user";
287
305
  const start = Date.now();
@@ -294,7 +312,7 @@ async function dispatch(input, ctx) {
294
312
  exitCode: 1,
295
313
  errorMessage: "unknown agent: " + input.agent + " (agentScope=" + agentScope + ", cwd=" + cwd + ")"
296
314
  };
297
- const fullPrompt = appendDepthHint(agent.systemPrompt, childDepth, ctx.maxDepth);
315
+ const fullPrompt = appendDepthHint(agent.systemPrompt, childDepth, ctx.maxDispatchDepth);
298
316
  const tmp = await writePromptToTempFile(agent.name, fullPrompt);
299
317
  const argv = buildArgv(input, agent, tmp.filePath);
300
318
  return await new Promise((resolve) => {
@@ -341,7 +359,11 @@ async function dispatch(input, ctx) {
341
359
  "ignore",
342
360
  "pipe",
343
361
  "pipe"
344
- ]
362
+ ],
363
+ env: {
364
+ ...process.env,
365
+ PI_PTC_DEPTH: String(childDepth)
366
+ }
345
367
  });
346
368
  } catch (err) {
347
369
  finalize("rejected", "failed to spawn pi: " + (err instanceof Error ? err.message : String(err)));
@@ -439,6 +461,8 @@ const BUILTIN_BINDING_NAMES = [
439
461
  "find",
440
462
  "ls"
441
463
  ];
464
+ /** Parallel binding name (ADR-0016). */
465
+ const DISPATCH_BINDING_NAME = "pi.dispatch";
442
466
  /**
443
467
  * Bindings exposed when the caller does not pass an explicit name list.
444
468
  *
@@ -448,11 +472,6 @@ const BUILTIN_BINDING_NAMES = [
448
472
  * out would silently shrink the surface relative to DSH. Callers that want a read-only
449
473
  * PTC surface pass an explicit subset.
450
474
  */
451
- /** Parallel binding name (ADR-0016). Always bound alongside the builtin set;
452
- * opt-out is the callers responsibility via an explicit subset to
453
- * createBuiltinBindings (today the subset is restricted to builtin names,
454
- * so opt-out is effectively use a future flag). */
455
- const DISPATCH_BINDING_NAME = "pi.dispatch";
456
475
  const DEFAULT_BINDING_NAMES = BUILTIN_BINDING_NAMES;
457
476
  const BUILTIN_TOOL_FACTORIES = {
458
477
  read: createReadTool,
@@ -472,6 +491,7 @@ const BUILTIN_TOOL_FACTORIES = {
472
491
  */
473
492
  function createBuiltinBindings(options) {
474
493
  const names = options.names ?? DEFAULT_BINDING_NAMES;
494
+ const includeDispatch = options.includeDispatch ?? options.names === void 0;
475
495
  const table = /* @__PURE__ */ new Map();
476
496
  for (const name of names) {
477
497
  const factory = BUILTIN_TOOL_FACTORIES[name];
@@ -496,7 +516,7 @@ function createBuiltinBindings(options) {
496
516
  }
497
517
  });
498
518
  }
499
- if (names === DEFAULT_BINDING_NAMES) table.set(DISPATCH_BINDING_NAME, {
519
+ if (includeDispatch) table.set(DISPATCH_BINDING_NAME, {
500
520
  name: DISPATCH_BINDING_NAME,
501
521
  execute: async (args, context) => {
502
522
  return dispatch(args, {
@@ -504,7 +524,7 @@ function createBuiltinBindings(options) {
504
524
  callId: context.callId,
505
525
  cwd: options.cwd,
506
526
  depth: context.depth,
507
- maxDepth: context.maxDispatchDepth
527
+ maxDispatchDepth: context.maxDispatchDepth
508
528
  });
509
529
  }
510
530
  });
@@ -912,7 +932,12 @@ function buildWorkerUrl() {
912
932
  * hardening itself is defined once, in `workerSpawnOptions` (`worker-pool.ts`),
913
933
  * - complete the `MessageChannel` handshake and send the `init` frame,
914
934
  * - route `tools.*` calls to the binding table — concurrently, with DSH's
915
- * `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.
916
941
  * - collect logs / narration / phases and enforce the joint output budget,
917
942
  * - enforce the deadline, honour caller cancellation, and settle the run: hand the worker
918
943
  * back (pooled) or tear it down (cold).
@@ -1054,9 +1079,22 @@ async function runPtcProgram(options) {
1054
1079
  let workerReady = false;
1055
1080
  let pendingCalls = 0;
1056
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
+ */
1057
1088
  let activeDispatches = 0;
1058
- const dispatchWaiters = [];
1059
- 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;
1060
1098
  /** Deadline timer: fires `timeoutMs` after the run started. A cancel does not disarm it. */
1061
1099
  let runTimer;
1062
1100
  /** The cancel's cooperative window: armed and re-armed by `armGraceTimer` only. */
@@ -1159,22 +1197,22 @@ async function runPtcProgram(options) {
1159
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`);
1160
1198
  return false;
1161
1199
  };
1162
- const acquireDispatchSlot = async () => {
1163
- if (activeDispatches < config.maxParallelSubCalls) {
1164
- activeDispatches += 1;
1200
+ const acquireBuiltinSlot = async () => {
1201
+ if (activeBuiltinCalls < config.maxParallelSubCalls) {
1202
+ activeBuiltinCalls += 1;
1165
1203
  return;
1166
1204
  }
1167
1205
  await new Promise((slot) => {
1168
- dispatchWaiters.push(slot);
1206
+ builtinWaiters.push(slot);
1169
1207
  });
1170
1208
  };
1171
- const releaseDispatchSlot = () => {
1172
- const next = dispatchWaiters.shift();
1209
+ const releaseBuiltinSlot = () => {
1210
+ const next = builtinWaiters.shift();
1173
1211
  if (next) {
1174
1212
  next();
1175
1213
  return;
1176
1214
  }
1177
- activeDispatches -= 1;
1215
+ activeBuiltinCalls -= 1;
1178
1216
  };
1179
1217
  const handleCall = (frame) => {
1180
1218
  pendingCalls += 1;
@@ -1198,10 +1236,25 @@ async function runPtcProgram(options) {
1198
1236
  });
1199
1237
  return;
1200
1238
  }
1201
- await acquireDispatchSlot();
1202
- if (settled) {
1203
- releaseDispatchSlot();
1204
- 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
+ }
1205
1258
  }
1206
1259
  try {
1207
1260
  const value = await binding.execute(frame.args, {
@@ -1236,7 +1289,8 @@ async function runPtcProgram(options) {
1236
1289
  message: messageOf(error)
1237
1290
  });
1238
1291
  } finally {
1239
- releaseDispatchSlot();
1292
+ if (isDispatch) activeDispatches -= 1;
1293
+ else releaseBuiltinSlot();
1240
1294
  }
1241
1295
  };
1242
1296
  /** `true` when the worker actually received the result (see the hoist at the call site). */
@@ -1569,6 +1623,20 @@ function renderModelValue(value) {
1569
1623
  * `details` stays raw for the TUI. See ADR-0012.
1570
1624
  */
1571
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
+ /**
1572
1640
  * Binding names for one run: the built-ins this session actually has enabled (T7, #21).
1573
1641
  *
1574
1642
  * A PTC program must never reach further than the session it runs in — a session started
@@ -2262,8 +2330,10 @@ function createPtcRunCodeTool(options = {}) {
2262
2330
  cwd,
2263
2331
  bindings: createBuiltinBindings({
2264
2332
  cwd,
2265
- names
2333
+ names,
2334
+ includeDispatch: true
2266
2335
  }),
2336
+ ...options.depth === void 0 ? {} : { depth: options.depth },
2267
2337
  ...params.timeoutMs === void 0 ? {} : { timeoutMs: params.timeoutMs },
2268
2338
  ...signal === void 0 ? {} : { signal },
2269
2339
  ...options.config === void 0 ? {} : { config: options.config },
@@ -2337,11 +2407,6 @@ const PARAMETERS = Type.Object({
2337
2407
  script: Type.String({ description: "The program: the body of an async TypeScript function." }),
2338
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." }))
2339
2409
  });
2340
- function describeValue(value) {
2341
- if (value === null) return "null";
2342
- if (Array.isArray(value)) return "an array";
2343
- return `a ${typeof value}`;
2344
- }
2345
2410
  /**
2346
2411
  * Reject anything that is not plain JSON, with a path-qualified reason.
2347
2412
  *
@@ -2434,9 +2499,11 @@ function createPtcWorkflowTool(options = {}) {
2434
2499
  cwd,
2435
2500
  bindings: createBuiltinBindings({
2436
2501
  cwd,
2437
- names
2502
+ names,
2503
+ includeDispatch: true
2438
2504
  }),
2439
2505
  ...params.args === void 0 ? {} : { args: params.args },
2506
+ ...options.depth === void 0 ? {} : { depth: options.depth },
2440
2507
  ...signal === void 0 ? {} : { signal },
2441
2508
  ...options.config === void 0 ? {} : { config: options.config },
2442
2509
  ...pool === void 0 ? {} : { pool }
@@ -2880,13 +2947,21 @@ function ptcSubagents(pi) {
2880
2947
  * `tools.<name>(...)` call fail.
2881
2948
  */
2882
2949
  const getBindingSourceNames = () => bindingSource(mode, pi.getActiveTools());
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();
2883
2956
  pi.registerTool(createPtcRunCodeTool({
2884
2957
  getBindingSourceNames,
2885
- getPool: () => turnPools.get("run_code")
2958
+ getPool: () => turnPools.get("run_code"),
2959
+ depth: ptcDepth
2886
2960
  }));
2887
2961
  pi.registerTool(createPtcWorkflowTool({
2888
2962
  getBindingSourceNames,
2889
- getPool: () => turnPools.get("workflow")
2963
+ getPool: () => turnPools.get("workflow"),
2964
+ depth: ptcDepth
2890
2965
  }));
2891
2966
  /**
2892
2967
  * Retire the turn's pools. `drain()` terminates the warm workers, so nothing outlives the