pi-crew 0.11.24 → 0.11.26

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.
Files changed (112) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -2
  3. package/agents/designer.md +23 -0
  4. package/agents/orchestrator.md +17 -0
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +568 -559
  7. package/{workflows/chain.workflow.md → docs/chain-workflow.md} +8 -0
  8. package/docs/dynamic-workflows.md +23 -0
  9. package/docs/publishing.md +34 -5
  10. package/docs/troubleshooting.md +50 -2
  11. package/docs/usage.md +7 -2
  12. package/install.mjs +2 -1
  13. package/package.json +4 -2
  14. package/skills/read-only-explorer/SKILL.md +15 -5
  15. package/skills/real-test-pi-crew/SKILL.md +7 -7
  16. package/src/config/config-validation.ts +58 -0
  17. package/src/config/config.ts +90 -5
  18. package/src/config/env-vars.ts +14 -7
  19. package/src/config/role-tools.ts +7 -5
  20. package/src/config/types.ts +4 -1
  21. package/src/extension/crew-cleanup.ts +6 -0
  22. package/src/extension/project-init.ts +19 -1
  23. package/src/extension/register.ts +68 -30
  24. package/src/extension/registration/artifact-cleanup.ts +52 -11
  25. package/src/extension/registration/commands/manage.ts +9 -2
  26. package/src/extension/registration/hook-registration.ts +3 -0
  27. package/src/extension/registration/lazy-configurers.ts +2 -0
  28. package/src/extension/registration/lifecycle-handlers.ts +79 -18
  29. package/src/extension/registration/render-loop.ts +27 -2
  30. package/src/extension/registration/tool-loop-guard.ts +5 -3
  31. package/src/extension/registration/verify-gate-tool.ts +7 -3
  32. package/src/extension/team-tool/api/read.ts +12 -3
  33. package/src/extension/team-tool/cancel.ts +11 -0
  34. package/src/extension/team-tool/config-patch.ts +56 -11
  35. package/src/extension/team-tool/destructive-gate.ts +72 -12
  36. package/src/extension/team-tool/dispatch/manage.ts +9 -1
  37. package/src/extension/team-tool/handle-schedule.ts +38 -4
  38. package/src/extension/team-tool/handle-settings.ts +76 -6
  39. package/src/extension/team-tool/health-monitor.ts +14 -2
  40. package/src/extension/team-tool/lifecycle-actions.ts +28 -10
  41. package/src/extension/team-tool/respond.ts +12 -5
  42. package/src/extension/team-tool/run-deadline.ts +29 -2
  43. package/src/extension/team-tool/run.ts +11 -0
  44. package/src/extension/team-tool/status.ts +64 -8
  45. package/src/extension/team-tool/workflow-manage.ts +5 -1
  46. package/src/extension/team-tool.ts +3 -0
  47. package/src/i18n.ts +18 -14
  48. package/src/prompt/inbox-poll.ts +14 -2
  49. package/src/prompt/message-tool.ts +1 -1
  50. package/src/prompt/prompt-runtime.ts +25 -3
  51. package/src/prompt/scratchpad-lifecycle.ts +14 -13
  52. package/src/prompt/surface-worker.ts +35 -9
  53. package/src/runtime/broker/crew-broker.ts +113 -111
  54. package/src/runtime/broker/delegate/delegate-abort.ts +118 -0
  55. package/src/runtime/broker/delegate/delegate-event.ts +3 -1
  56. package/src/runtime/broker/delegate/delegate-finalize.ts +179 -0
  57. package/src/runtime/broker/protocol/wait-resolve.ts +71 -5
  58. package/src/runtime/budget-enforcement.ts +26 -6
  59. package/src/runtime/chain-parser.ts +5 -3
  60. package/src/runtime/chain-runner.ts +41 -22
  61. package/src/runtime/child-pi/mock-fixtures.ts +12 -5
  62. package/src/runtime/compaction/tool-output-pruner.ts +8 -3
  63. package/src/runtime/delegate-spawn.ts +61 -8
  64. package/src/runtime/dispatch-batch.ts +10 -4
  65. package/src/runtime/effectiveness.ts +31 -3
  66. package/src/runtime/goal-workflow/plan-templates.ts +2 -2
  67. package/src/runtime/live-session/live-agent-control.ts +89 -6
  68. package/src/runtime/live-session/live-agent-manager.ts +94 -10
  69. package/src/runtime/live-session/live-session-runtime.ts +9 -2
  70. package/src/runtime/model/model-fallback.ts +6 -9
  71. package/src/runtime/model/pi-args.ts +3 -3
  72. package/src/runtime/model/runtime-policy.ts +4 -2
  73. package/src/runtime/model/runtime-resolver.ts +6 -2
  74. package/src/runtime/process/orphan-reaper.ts +143 -0
  75. package/src/runtime/process-status.ts +38 -16
  76. package/src/runtime/resilient-edit.ts +170 -93
  77. package/src/runtime/role-permission.ts +3 -1
  78. package/src/runtime/run-tracker.ts +9 -1
  79. package/src/runtime/sanitize/worker-text.ts +98 -0
  80. package/src/runtime/scheduling/run-coalesced-task-group.ts +82 -28
  81. package/src/runtime/spawn-policy.ts +190 -8
  82. package/src/runtime/stale-reconciler.ts +36 -1
  83. package/src/runtime/subagent-manager.ts +29 -0
  84. package/src/runtime/surface/herdr-provider.ts +31 -1
  85. package/src/runtime/task-output-context.ts +58 -11
  86. package/src/runtime/task-runner/post-execution.ts +150 -0
  87. package/src/runtime/task-runner/prompt-builder.ts +41 -8
  88. package/src/runtime/task-runner.ts +16 -3
  89. package/src/runtime/verification/classifier-pre-gate.ts +27 -6
  90. package/src/runtime/verification/verify-gate.ts +153 -30
  91. package/src/state/atomic-write.ts +73 -4
  92. package/src/state/contracts.ts +7 -0
  93. package/src/state/coordination/mailbox.ts +40 -11
  94. package/src/state/event-log/event-log-rotation.ts +28 -6
  95. package/src/state/event-log/poison-marker.ts +16 -1
  96. package/src/state/stores/artifact-store.ts +181 -6
  97. package/src/state/stores/manifest-io.ts +126 -8
  98. package/src/state/stores/state-store.ts +30 -31
  99. package/src/state/types.ts +13 -0
  100. package/src/state/usage.ts +41 -0
  101. package/src/ui/run-dashboard.ts +3 -3
  102. package/src/ui/settings-overlay.ts +19 -1
  103. package/src/utils/internal-error.ts +5 -1
  104. package/src/utils/timer-limits.ts +30 -0
  105. package/src/utils/token-counter.ts +47 -0
  106. package/src/workflows/discover-workflows.ts +9 -1
  107. package/src/worktree/worktree-manager.ts +128 -18
  108. package/workflows/default.workflow.md +1 -1
  109. package/workflows/fast-fix.workflow.md +1 -1
  110. package/workflows/plan-execute.workflow.md +1 -1
  111. package/workflows/review.workflow.md +1 -1
  112. package/workflows/strict-fast-fix.workflow.md +1 -1
