@akagilnc/pi-workflow-roles 0.1.2091 → 0.1.2101

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/README.md CHANGED
@@ -11,7 +11,7 @@ pi install npm:@akagilnc/pi-workflow-roles
11
11
  export PATH="$HOME/.pi/agent/npm/node_modules/.bin:$PATH"
12
12
  ```
13
13
 
14
- Update with `pi update npm:@akagilnc/pi-workflow-roles`—never a second global `npm install -g`. Inspect with `ak-role roles` and `ak-role help <role>`; set per-seat defaults with `ak-role config set judge openai-codex/gpt-5.6-sol:high`.
14
+ Update with `pi update npm:@akagilnc/pi-workflow-roles`—never a second global `npm install -g`. Inspect with `ak-role roles` and `ak-role help <role>`; set per-seat model defaults with `ak-role config set judge openai-codex/gpt-5.6-sol:high`; set or clear a persistent labor engine (judge|reviewer) with `ak-role config set-engine <seat> <name>` / `ak-role config unset-engine <seat>`.
15
15
 
16
16
  ## Reading results
17
17
 
@@ -31,10 +31,17 @@ Every run also prepares Navigator advice in the same Terminal. Configure it like
31
31
 
32
32
  ```bash
33
33
  ak-role config set navigator openai-codex/gpt-5.6-luna:medium
34
+ # persistent labor engine (judge|reviewer only); one-shot override remains --engine
35
+ ak-role config set-engine judge opus
36
+ ak-role config unset-engine judge
34
37
  ```
35
38
 
39
+ `config set` stores the seat model default; `config set-engine` / `unset-engine` store or clear the persistent labor-engine name on judge|reviewer only (same seats as `--engine`). Usage and refusal text are owned by `ak-role config` in the public CLI.
40
+
36
41
  Receipts are typed, so callers compose roles without parsing prose; ordering and stopping stay caller-owned. Programmatic consumers derive contracts from the exported schemas in `src/package-contracts/`, not from this guide.
37
42
 
43
+ When a Judge/Reviewer labor-engine detour fails and the seat continues the labor on the main road, the typed receipt may carry a mechanical `engineLaborFallback` field: `{ engine, failure, laborBy: "seat" }`. It appears only after a real detour failure that fell back to seat labor (including package-owned idle timeout on the detour tool)—not on detour success or caller cancel. First failure wins for the activation; model-forged `engineLaborFallback` keys are stripped unless the package latch recorded one. Sole producer: `src/engine-labor-fallback.ts`; decision record: [ADR 0071](docs/adr/0071-engine-detour-failure-seat-fallback-declaration.md). This README only projects that contract.
44
+
38
45
  ## Call the roles
39
46
 
40
47
  Public option identity, aliases, requiredness, and mode faces live in the generated [Public CLI options](#public-cli-options-generated) table and in `ak-role help <command>` — both project the same typed source. The examples below are usage sketches, not a second flag contract. An instruction is optional for judge, collector, and doctor, and required nonblank for coder, fixer, reviewer, and merger.
package/README.zh-CN.md CHANGED
@@ -11,7 +11,7 @@ pi install npm:@akagilnc/pi-workflow-roles
11
11
  export PATH="$HOME/.pi/agent/npm/node_modules/.bin:$PATH"
12
12
  ```
13
13
 
14
- 更新用 `pi update npm:@akagilnc/pi-workflow-roles`——勿另起全局 `npm install -g`。查看能力:`ak-role roles`、`ak-role help <role>`;设席位默认:`ak-role config set judge openai-codex/gpt-5.6-sol:high`。
14
+ 更新用 `pi update npm:@akagilnc/pi-workflow-roles`——勿另起全局 `npm install -g`。查看能力:`ak-role roles`、`ak-role help <role>`;设席位模型默认:`ak-role config set judge openai-codex/gpt-5.6-sol:high`;设或清持久劳务引擎(仅 judge|reviewer):`ak-role config set-engine <seat> <name>` / `ak-role config unset-engine <seat>`。
15
15
 
16
16
  ## 读结果
17
17
 
@@ -31,10 +31,17 @@ ak-role judge --attach ./plan.md "Review this plan." > result.txt
31
31
 