@@ -14,6 +14,13 @@
14
14
  * apply (`--tools`/`--exclude-tools` from role-tools config) — a
15
15
  * "read-only" explorer/analyst grandchild must actually be read-only
16
16
  * (ADR-5 §9's serialization exemption depends on it).
17
+ * R43-S1 (2026-10-19 fix-wave 6): the restrictions are now TRANSITIVE —
18
+ * the effective grandchild tool policy is the intersection (min) of the
19
+ * PARENT task role's policy and the requested role's policy
20
+ * (spawn-policy.ts effectiveGrandchildToolPolicy), applied to the
21
+ * in-memory agent config below. The delegate grandchildren are spawned
22
+ * from a MINIMAL AgentConfig (no agents/<role>.md frontmatter is read),
23
+ * so this is the ONLY argv-level tool cut they receive.
17
24
  * - S1#1: the grandchild authenticates under its OWN subId identity
18
25
  * (grandchild-scoped broker token pre-minted by the handler) — never the
19
26
  * parent's task token. Identity fields (`agentId: subId`) match.
@@ -27,11 +34,15 @@ import * as fs from "node:fs";
27
34
  import * as path from "node:path";
28
35
  import type { AgentConfig } from "../agents/agent-config.ts";
29
36
  import { type ChildPiRunResult, runChildPi } from "./child-pi/child-pi.ts";
37
+ import { effectiveGrandchildToolPolicy } from "./spawn-policy.ts";
30
38
 
31
39
  export interface GrandchildSpawnInput {
32
40
  cwd: string;
33
41
  runId: string;
34
42
  parentTaskId: string;
43
+ /** Parent task record role (run-locked read) — R43-S1 transitive tier
44
+ * containment: the grandchild's tool policy is min(parent, requested). */
45
+ parentRole?: string;
35
46
  /** Nested-slot holder id (minted by the broker handler). */
36
47
  subId: string;
37
48
  prompt: string;
@@ -48,6 +59,13 @@ export interface GrandchildSpawnInput {
48
59
  /** Grandchild-scoped broker credentials (S1#1 — NEVER the parent token). */
49
60
  brokerSpawn?: { socketPath: string; token: string };
50
61
  onSpawn?: (pid: number | null) => void;
62
+ /** M3-5 / R43-N3: owner-side cancellation. The broker's delegate abort
63
+ * registry aborts this on run terminalise / budget abort / broker stop, so
64
+ * a grandchild can no longer outlive its run by up to its own 24h deadline
65
+ * (pre-fix: fire-and-forget spawner with no abort path). Aborting produces
66
+ * `{ ok: false, cancelled: true }` — distinct from the deadline
67
+ * `timedOut` outcome so the settle path can audit which one happened. */
68
+ signal?: AbortSignal;
51
69
  }
52
70
 
53
71
  export interface GrandchildSpawnResult {
@@ -55,19 +73,36 @@ export interface GrandchildSpawnResult {
55
73
  /** Fenced-ish result text (caller fences before mailbox delivery). */
56
74
  resultText: string;
57
75
  /** Usage in tokens for roll-up (input+output+cache summed over
58
- * message_end events); undefined = unattributed. */
76
+ * message_end events); undefined = unattributed.
77
+ * M3-4/R44-N4: AUDIT ONLY — the budget charge is the host-side
78
+ * reservation, never this self-attested number. */
59
79
  usageTokens?: number;
60
80
  timedOut?: boolean;
81
+ /** M3-5/R43-N3: the OWNER aborted the spawn (run terminalise / budget abort
82
+ * / broker stop) — distinct from the grandchild's own deadline. */
83
+ cancelled?: boolean;
61
84
  }
62
85
 
63
- /** Minimal in-memory agent config for a delegate grandchild role. */
64
- function agentForRole(role: string): AgentConfig {
86
+ /**
87
+ * Minimal in-memory agent config for a delegate grandchild role.
88
+ *
89
+ * R43-S1: applies the EFFECTIVE tool policy — intersection (min) of the
90
+ * parent task role's policy and the requested grandchild role's policy —
91
+ * so the pi argv `--tools`/`--exclude-tools` cut reflects the transitive
92
+ * floor. A read-only parent's grandchild is bash/edit/write-denied even if
93
+ * the requested role itself is executor-class (defense-in-depth behind the
94
+ * spawn-policy admission gate).
95
+ */
96
+ export function grandchildAgentForRole(role: string, parentRole?: string): AgentConfig {
97
+ const policy = effectiveGrandchildToolPolicy(parentRole, role);
65
98
  return {
66
99
  name: role,
67
100
  description: `delegate grandchild (${role})`,
68
101
  source: "builtin",
69
102
  filePath: "",
70
103
  systemPrompt: "",
104
+ ...(policy.tools !== undefined ? { tools: policy.tools } : {}),
105
+ ...(policy.excludeTools !== undefined ? { disallowedTools: policy.excludeTools } : {}),
71
106
  };
72
107
  }
73
108
 
@@ -118,7 +153,18 @@ export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Prom
118
153
  const artifactsRoot = grandchildArtifactsRoot(input.cwd, input.runId, input.parentTaskId, input.subId);
119
154
  fs.mkdirSync(artifactsRoot, { recursive: true });
120
155
 
156
+ // M3-5/R43-N3: two abort sources — this spawn's own deadline and the
157
+ // OWNER (broker abort registry). `ownerAborted` distinguishes them for the
158
+ // settle audit; the AbortController it drives is the single signal handed to
159
+ // runChildPi, so both paths kill the child the same way.
121
160
  const abort = new AbortController();
161
+ let ownerAborted = false;
162
+ const onOwnerAbort = (): void => {
163
+ ownerAborted = true;
164
+ abort.abort(input.signal?.reason);
165
+ };
166
+ if (input.signal?.aborted) onOwnerAbort();
167
+ else input.signal?.addEventListener("abort", onOwnerAbort, { once: true });
122
168
  const timer = setTimeout(() => abort.abort(), input.timeoutSec * 1000);
123
169
  timer.unref?.();
124
170
 
@@ -133,8 +179,9 @@ export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Prom
133
179
  const result: ChildPiRunResult = await runChildPi({
134
180
  cwd: input.cwd,
135
181
  task: input.prompt,
136
- agent: agentForRole(input.role),
137
- // S1#2: thread the role so role-based tool restrictions apply.
182
+ // S1#2 + R43-S1: thread the role AND the parent role so the effective
183
+ // (min-intersected) tool policy applies at the argv level.
184
+ agent: grandchildAgentForRole(input.role, input.parentRole),
138
185
  role: input.role,
139
186
  ...(input.model !== undefined ? { model: input.model } : {}),
140
187
  ...(input.maxTurns !== undefined ? { maxTurns: input.maxTurns } : {}),
@@ -154,13 +201,19 @@ export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Prom
154
201
  const ok = result.exitCode === 0;
155
202
  return { ok, resultText: grandchildResultText(result), ...(usageTokens !== undefined ? { usageTokens } : {}) };
156
203
  } catch (error) {
157
- const timedOut = abort.signal.aborted;
204
+ const timedOut = abort.signal.aborted && !ownerAborted;
205
+ const cancelled = ownerAborted;
158
206
  return {
159
207
  ok: false,
160
- timedOut,
161
- resultText: timedOut ? "[delegate timed out]" : `delegate spawn failed: ${(error as Error).message}`,
208
+ ...(cancelled ? { cancelled } : { timedOut }),
209
+ resultText: cancelled
210
+ ? "[delegate cancelled: run terminated]"
211
+ : timedOut
212
+ ? "[delegate timed out]"
213
+ : `delegate spawn failed: ${(error as Error).message}`,
162
214
  };
163
215
  } finally {
164
216
  clearTimeout(timer);
217
+ input.signal?.removeEventListener("abort", onOwnerAbort);
165
218
  }
166
219
  }
@@ -21,6 +21,7 @@ import type { TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/
21
21
  import { logInternalError } from "../utils/internal-error.ts";
22
22
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
23
23
  import { isDelegateShadowTask } from "./broker/delegate/shadow-lifecycle.ts";
24
+ import { repointWaitState } from "./broker/protocol/wait-resolve.ts";
24
25
  import { classifyAmbientNoiseGate } from "./classifier/ambient-noise-gate.ts";
25
26
  import { resolveClassifierEnabled, resolveClassifierModel } from "./classifier/classifier-service.ts";
26
27
  import { readCrewAgents, recordFromTask, saveCrewAgents } from "./crew-agent-records.ts";
@@ -192,11 +193,16 @@ export async function sweepExpiredWaitingTasks(
192
193
  }
193
194
  saveRunTasks(fresh.manifest, tasks);
194
195
  let manifest = fresh.manifest;
195
- // WP-2 review round 1 (P3): clear manifest.waitState when its park was
196
- // requeued — mirror the broker's clearWaitState guard, else the stale
197
- // pointer shields the run from staleness repair for the full 24h TTL.
196
+ // WP-2 review round 1 (P3) + M4-R1(b) (fix-wave 6 residual): the ask-timeout
197
+ // requeue ended ONE park — scoped-forget the slot by RE-POINTING it at a
198
+ // surviving parked task (latest deadline wins, the wait.resolve M3-6
199
+ // invariant) instead of clearing it: a third task may still be parked and
200
+ // this single slot is its only stale-repair shield (isWaitAnswerPending
201
+ // reads only this field). Cleared only when no park is left — which also
202
+ // retires the stale-pointer-shields-the-run issue this guard was added for.
198
203
  if (manifest.waitState && requeuedTaskIds.includes(manifest.waitState.taskId)) {
199
- manifest = { ...manifest, waitState: undefined, updatedAt: new Date().toISOString() };
204
+ const waitState = repointWaitState(tasks, manifest.waitState, (candidateId) => requeuedTaskIds.includes(candidateId));
205
+ manifest = { ...manifest, waitState, updatedAt: new Date().toISOString() };
200
206
  saveRunManifest(manifest);
201
207
  }
202
208
  if (
@@ -34,6 +34,17 @@ export function delegateCompletedTaskIdsFromEvents(events: ReadonlyArray<{ type?
34
34
  return ids;
35
35
  }
36
36
 
37
+ /** R47-V7 (2026-10-19): a task completed by the deterministic gate-skip path
38
+ * (task-runner.ts pre-gate `skipVerifier` branch) carries a SYNTHESIZED
39
+ * terminal result — a fabricated `exitCode: 0`, a green gate summary as the
40
+ * result artifact/stdout, and no model attempt. task-runner tags that
41
+ * provenance as `diagnostics.synthetic = true`. Unlike the genuine gate
42
+ * verdict (also in diagnostics), this synthesized "completed with exit 0"
43
+ * shape must never be mistaken for real worker output. */
44
+ export function taskIsSyntheticGateSkip(task: TeamTaskState): boolean {
45
+ return task.diagnostics?.synthetic === true;
46
+ }
47
+
37
48
  export function taskHasObservableWorkerActivity(task: TeamTaskState): boolean {
38
49
  return Boolean(
39
50
  (task.agentProgress?.toolCount ?? 0) > 0 ||
@@ -49,7 +60,13 @@ export function taskHasObservableWorkerActivity(task: TeamTaskState): boolean {
49
60
  // team_20261010162310): green-gated verifier tasks were false-blocked
50
61
  // here ("no observable worker activity") → run stuck non-terminal
51
62
  // "blocked" → sync team calls hung forever.
52
- task.diagnostics?.verifyGate !== undefined,
63
+ //
64
+ // R47-V7 (2026-10-19): the skip-path result is SYNTHETIC provenance —
65
+ // its exit code is fabricated, not a real worker outcome — so a
66
+ // provenance-tagged synthetic task does NOT count as observable work.
67
+ // (The gate verdict stays on diagnostics as genuine evidence; only the
68
+ // synthesized result is excluded.) See {@link taskIsSyntheticGateSkip}.
69
+ (task.diagnostics?.verifyGate !== undefined && !taskIsSyntheticGateSkip(task)),
53
70
  );
54
71
  }
55
72
 
@@ -106,8 +123,19 @@ export function evaluateRunEffectiveness(input: {
106
123
  let severity: RunEffectivenessSeverity = "ok";
107
124
  if (input.executeWorkers && guardMode !== "off" && noObservedWorkTasks.length > 0) {
108
125
  severity = guardMode === "fail" ? "failed" : guardMode === "block" ? "blocked" : "warning";
109
- // P0.1: default warn escalates to blocked for mutating-role tasks without observed work
110
- if (severity === "warning" && noObservedWorkTasks.some((task) => permissionForRole(task.role) !== "read_only")) {
126
+ // P0.1: default warn escalates to blocked for mutating-role tasks without observed work.
127
+ // R47-V7 / U13 reconciliation (2026-10-19): a synthetic gate-skip task has
128
+ // no observable worker activity (so it is excluded from the score above, per
129
+ // R47-V7) but it is NOT evidence of an idle/broken run — the host gate really
130
+ // verified the change. Escalating it to "blocked" would re-open the U13
131
+ // false-block → non-terminal run → sync team call hang (live-caught
132
+ // 2026-10-10). Exclude synthetic tasks from the auto-escalation so a
133
+ // gate-skipped verifier (a WRITE role) can never block the run by itself.
134
+ // (An explicit guardMode block/fail still honors the user's setting.)
135
+ if (
136
+ severity === "warning" &&
137
+ noObservedWorkTasks.some((task) => !taskIsSyntheticGateSkip(task) && permissionForRole(task.role) !== "read_only")
138
+ ) {
111
139
  severity = "blocked";
112
140
  }
113
141
  }
@@ -141,7 +141,7 @@ registerPlanTemplate({
141
141
  name: "verify",
142
142
  role: "verifier",
143
143
  taskTemplate:
144
- "Verify that all review findings are addressed. Run FAST checks (completes in <2 min): `npm run test:critical && npx tsc --noEmit`. Do NOT run `npm run test:unit` or `npm test` — too slow (642 files, >4 min). Confirm: {{goal}} is achieved.",
144
+ "Verify that all review findings are addressed. Run FAST checks (completes in <2 min): `npm run test:critical && npx tsc --noEmit`. Do NOT run `npm run test:unit` or `npm test` — too slow (642 files, >4 min). Confirm: {{goal}} is achieved. Start the report with the verdict line `PASS: <what verified> — <evidence>` or `FAIL: <what failed> — <reason>` (the green-contract consumer reads it; no verdict line = UNVERIFIED).",
145
145
  maxTasks: 1,
146
146
  dependsOn: ["review"],
147
147
  verificationCommand: "npm run test:critical && npx tsc --noEmit",
@@ -189,7 +189,7 @@ registerPlanTemplate({
189
189
  name: "verify",
190
190
  role: "verifier",
191
191
  taskTemplate:
192
- "Verify the complete implementation of: {{goal}}. Run FAST checks (`npm run test:critical && npx tsc --noEmit`, completes in <2 min). Do NOT run `npm run test:unit` or `npm test` — too slow for in-loop verification (642 files, >4 min). Validate all acceptance criteria.",
192
+ "Verify the complete implementation of: {{goal}}. Run FAST checks (`npm run test:critical && npx tsc --noEmit`, completes in <2 min). Do NOT run `npm run test:unit` or `npm test` — too slow for in-loop verification (642 files, >4 min). Validate all acceptance criteria. Start the report with the verdict line `PASS: <what verified> — <evidence>` or `FAIL: <what failed> — <reason>` (the green-contract consumer reads it; no verdict line = UNVERIFIED).",
193
193
  maxTasks: 1,
194
194
  dependsOn: ["review"],
195
195
  verificationCommand: "npm run test:critical && npx tsc --noEmit",
@@ -22,6 +22,35 @@ export interface LiveAgentControlCursor {
22
22
  offset: number;
23
23
  }
24
24
 
25
+ /**
26
+ * R41-1 (M3-11) — durable processed marker.
27
+ *
28
+ * `processedAt` used to be set on the IN-MEMORY request object only, and the
29
+ * dedupe that made it meaningful lived in a per-invocation
30
+ * `BoundedSeenControlRequestIdSet` (live-session-runtime.ts). After a crash or
31
+ * a resume, the cursor starts at offset 0 and the seen-set is empty, so EVERY
32
+ * control request already applied was read back and re-applied (stop / steer /
33
+ * follow-up replayed into a fresh session).
34
+ *
35
+ * The fix persists the consumed state as an append-only marker line in the same
36
+ * `live-control.jsonl` file: crash-safe, no rewrite of request lines, no extra
37
+ * file to lock, and it survives a resume with a cold cursor. Marker lines are
38
+ * distinguishable from request lines by `type === "processed"` and are filtered
39
+ * out of every read.
40
+ */
41
+ export interface LiveAgentControlMarker {
42
+ type: "processed";
43
+ requestId: string;
44
+ runId: string;
45
+ taskId: string;
46
+ processedAt: string;
47
+ error?: string;
48
+ }
49
+
50
+ function isControlMarker(value: unknown): value is LiveAgentControlMarker {
51
+ return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "processed");
52
+ }
53
+
25
54
  export function liveAgentControlPath(manifest: TeamRunManifest, taskId: string): string {
26
55
  return path.join(ensureAgentStateDir(manifest, taskId), "live-control.jsonl");
27
56
  }
@@ -71,14 +100,31 @@ export function readLiveAgentControlRequests(
71
100
  if (!fs.existsSync(filePath)) return { requests: [], cursor };
72
101
  const text = fs.readFileSync(filePath, "utf-8");
73
102
  const lines = text.split(/\r?\n/).filter(Boolean);
74
- const requests = lines.slice(cursor.offset).flatMap((line) => {
103
+ const records = lines.map((line) => {
75
104
  try {
76
- const parsed = JSON.parse(line) as LiveAgentControlRequest;
77
- return parsed && parsed.runId === manifest.runId && parsed.taskId === taskId ? [parsed] : [];
105
+ return JSON.parse(line) as unknown;
78
106
  } catch {
79
- return [];
107
+ return undefined; // unparseable line — skip (same tolerance as before)
80
108
  }
81
109
  });
110
+ // R41-1: markers may appear anywhere in the file (appended by any process),
111
+ // so the processed set is scanned over the WHOLE file — not just the unread
112
+ // window — before the cursor slice is filtered. This is what makes a cold
113
+ // cursor after crash/resume replay-free.
114
+ const processedRequestIds = new Set<string>();
115
+ for (const record of records) {
116
+ if (!isControlMarker(record)) continue;
117
+ if (record.runId !== manifest.runId || record.taskId !== taskId) continue;
118
+ processedRequestIds.add(record.requestId);
119
+ }
120
+ const requests = records.slice(cursor.offset).flatMap((record) => {
121
+ if (!record || isControlMarker(record)) return [];
122
+ const parsed = record as LiveAgentControlRequest;
123
+ if (parsed.runId !== manifest.runId || parsed.taskId !== taskId) return [];
124
+ // Already consumed in an earlier process (persisted marker) — never replay.
125
+ if (processedRequestIds.has(parsed.id)) return [];
126
+ return [parsed];
127
+ });
82
128
  return { requests, cursor: { offset: lines.length } };
83
129
  }
84
130
 
@@ -92,6 +138,8 @@ export async function applyLiveAgentControlRequest(input: {
92
138
  abort?: () => Promise<void> | void;
93
139
  };
94
140
  seenRequestIds?: Set<string>;
141
+ /** R41-1: when supplied, a processed marker is durably appended after the request is consumed. */
142
+ manifest?: TeamRunManifest;
95
143
  }): Promise<boolean> {
96
144
  const { request, taskId, agentId, session, seenRequestIds } = input;
97
145
  if (seenRequestIds?.has(request.id)) return false;
@@ -130,9 +178,34 @@ export async function applyLiveAgentControlRequest(input: {
130
178
  `runId=${request.runId} taskId=${request.taskId} operation=${request.operation} requestId=${request.id}`,
131
179
  );
132
180
  }
181
+ // R41-1: persist the consumed state BEFORE anyone can observe "applied" — a
182
+ // crash right after this return must not replay the request.
183
+ if (input.manifest) appendProcessedControlMarker(input.manifest, taskId, request);
133
184
  return true;
134
185
  }
135
186
 
187
+ /**
188
+ * R41-1: appends a processed marker for a request this process consumed.
189
+ * Best-effort — a failed marker write must never fail the control loop (the
190
+ * in-memory seen-set still dedupes within this process); it only means the
191
+ * request may replay once after a crash.
192
+ */
193
+ function appendProcessedControlMarker(manifest: TeamRunManifest, taskId: string, request: LiveAgentControlRequest): void {
194
+ const marker: LiveAgentControlMarker = {
195
+ type: "processed",
196
+ requestId: request.id,
197
+ runId: manifest.runId,
198
+ taskId,
199
+ processedAt: request.processedAt ?? new Date().toISOString(),
200
+ ...(request.error ? { error: request.error } : {}),
201
+ };
202
+ try {
203
+ fs.appendFileSync(liveAgentControlFile(manifest, taskId), `${JSON.stringify(marker)}\n`, "utf-8");
204
+ } catch (error) {
205
+ logInternalError("live-agent-control.mark-processed", error, `taskId=${taskId} requestId=${request.id}`);
206
+ }
207
+ }
208
+
136
209
  export async function applyLiveAgentControlRequests(input: {
137
210
  manifest: TeamRunManifest;
138
211
  taskId: string;
@@ -146,13 +219,23 @@ export async function applyLiveAgentControlRequests(input: {
146
219
  seenRequestIds?: Set<string>;
147
220
  }): Promise<LiveAgentControlCursor> {
148
221
  const batch = readLiveAgentControlRequests(input.manifest, input.taskId, input.cursor);
149
- for (const request of batch.requests)
150
- await applyLiveAgentControlRequest({
222
+ for (const request of batch.requests) {
223
+ const applied = await applyLiveAgentControlRequest({
151
224
  request,
152
225
  taskId: input.taskId,
153
226
  agentId: input.agentId,
154
227
  session: input.session,
155
228
  seenRequestIds: input.seenRequestIds,
229
+ manifest: input.manifest,
156
230
  });
231
+ // R41-1: the realtime subscription (live-session-runtime) consumes an
232
+ // already-persisted request WITHOUT a manifest, so it cannot write the
233
+ // marker itself. The poll sees the id in the shared seen-set on its next
234
+ // tick — mark it consumed here so a crash before then cannot replay it.
235
+ // A request addressed to a DIFFERENT agent is never marked (it is not ours
236
+ // to consume): applyLiveAgentControlRequest returns false for it without
237
+ // touching the seen-set.
238
+ if (!applied && input.seenRequestIds?.has(request.id)) appendProcessedControlMarker(input.manifest, input.taskId, request);
239
+ }
157
240
  return batch.cursor;
158
241
  }
@@ -84,6 +84,50 @@ const liveAgents = new Map<string, LiveAgentHandle>();
84
84
  // warning) to keep memory bounded.
85
85
  const MAX_LIVE_AGENTS = 5_000;
86
86
 
87
+ /**
88
+ * R41-4 (M3-12): hard deadline for `session.abort()`.
89
+ *
90
+ * terminateLiveAgent awaited abort() with NO deadline: a session whose abort
91
+ * never settles (hung tool / provider holding the turn) deferred the whole
92
+ * teardown (dispose + registry removal + worker kill) forever, so the record
93
+ * stayed "running" and the process leaked. Mirrors the in-file
94
+ * Promise.race-with-timeout pattern in live-session-runtime.ts
95
+ * (bindExtensions 30s / control 30s).
96
+ */
97
+ const LIVE_AGENT_ABORT_DEADLINE_MS = 10_000;
98
+
99
+ async function abortLiveSessionWithDeadline(handle: LiveAgentHandle, deadlineMs: number = LIVE_AGENT_ABORT_DEADLINE_MS): Promise<void> {
100
+ const abort = handle.session.abort;
101
+ if (typeof abort !== "function") return;
102
+ let timer: ReturnType<typeof setTimeout> | undefined;
103
+ const deadline = new Promise<void>((resolve) => {
104
+ timer = setTimeout(() => {
105
+ logInternalError(
106
+ "live-agent-manager.abort-timeout",
107
+ new Error(`session.abort() did not settle within ${deadlineMs}ms`),
108
+ `agentId=${handle.agentId}`,
109
+ );
110
+ resolve();
111
+ }, deadlineMs);
112
+ // NOTE: deliberately NOT unref'd. A ref'd timer is what lets the deadline
113
+ // actually fire when nothing else keeps the event loop alive (an unref'd
114
+ // deadline on an awaited race can never settle — the RR-021 unref
115
+ // incident). The wait is bounded by deadlineMs, so it cannot leak.
116
+ });
117
+ try {
118
+ // The abort promise is left to settle on its own after the deadline;
119
+ // only the RACE is bounded (teardown must not be deferred).
120
+ await Promise.race([Promise.resolve(abort.call(handle.session)), deadline]);
121
+ } finally {
122
+ if (timer) clearTimeout(timer);
123
+ }
124
+ }
125
+
126
+ /** @internal — test seam for the R41-4 abort deadline (see live-agent-manager-eviction tests). */
127
+ export const __test__abortLiveSessionWithDeadline = abortLiveSessionWithDeadline;
128
+ /** @internal — the production abort deadline, exposed so tests can assert its magnitude. */
129
+ export const __test__LIVE_AGENT_ABORT_DEADLINE_MS = LIVE_AGENT_ABORT_DEADLINE_MS;
130
+
87
131
  /**
88
132
  * List all live agents for a specific workspace.
89
133
  * Only agents belonging to the given workspaceId are returned.
@@ -128,17 +172,23 @@ export function registerLiveAgent(
128
172
  // evict the oldest completed agent (already finished, so caller no
129
173
  // longer needs it). If none exist, evict the oldest running one with
130
174
  // a warning so memory stays bounded.
175
+ // R41-4 (M3-12): eviction used to be a bare map.delete — the evicted
176
+ // session was never aborted/disposed, and the cap could therefore drop a
177
+ // RUNNING record while its session kept executing (an untracked live
178
+ // worker). Both branches now go through evictLiveAgentForCap, which
179
+ // initiates abort() (bounded by a deadline) and disposes before removing
180
+ // the record. Also: "oldest completed" is now picked by completedAtMs, not
181
+ // by insertion order (Map order says nothing about completion time).
131
182
  if (liveAgents.size >= MAX_LIVE_AGENTS) {
132
- const completed = [...liveAgents.entries()].find(([, h]) => h.activity.completedAtMs > 0);
183
+ const completed = [...liveAgents.entries()]
184
+ .filter(([, h]) => h.activity.completedAtMs > 0)
185
+ .sort((a, b) => a[1].activity.completedAtMs - b[1].activity.completedAtMs)[0];
133
186
  if (completed) {
134
- liveAgents.delete(completed[0]);
135
- invalidateSortedLiveAgents();
187
+ evictLiveAgentForCap(completed[0], completed[1], `liveAgents at cap ${MAX_LIVE_AGENTS}`);
136
188
  } else {
137
- const oldestKey = liveAgents.keys().next().value;
138
- if (oldestKey !== undefined) {
139
- logInternalError("live-agent-manager.cap", new Error(`liveAgents at cap ${MAX_LIVE_AGENTS}; evicting oldest ${oldestKey}`));
140
- liveAgents.delete(oldestKey);
141
- invalidateSortedLiveAgents();
189
+ const oldest = liveAgents.entries().next().value;
190
+ if (oldest) {
191
+ evictLiveAgentForCap(oldest[0], oldest[1], `liveAgents at cap ${MAX_LIVE_AGENTS} with no completed agent`);
142
192
  }
143
193
  }
144
194
  }
@@ -259,6 +309,33 @@ function markLiveAgentHandleTerminated(handle: LiveAgentHandle): void {
259
309
  invalidateSortedLiveAgents();
260
310
  }
261
311
 
312
+ /**
313
+ * R41-4 (M3-12): cap eviction = abort (deadline-bounded) + dispose + delete.
314
+ *
315
+ * Order matters: abort() is INITIATED first (it is the only channel that
316
+ * cancels an in-flight turn), then the handle is marked terminal, the session
317
+ * disposed and the record removed. registerLiveAgent is synchronous, so the
318
+ * abort promise itself cannot be awaited — it runs fire-and-forget through
319
+ * abortLiveSessionWithDeadline, so a never-settling abort cannot keep the
320
+ * session alive past the deadline (and never blocks the registration).
321
+ *
322
+ * The delete is identity-checked: only the exact handle being evicted is
323
+ * removed, so a handle re-registered for the same agentId can never be
324
+ * dropped by this path.
325
+ */
326
+ function evictLiveAgentForCap(agentId: string, handle: LiveAgentHandle, reason: string): void {
327
+ logInternalError("live-agent-manager.cap", new Error(`${reason}; evicting ${agentId}`));
328
+ const aborting = abortLiveSessionWithDeadline(handle).catch((error) => {
329
+ logInternalError("live-agent-manager.cap-abort", error, `agentId=${agentId}`);
330
+ });
331
+ markLiveAgentHandleTerminated(handle);
332
+ safeDisposeLiveSession(handle);
333
+ if (liveAgents.get(agentId) === handle) liveAgents.delete(agentId);
334
+ invalidateSortedLiveAgents();
335
+ // Fire-and-forget by design — see docblock. Keeps the timer promise observed.
336
+ void aborting;
337
+ }
338
+
262
339
  /**
263
340
  * U4 hung-tool escape hatch — drop-reference.
264
341
  *
@@ -308,11 +385,18 @@ export async function terminateLiveAgent(
308
385
  /* non-critical */
309
386
  }
310
387
  try {
311
- await handle.session.abort?.();
388
+ // R41-4 (M3-12): abort must not defer teardown — a hung abort() used to
389
+ // leave the handle registered as "running" forever. Bounded by
390
+ // LIVE_AGENT_ABORT_DEADLINE_MS; teardown runs either way (finally).
391
+ await abortLiveSessionWithDeadline(handle);
312
392
  } finally {
313
393
  markLiveAgentHandleTerminated(handle);
314
394
  safeDisposeLiveSession(handle);
315
- liveAgents.delete(handle.agentId); // Move AFTER abort completes to prevent race
395
+ // Move AFTER abort completes to prevent race — and identity-check the
396
+ // entry so a handle re-registered for the same agentId during the
397
+ // bounded abort is not removed (R41-4 sibling invariant to the cap
398
+ // eviction path).
399
+ if (liveAgents.get(handle.agentId) === handle) liveAgents.delete(handle.agentId);
316
400
  invalidateSortedLiveAgents();
317
401
  // WI-5 (SDD-4): if the session still backs a live worker process that
318
402
  // abort()/dispose() failed to stop, signal it last so terminate cannot
@@ -892,6 +892,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
892
892
  const agentId = `${input.manifest.runId}:${input.task.id}`;
893
893
  const inherited =
894
894
  input.runtimeConfig?.inheritContext === true && input.parentContext ? ` with inherited context: ${input.parentContext}` : "";
895
+ // M3-2 (R47-3/V4): the verifier output contract requires a PASS:/FAIL:
896
+ // verdict line; the mock must emit contract-shaped output for verifier-role
897
+ // tasks or the (now real) verdict consumer marks the evidence unverified
898
+ // and the green contract blocks the run (fail-closed — correct for real
899
+ // verifiers, wrong for the success mock).
900
+ const mockVerdictSuffix = input.task.role === "verifier" ? "\nPASS: mock verifier — mock live-session success" : "";
901
+ const mockText = `Mock live-session success for ${input.agent.name}${inherited}${mockVerdictSuffix}`;
895
902
  const event = {
896
903
  type: "message_end",
897
904
  message: {
@@ -899,7 +906,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
899
906
  content: [
900
907
  {
901
908
  type: "text",
902
- text: `Mock live-session success for ${input.agent.name}${inherited}`,
909
+ text: mockText,
903
910
  },
904
911
  ],
905
912
  },
@@ -945,7 +952,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
945
952
  // the batched writers here — tests read these files right after return.
946
953
  flushPendingSidechainWrites();
947
954
  if (isCurrent()) input.onEvent?.(event);
948
- const stdout = `Mock live-session success for ${input.agent.name}${inherited}`;
955
+ const stdout = mockText;
949
956
  if (isCurrent()) input.onOutput?.(stdout);
950
957
  updateLiveAgentStatus(agentId, "completed");
951
958
  markLiveAgentCompleted(agentId);
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { getCrewEnv } from "../../config/env-vars.ts";
4
5
  import { errors } from "../../errors.ts";
5
6
  import { logInternalError } from "../../utils/internal-error.ts";
6
7
  import { fuzzyResolveModelId } from "./model-resolver.ts";
@@ -646,7 +647,7 @@ export function resolveModelFallbackPolicy(
646
647
  | undefined,
647
648
  env: NodeJS.ProcessEnv = process.env,
648
649
  ): ModelFallbackPolicy | undefined {
649
- const envMaxAuto = env.PI_CREW_MAX_AUTO_FALLBACKS;
650
+ const envMaxAuto = getCrewEnv("PI_CREW_MAX_AUTO_FALLBACKS", env);
650
651
  let maxAutoFallbacks: number | undefined;
651
652
  if (envMaxAuto) {
652
653
  const parsed = Number.parseInt(envMaxAuto, 10);
@@ -680,7 +681,7 @@ export function resolveModelFallbackPolicy(
680
681
  // "parent-first") silently behaved as parentFirst. Invalid → warn + fall
681
682
  // back to config/default, mirroring the PI_CREW_MAX_AUTO_FALLBACKS
682
683
  // clamp-warn sibling pattern above.
683
- const envOrder = env.PI_CREW_MODEL_FALLBACK_ORDER;
684
+ const envOrder = getCrewEnv("PI_CREW_MODEL_FALLBACK_ORDER", env);
684
685
  let order: "parentFirst" | "asIs" | undefined;
685
686
  if (envOrder) {
686
687
  if (envOrder === "parentFirst" || envOrder === "asIs") {
@@ -697,12 +698,8 @@ export function resolveModelFallbackPolicy(
697
698
  } else {
698
699
  order = config?.order;
699
700
  }
700
- const requireCredentials =
701
- env.PI_CREW_MODEL_REQUIRE_CREDENTIALS === "1"
702
- ? true
703
- : env.PI_CREW_MODEL_REQUIRE_CREDENTIALS === "0"
704
- ? false
705
- : config?.requireCredentials;
701
+ const envRequireCredentials = getCrewEnv("PI_CREW_MODEL_REQUIRE_CREDENTIALS", env);
702
+ const requireCredentials = envRequireCredentials === "1" ? true : envRequireCredentials === "0" ? false : config?.requireCredentials;
706
703
  // quotaAwareOrdering defaults to true (user preference: default-on with cache).
707
704
  // When explicitly disabled, no deprioritization/rank data is attached.
708
705
  const quotaAware = config?.quotaAwareOrdering !== false;
@@ -725,7 +722,7 @@ export function resolveDefaultSubagentModel(
725
722
  config: { defaultSubagentModel?: string } | undefined,
726
723
  env: NodeJS.ProcessEnv = process.env,
727
724
  ): string | undefined {
728
- return env.PI_CREW_MODEL?.trim() || config?.defaultSubagentModel?.trim() || undefined;
725
+ return getCrewEnv("PI_CREW_MODEL", env)?.trim() || config?.defaultSubagentModel?.trim() || undefined;
729
726
  }
730
727
 
731
728
  export interface ConfiguredModelRouting {
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "../../agents/agent-config.ts";
5
- import { getCrewEnvBool } from "../../config/env-vars.ts";
5
+ import { getCrewEnv, getCrewEnvBool } from "../../config/env-vars.ts";
6
6
  import { atomicWriteFile } from "../../state/atomic-write.ts";
7
7
  import { hasRunStateLayout, packageRoot, userPiRoot } from "../../utils/paths.ts";
8
8
  import { PI_NO_MCP_FLOOR, piVersionAtLeast } from "../child-pi/pi-version.ts";
@@ -131,13 +131,13 @@ export function applyThinkingSuffix(model: string | undefined, thinking: string
131
131
  }
132
132
 
133
133
  export function currentCrewDepth(env: NodeJS.ProcessEnv = process.env): number {
134
- const raw = env.PI_CREW_DEPTH ?? env.PI_TEAMS_DEPTH ?? "0";
134
+ const raw = getCrewEnv("PI_CREW_DEPTH", env) ?? "0";
135
135
  const parsed = Number(raw);
136
136
  return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
137
137
  }
138
138
 
139
139
  export function resolveCrewMaxDepth(inputMaxDepth?: number, env: NodeJS.ProcessEnv = process.env): number {
140
- const raw = env.PI_CREW_MAX_DEPTH ?? env.PI_TEAMS_MAX_DEPTH;
140
+ const raw = getCrewEnv("PI_CREW_MAX_DEPTH", env);
141
141
  const envDepth = raw !== undefined ? Number(raw) : NaN;
142
142
  if (Number.isInteger(envDepth) && envDepth >= 1 && envDepth <= 10) return envDepth;
143
143
  if (Number.isInteger(envDepth) && envDepth > 10) {
@@ -1,4 +1,5 @@
1
1
  import type { CrewRuntimeConfig } from "../../config/config.ts";
2
+ import { getCrewEnv } from "../../config/env-vars.ts";
2
3
  import type { CrewRuntimeKind } from "../crew-agent-runtime.ts";
3
4
  import { currentCrewDepth } from "./pi-args.ts";
4
5
 
@@ -51,8 +52,9 @@ export function resolveEmbeddedFaultThreshold(policy: CrewRuntimeConfig["embedde
51
52
  * set, and PI_CREW_ALLOW_MOCK is filtered from child envs.
52
53
  */
53
54
  export function mockLiveSessionEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
54
- if (env.PI_CREW_MOCK_LIVE_SESSION !== "success") return false;
55
- return env.PI_CREW_ALLOW_MOCK === "1" || env.PI_CREW_ALLOW_MOCK === "true";
55
+ if (getCrewEnv("PI_CREW_MOCK_LIVE_SESSION", env) !== "success") return false;
56
+ const allowMock = getCrewEnv("PI_CREW_ALLOW_MOCK", env);
57
+ return allowMock === "1" || allowMock === "true";
56
58
  }
57
59
 
58
60
  export function resolveTaskRuntimeKind(