32
32
  ```bash
33
33
  ak-role config set navigator openai-codex/gpt-5.6-luna:medium
34
+ # 持久劳务引擎(仅 judge|reviewer);一次性覆盖仍用 --engine
35
+ ak-role config set-engine judge opus
36
+ ak-role config unset-engine judge
34
37
  ```
35
38
 
39
+ `config set` 存席位模型默认;`config set-engine` / `unset-engine` 在 judge|reviewer 上写入或清除持久劳务引擎名(与 `--engine` 同轴)。用法与拒绝文案以公开 CLI 的 `ak-role config` 为准。
40
+
36
41
  回执是 typed 的,调用者不必解析散文即可组合角色;顺序与停止归调用者。编程消费者从 `src/package-contracts/` 导出推导契约,不从本文。
37
42
 
43
+ 当 Judge/Reviewer 劳务引擎绕行失败、座席回到主路继续劳务时,typed 回执可带机械字段 `engineLaborFallback`:`{ engine, failure, laborBy: "seat" }`。仅在真实绕行失败并座席顶班后出现(含 detour 工具命中 package-owned idle 超时)——成功绕行或调用方 cancel 不出现。同一次 activation 内先到先得;无包内 latch 时剥离模型伪造的 `engineLaborFallback` 键。唯一构造点:`src/engine-labor-fallback.ts`;决策记录:[ADR 0071](docs/adr/0071-engine-detour-failure-seat-fallback-declaration.md)。本文只投影该契约。
44
+
38
45
  ## 调用百官
39
46
 
40
47
  公开 option 身份、别名、必填性与 mode 面以生成区 [公开 CLI 选项(生成)](#公开-cli-选项生成) 与 `ak-role help <command>` 为准——二者同源。下例只是用法速写,不是第二份旗标合同。指令对大理寺、门下省、太医署可省略,对将作监、修内司、御史台、校书郎必须非空。
@@ -1,16 +1,51 @@
1
1
  import { Type } from "typebox";
2
2
  import { ENGINE_DETOUR_ALREADY_USED_DIAGNOSTIC, ENGINE_DETOUR_TOOL_NAME, engineDetourFailureDiagnostic, engineNameFromEnv, isEngineDetourFailure, runEngineDetourOnce, } from "./engine-detour.js";
3
- import { wrapPackageOwnedToolDefinition } from "./package-owned-tool-idle.js";
3
+ import { activationEngineLaborFallbackLatch, recordEngineLaborFallback, } from "./engine-labor-fallback.js";
4
+ import { isPackageOwnedToolIdleTimeoutError, pokePackageOwnedToolIdle, wrapPackageOwnedToolDefinition, } from "./package-owned-tool-idle.js";
4
5
  const engineDetourArgsSchema = Type.Object({
5
6
  argv: Type.Array(Type.String({ minLength: 1 }), {
6
7
  minItems: 1,
7
8
  description: "Executable argv for one engine subprocess. First element is the command (PATH lookup); remaining elements are arguments. Build argv from the host CLI actual interface for the configured engine name; when optional packaged notes are present in the session prompt, follow those bytes. Do not invent package flags.",
8
9
  }),
9
10
  }, { additionalProperties: false });
11
+ function seatFallbackToolResult(field, failure) {
12
+ return {
13
+ content: [
14
+ {
15
+ type: "text",
16
+ text: `Engine detour failed: ${failure}. Perform the labor in this session (seat main road) and submit via the existing typed path. A mechanical fallback declaration will attach to the typed receipt.`,
17
+ },
18
+ ],
19
+ details: {
20
+ tool: ENGINE_DETOUR_TOOL_NAME,
21
+ detourFailed: true,
22
+ ...field,
23
+ },
24
+ };
25
+ }
26
+ /** Caller/upper-layer cancel must propagate; idle backstop is seat-fallback, not cancel. */
27
+ function isCallerCancellation(error, signal) {
28
+ if (isPackageOwnedToolIdleTimeoutError(error))
29
+ return false;
30
+ if (signal !== undefined &&
31
+ isPackageOwnedToolIdleTimeoutError(signal.reason)) {
32
+ return false;
33
+ }
34
+ if (signal?.aborted === true)
35
+ return true;
36
+ if (typeof error === "object" &&
37
+ error !== null &&
38
+ error.name === "AbortError") {
39
+ return true;
40
+ }
41
+ return false;
42
+ }
10
43
  /**
11
44
  * Build one once-latch detour tool definition for a configured engine name.
12
45
  * `latch` is shared so parent registration can reset between activations.
13
- * `fail` owns host abort (parent) vs throw (evidence child).
46
+ * `fail` owns host abort (parent) vs throw (evidence child) for tool misuse only.
47
+ * Engine process failure (nonzero/empty/spawn/idle-timeout) soft-returns seat fallback (#380).
48
+ * Caller AbortSignal cancel propagates without writing fallback.
14
49
  */
15
50
  export function createEngineDetourToolDefinition(input) {
16
51
  const latch = input.latch ?? { used: false };
@@ -24,6 +59,7 @@ export function createEngineDetourToolDefinition(input) {
24
59
  `Use ${ENGINE_DETOUR_TOOL_NAME} exactly once for the configured engine (${engineName}). Optional packaged notes are guidance when present; a bare engine name alone is also a valid call path.`,
25
60
  "Pass argv for the host CLI of this engine name — first element is the executable name on PATH. Follow optional packaged notes when delivered; otherwise act from the engine name and the host CLI actual interface. Do not invent package flags.",
26
61
  "On success, use the returned stdout as labor content for the existing typed submission / report path.",
62
+ "On engine failure the tool returns a soft failure: continue labor in this session and submit via the existing typed path. Do not treat engine failure as a reason to withhold the typed receipt.",
27
63
  ],
28
64
  parameters: engineDetourArgsSchema,
29
65
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
@@ -36,19 +72,36 @@ export function createEngineDetourToolDefinition(input) {
36
72
  if (argv.length === 0 || argv.some((part) => typeof part !== "string" || part.length === 0)) {
37
73
  input.fail(new Error("engine detour argv must be a non-empty string array"), toolCallId, ctx);
38
74
  }
75
+ const softFail = (failure) => {
76
+ // Activation-scoped latch is the sole shared recorder (parent seat + legs).
77
+ const fallbackLatch = activationEngineLaborFallbackLatch() ?? { field: undefined };
78
+ const field = recordEngineLaborFallback(fallbackLatch, {
79
+ engine: engineName,
80
+ failure,
81
+ });
82
+ return seatFallbackToolResult(field, failure);
83
+ };
39
84
  let result;
40
85
  try {
86
+ // Byte activity on stdout/stderr touches the outer package-owned idle clock
87
+ // (183s silence law unchanged). True hangs still die; slow streaming engines live.
41
88
  result = await runEngineDetourOnce({
42
89
  argv,
43
90
  cwd: ctx.cwd,
44
91
  ...(signal === undefined ? {} : { signal }),
92
+ onOutputActivity: pokePackageOwnedToolIdle,
45
93
  });
46
94
  }
47
95
  catch (error) {
48
- input.fail(error instanceof Error ? error : new Error(String(error)), toolCallId, ctx);
96
+ // Caller cancel: propagate. Idle backstop + spawn/engine failure: seat fallback.
97
+ if (isCallerCancellation(error, signal)) {
98
+ throw error;
99
+ }
100
+ const failure = error instanceof Error ? error.message : String(error);
101
+ return softFail(failure.trim() === "" ? "engine detour spawn failed" : failure);
49
102
  }
50
103
  if (isEngineDetourFailure(result)) {
51
- input.fail(new Error(engineDetourFailureDiagnostic(result)), toolCallId, ctx);
104
+ return softFail(engineDetourFailureDiagnostic(result));
52
105
  }
53
106
  return {
54
107
  content: [{ type: "text", text: result.stdout }],
@@ -10,9 +10,21 @@ export const ENGINE_DETOUR_TOOL_NAME = "ak_engine_detour";
10
10
  export const AK_ROLE_ENGINE_ENV = "AK_ROLE_ENGINE";
11
11
  export const ENGINE_DETOUR_EMPTY_STDOUT_DIAGNOSTIC = "engine detour produced empty stdout";
12
12
  export const ENGINE_DETOUR_ALREADY_USED_DIAGNOSTIC = "engine detour already used in this activation";
13
+ function abortReasonError(signal) {
14
+ const reason = signal.reason;
15
+ if (reason instanceof Error)
16
+ return reason;
17
+ if (typeof reason === "string" && reason.trim() !== "") {
18
+ return new Error(reason);
19
+ }
20
+ const error = new Error("aborted");
21
+ error.name = "AbortError";
22
+ return error;
23
+ }
13
24
  /**
14
25
  * Run one engine subprocess. First argv element is the executable (PATH lookup).
15
26
  * stdio: ignore stdin, pipe stdout+stderr. No shell, no retry, no hang timer.
27
+ * AbortSignal cancels the child immediately via an explicit listener (reason preserved).
16
28
  */
17
29
  export async function runEngineDetourOnce(input) {
18
30
  if (input.argv.length === 0) {
@@ -22,32 +34,69 @@ export async function runEngineDetourOnce(input) {
22
34
  const args = input.argv.slice(1);
23
35
  return await new Promise((resolve, reject) => {
24
36
  let settled = false;
37
+ const signal = input.signal;
38
+ // Own abort→kill explicitly so rejection preserves signal.reason (caller cancel
39
+ // vs package-owned idle). Do not pass `signal` to spawn (Node replaces reason).
25
40
  const child = spawn(command, args, {
26
41
  cwd: input.cwd,
27
42
  env: input.env ?? process.env,
28
43
  stdio: ["ignore", "pipe", "pipe"],
29
- ...(input.signal === undefined ? {} : { signal: input.signal }),
30
44
  });
31
45
  let stdout = "";
32
46
  let stderr = "";
47
+ const noteActivity = (chunk) => {
48
+ if (chunk.length === 0)
49
+ return;
50
+ input.onOutputActivity?.();
51
+ };
33
52
  child.stdout.setEncoding("utf8").on("data", (chunk) => {
34
53
  stdout += chunk;
54
+ noteActivity(chunk);
35
55
  });
36
56
  child.stderr.setEncoding("utf8").on("data", (chunk) => {
37
57
  stderr += chunk;
58
+ noteActivity(chunk);
38
59
  });
39
60
  const fail = (error) => {
40
61
  if (settled)
41
62
  return;
42
63
  settled = true;
64
+ if (signal !== undefined) {
65
+ signal.removeEventListener("abort", onAbort);
66
+ }
43
67
  reject(error instanceof Error ? error : new Error(String(error)));
44
68
  };
45
- child.on("error", (error) => fail(error));
46
- child.on("close", (code) => {
69
+ const succeed = (result) => {
47
70
  if (settled)
48
71
  return;
49
72
  settled = true;
50
- resolve({ code: code ?? 1, stdout, stderr });
73
+ if (signal !== undefined) {
74
+ signal.removeEventListener("abort", onAbort);
75
+ }
76
+ resolve(result);
77
+ };
78
+ const onAbort = () => {
79
+ // Fail synchronously so cooperative idle/cancel paths can soft-settle
80
+ // before the outer package-owned idle hard-reject drain.
81
+ fail(signal !== undefined ? abortReasonError(signal) : new Error("aborted"));
82
+ try {
83
+ child.kill("SIGTERM");
84
+ }
85
+ catch {
86
+ // already exited
87
+ }
88
+ };
89
+ if (signal !== undefined) {
90
+ if (signal.aborted) {
91
+ onAbort();
92
+ }
93
+ else {
94
+ signal.addEventListener("abort", onAbort, { once: true });
95
+ }
96
+ }
97
+ child.on("error", (error) => fail(error));
98
+ child.on("close", (code) => {
99
+ succeed({ code: code ?? 1, stdout, stderr });
51
100
  });
52
101
  });
53
102
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * #380 — sole shared seat-fallback declaration after engine detour failure.
3
+ * Detour rejoins the main road (ADR 0069); silence is the only crime.
4
+ * Construction of the typed receipt field lives in exactly one site (S1).
5
+ */
6
+ /** Activation-scoped latch holder (one parent seat activation at a time). */
7
+ let activationLatch;
8
+ /** Sole construction site for typed receipt field `engineLaborFallback` (#380 S1). */
9
+ export function buildEngineLaborFallbackField(input) {
10
+ return Object.freeze({
11
+ engineLaborFallback: Object.freeze({
12
+ engine: input.engine,
13
+ failure: input.failure,
14
+ laborBy: "seat",
15
+ }),
16
+ });
17
+ }
18
+ export function createEngineLaborFallbackLatch() {
19
+ return { field: undefined };
20
+ }
21
+ /**
22
+ * Record first detour failure for this latch (first wins; parallel legs share one field).
23
+ * Always returns the latched first-wins value so tool details and receipt projection match.
24
+ */
25
+ export function recordEngineLaborFallback(latch, input) {
26
+ const field = buildEngineLaborFallbackField(input);
27
+ if (latch.field === undefined)
28
+ latch.field = field;
29
+ return latch.field;
30
+ }
31
+ export function readEngineLaborFallbackField(latch) {
32
+ return latch?.field;
33
+ }
34
+ /**
35
+ * Merge sole-built field into typed receipt details.
36
+ * Spread only — must not construct the field key again (S1).
37
+ * Without a mechanical latch, strip any model-injected reserved key (no forged declaration).
38
+ */
39
+ export function withEngineLaborFallbackField(receipt, field) {
40
+ if (field !== undefined) {
41
+ return { ...receipt, ...field };
42
+ }
43
+ if (!Object.prototype.hasOwnProperty.call(receipt, "engineLaborFallback")) {
44
+ return receipt;
45
+ }
46
+ const { engineLaborFallback: _forged, ...rest } = receipt;
47
+ return rest;
48
+ }
49
+ /** Install the activation-scoped latch (Judge/Reviewer session_start). */
50
+ export function installActivationEngineLaborFallbackLatch(latch) {
51
+ activationLatch = latch;
52
+ }
53
+ /** Clear activation latch (session end / next activation). */
54
+ export function clearActivationEngineLaborFallbackLatch() {
55
+ activationLatch = undefined;
56
+ }
57
+ /** Active activation latch, if any (legs inherit parent seat activation). */
58
+ export function activationEngineLaborFallbackLatch() {
59
+ return activationLatch;
60
+ }
61
+ /** Read fallback field from the active activation latch. */
62
+ export function readActivationEngineLaborFallbackField() {
63
+ return readEngineLaborFallbackField(activationLatch);
64
+ }
65
+ /**
66
+ * Read a previously attached declaration from a typed receipt / details object.
67
+ * Rebuilds via the sole construction site — callers must spread, never re-key.
68
+ */
69
+ export function readEngineLaborFallbackFieldFrom(source) {
70
+ if (typeof source !== "object" || source === null || Array.isArray(source)) {
71
+ return undefined;
72
+ }
73
+ let raw;
74
+ try {
75
+ raw = source.engineLaborFallback;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
81
+ return undefined;
82
+ const rec = raw;
83
+ if (typeof rec.engine !== "string" ||
84
+ typeof rec.failure !== "string" ||
85
+ rec.laborBy !== "seat") {
86
+ return undefined;
87
+ }
88
+ return buildEngineLaborFallbackField({
89
+ engine: rec.engine,
90
+ failure: rec.failure,
91
+ });
92
+ }
93
+ /**
94
+ * Restore activation latch from durable session tool results (#380 resume).
95
+ * Scans existing same-session detour tool results only — no sidecar / new entry type.
96
+ * Replays through recordEngineLaborFallback so first-wins + sole producer stay intact.
97
+ */
98
+ export function restoreEngineLaborFallbackFromSessionEntries(latch, entries, toolName) {
99
+ for (const entry of entries) {
100
+ if (typeof entry !== "object" || entry === null)
101
+ continue;
102
+ const row = entry;
103
+ if (row.type !== "message")
104
+ continue;
105
+ const message = row.message;
106
+ if (typeof message !== "object" || message === null)
107
+ continue;
108
+ const msg = message;
109
+ if (msg.role !== "toolResult")
110
+ continue;
111
+ if (msg.toolName !== toolName)
112
+ continue;
113
+ const field = readEngineLaborFallbackFieldFrom(msg.details);
114
+ if (field === undefined)
115
+ continue;
116
+ recordEngineLaborFallback(latch, {
117
+ engine: field.engineLaborFallback.engine,
118
+ failure: field.engineLaborFallback.failure,
119
+ });
120
+ }
121
+ }
@@ -472,16 +472,14 @@ export async function executeEvidenceChild(workspace, prompt, context, options =
472
472
  engine: engineName,
473
473
  packageRoot: options.packageRoot,
474
474
  });
475
- // #378: detour failure is durable for this leg later assistant reports cannot wash it.
476
- let engineDetourFailure;
475
+ // #378/#380: same detour tool as parent seat. Engine process failure soft-returns
476
+ // seat main-road fallback (ADR 0069 detour-rejoins-main-road); tool misuse still throws.
477
477
  const engineDetourTool = engineName === undefined
478
478
  ? undefined
479
479
  : createEngineDetourToolDefinition({
480
480
  engineName,
481
481
  fail(error) {
482
- const failure = error instanceof Error ? error : new Error(String(error));
483
- engineDetourFailure = failure;
484
- throw failure;
482
+ throw error instanceof Error ? error : new Error(String(error));
485
483
  },
486
484
  });
487
485
  // No tools allowlist — Pi defaults + unrestricted evidence surface (ADR 0064).
@@ -520,18 +518,8 @@ export async function executeEvidenceChild(workspace, prompt, context, options =
520
518
  await session.prompt(delivered);
521
519
  }
522
520
  catch (error) {
523
- // Engine detour fail becomes isError toolResult and must outrank a later
524
- // provider-shaped throw from the same prompt turn (#378).
525
- if (engineDetourFailure !== undefined) {
526
- throw classifiedError(engineDetourFailure, "child");
527
- }
528
521
  throw classifiedError(error, "provider");
529
522
  }
530
- // Launched-leg detour non-zero / empty stdout / spawn failure is infrastructure:
531
- // reject the leg even when the model still emits a non-blank report afterward.
532
- if (engineDetourFailure !== undefined) {
533
- throw classifiedError(engineDetourFailure, "child");
534
- }
535
523
  if (signal?.aborted)
536
524
  throw new Error("Evidence child was cancelled");
537
525
  const lastAssistant = [...session.messages]
@@ -4,11 +4,16 @@
4
4
  * Fixed 183000ms silence clock on package-owned tool execute only.
5
5
  * Real producing onUpdate resets; final resolve/reject clears; timeout throws so
6
6
  * Pi settles the current call as an LLM-visible isError tool result. No retry,
7
- * role failure, process termination, signal abort, config, or Pi built-in coverage.
7
+ * role failure, process termination, config, or Pi built-in coverage.
8
8
  *
9
9
  * #339: do not name-exempt whole terminating tools. Outer idle stays armed for
10
10
  * pre/post-audit work. Only the real compliance-audit await suspends this single
11
11
  * layer (ADR 0059 owns that interval); resume re-arms the same outer backstop.
12
+ *
13
+ * #380: on idle, abort a derived tool signal (not the caller signal) so detour
14
+ * spawn cleanup and softFail can run through the existing AbortSignal path.
15
+ * Cooperative soft-settle may win; non-listeners still hard-reject after a
16
+ * microtask drain (no second timer).
12
17
  */
13
18
  import { AsyncLocalStorage } from "node:async_hooks";
14
19
  import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, createStreamIdleGuard, } from "./stream-idle-guard.js";
@@ -29,6 +34,13 @@ export class PackageOwnedToolIdleTimeoutError extends Error {
29
34
  this.name = "PackageOwnedToolIdleTimeoutError";
30
35
  }
31
36
  }
37
+ export function isPackageOwnedToolIdleTimeoutError(value) {
38
+ return (value instanceof PackageOwnedToolIdleTimeoutError ||
39
+ (typeof value === "object" &&
40
+ value !== null &&
41
+ value.name === "PackageOwnedToolIdleTimeoutError" &&
42
+ value.code === PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_CODE));
43
+ }
32
44
  /**
33
45
  * Package-tool activity includes content production and host-only details
34
46
  * progress. Keep the observation-plane oracle separate: its stderr heartbeat
@@ -68,6 +80,14 @@ export async function withPackageOwnedToolIdleSuspended(run) {
68
80
  scope.resume();
69
81
  }
70
82
  }
83
+ /**
84
+ * Touch the active package-owned execute idle clock (reuse stream-idle-guard poke).
85
+ * No-op outside a wrapped package-owned execute or while that layer is suspended.
86
+ * Used by long-running tools (engine detour) that see real subprocess output bytes.
87
+ */
88
+ export function pokePackageOwnedToolIdle() {
89
+ packageOwnedToolIdleScope.getStore()?.poke();
90
+ }
71
91
  /**
72
92
  * Single shared execute wrapper for package-owned tool definitions.
73
93
  * Idempotent: wrapping twice returns the same protected definition.
@@ -79,7 +99,7 @@ export function wrapPackageOwnedToolDefinition(tool) {
79
99
  return tool;
80
100
  const originalExecute = tool.execute.bind(tool);
81
101
  const wrappedExecute = function packageOwnedToolIdleExecute(...args) {
82
- const signal = args[2];
102
+ const parentSignal = args[2];
83
103
  const onUpdate = args[3];
84
104
  return new Promise((resolve, reject) => {
85
105
  let settled = false;
@@ -87,16 +107,47 @@ export function wrapPackageOwnedToolDefinition(tool) {
87
107
  let idle = createStreamIdleGuard({
88
108
  idleTimeoutMs: PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_MS,
89
109
  });
110
+ // Derived tool signal: caller cancel forwards; idle aborts this only (not parent).
111
+ const toolController = new AbortController();
112
+ const onParentAbort = () => {
113
+ if (toolController.signal.aborted)
114
+ return;
115
+ toolController.abort(parentSignal?.reason);
116
+ };
117
+ if (parentSignal !== undefined) {
118
+ if (parentSignal.aborted) {
119
+ toolController.abort(parentSignal.reason);
120
+ }
121
+ else {
122
+ parentSignal.addEventListener("abort", onParentAbort);
123
+ }
124
+ }
125
+ const cleanup = () => {
126
+ idle.signal.removeEventListener("abort", onIdle);
127
+ idle.dispose();
128
+ parentSignal?.removeEventListener("abort", onParentAbort);
129
+ };
90
130
  const settle = (deliver) => {
91
131
  if (settled)
92
132
  return;
93
133
  settled = true;
94
- idle.signal.removeEventListener("abort", onIdle);
95
- idle.dispose();
134
+ cleanup();
96
135
  deliver();
97
136
  };
98
137
  const onIdle = () => {
99
- settle(() => reject(new PackageOwnedToolIdleTimeoutError()));
138
+ const idleError = new PackageOwnedToolIdleTimeoutError();
139
+ if (!toolController.signal.aborted) {
140
+ toolController.abort(idleError);
141
+ }
142
+ // Cooperative tools (detour) soft-settle via the aborted signal in the
143
+ // next microtasks. Non-listeners still hard-reject after a short drain
144
+ // of those microtasks — no second wall-clock timer.
145
+ void Promise.resolve()
146
+ .then(() => { })
147
+ .then(() => { })
148
+ .then(() => {
149
+ settle(() => reject(idleError));
150
+ });
100
151
  };
101
152
  idle.signal.addEventListener("abort", onIdle, { once: true });
102
153
  const suspension = {
@@ -118,6 +169,11 @@ export function wrapPackageOwnedToolDefinition(tool) {
118
169
  });
119
170
  idle.signal.addEventListener("abort", onIdle, { once: true });
120
171
  },
172
+ poke() {
173
+ if (settled || suspended)
174
+ return;
175
+ idle.poke();
176
+ },
121
177
  };
122
178
  const guardedOnUpdate = onUpdate === undefined
123
179
  ? undefined
@@ -129,8 +185,9 @@ export function wrapPackageOwnedToolDefinition(tool) {
129
185
  onUpdate(partialResult);
130
186
  };
131
187
  const callArgs = args.slice();
132
- // Preserve the original signal at args[2]; timeout must not abort it.
133
- callArgs[2] = signal;
188
+ // Pass derived signal so idle/cancel can clean up subprocesses without
189
+ // aborting the caller/parent agent signal identity.
190
+ callArgs[2] = toolController.signal;
134
191
  callArgs[3] = guardedOnUpdate;
135
192
  void packageOwnedToolIdleScope.run(suspension, async () => {
136
193
  try {