pi-crew 0.9.67 → 0.10.1

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 (222) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/agents/analyst.md +1 -1
  3. package/agents/cold-verifier.md +3 -1
  4. package/agents/critic.md +1 -1
  5. package/agents/executor.md +1 -1
  6. package/agents/explorer.md +1 -1
  7. package/agents/planner.md +1 -1
  8. package/agents/reviewer.md +1 -1
  9. package/agents/security-reviewer.md +1 -1
  10. package/agents/test-engineer.md +1 -1
  11. package/agents/verifier.md +1 -1
  12. package/agents/writer.md +1 -1
  13. package/dist/index.mjs +65847 -60331
  14. package/docs/README.md +2 -0
  15. package/docs/actions-reference.md +31 -0
  16. package/docs/commands-reference.md +17 -6
  17. package/docs/resource-formats.md +13 -0
  18. package/package.json +9 -6
  19. package/schema.json +15 -1
  20. package/scripts/resource-sampler.mjs +36 -2
  21. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  22. package/skills/widget-rendering/SKILL.md +7 -7
  23. package/src/agents/agent-config.ts +36 -14
  24. package/src/agents/discover-agents.ts +23 -14
  25. package/src/config/config-merge.ts +183 -0
  26. package/src/config/config-validation.ts +681 -0
  27. package/src/config/config.ts +22 -864
  28. package/src/config/defaults.ts +35 -1
  29. package/src/config/drift-detector.ts +1 -1
  30. package/src/config/env-vars.ts +691 -0
  31. package/src/config/role-tools.ts +8 -8
  32. package/src/config/sanitize-project-config.ts +172 -0
  33. package/src/config/types.ts +30 -0
  34. package/src/extension/async-notifier.ts +25 -2
  35. package/src/extension/crew-vibes/config.ts +2 -1
  36. package/src/extension/plan-orchestrate.ts +132 -0
  37. package/src/extension/registration/commands/dashboard.ts +158 -0
  38. package/src/extension/registration/commands/index.ts +35 -0
  39. package/src/extension/registration/commands/manage.ts +303 -0
  40. package/src/extension/registration/commands/run.ts +214 -0
  41. package/src/extension/registration/commands/shared.ts +631 -0
  42. package/src/extension/registration/commands/status.ts +60 -0
  43. package/src/extension/registration/commands.ts +13 -1224
  44. package/src/extension/registration/lifecycle-handlers.ts +126 -15
  45. package/src/extension/registration/subagent-tools.ts +218 -9
  46. package/src/extension/rpc-hmac.ts +5 -3
  47. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  48. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  49. package/src/extension/team-tool/api/task-claims.ts +109 -40
  50. package/src/extension/team-tool/cancel.ts +84 -50
  51. package/src/extension/team-tool/dispatch/index.ts +1 -0
  52. package/src/extension/team-tool/dispatch/run.ts +4 -1
  53. package/src/extension/team-tool/doctor.ts +103 -1
  54. package/src/extension/team-tool/orchestrate.ts +66 -1
  55. package/src/extension/team-tool/plans.ts +192 -0
  56. package/src/extension/team-tool/respond.ts +197 -65
  57. package/src/extension/team-tool/run-deadline.ts +16 -1
  58. package/src/extension/team-tool/run-intent.ts +63 -0
  59. package/src/extension/team-tool/run.ts +48 -18
  60. package/src/extension/team-tool/status.ts +84 -26
  61. package/src/extension/team-tool.ts +11 -2
  62. package/src/hooks/registry.ts +1 -6
  63. package/src/i18n.ts +9 -0
  64. package/src/prompt/prompt-runtime.ts +521 -2
  65. package/src/prompt/worker-events-channel.ts +173 -0
  66. package/src/runtime/README.md +8 -8
  67. package/src/runtime/async-runner.ts +7 -3
  68. package/src/runtime/background-runner.ts +42 -14
  69. package/src/runtime/broker/broker-issuer.ts +9 -2
  70. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  71. package/src/runtime/broker/crew-broker.ts +838 -10
  72. package/src/runtime/broker/wait-status-cache.ts +157 -0
  73. package/src/runtime/budget-enforcement.ts +281 -0
  74. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  75. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  76. package/src/runtime/child-pi/child-pi.ts +97 -201
  77. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  78. package/src/runtime/crew-agent-records.ts +259 -14
  79. package/src/runtime/delegate-spawn.ts +148 -0
  80. package/src/runtime/deterministic-ast.ts +2 -1
  81. package/src/runtime/dispatch-batch.ts +945 -0
  82. package/src/runtime/finalize-run.ts +557 -0
  83. package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
  84. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
  85. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  86. package/src/runtime/group-join.ts +11 -125
  87. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  88. package/src/runtime/merge-gate.ts +7 -1
  89. package/src/runtime/merge-loop.ts +130 -0
  90. package/src/runtime/model/model-budget-summary.ts +53 -0
  91. package/src/runtime/model/model-fallback.ts +32 -2
  92. package/src/runtime/model/pi-args.ts +10 -0
  93. package/src/runtime/model/provider-extensions.ts +10 -0
  94. package/src/runtime/orphan-worker-registry.ts +1 -1
  95. package/src/runtime/output/output-validator.ts +45 -0
  96. package/src/runtime/parent-guard.ts +3 -1
  97. package/src/runtime/peer-dep.ts +2 -1
  98. package/src/runtime/per-write-validator.ts +0 -5
  99. package/src/runtime/pi-spawn.ts +61 -15
  100. package/src/runtime/plan-approval.ts +125 -0
  101. package/src/runtime/plan-replan.ts +151 -0
  102. package/src/runtime/recovery/checkpoint.ts +0 -18
  103. package/src/runtime/recovery/crash-recovery.ts +95 -46
  104. package/src/runtime/scheduler-context.ts +98 -0
  105. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  106. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  107. package/src/runtime/scheduling/nested-slots.ts +70 -0
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  109. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  110. package/src/runtime/scratchpad/README.md +10 -5
  111. package/src/runtime/scratchpad/guest.ts +103 -2
  112. package/src/runtime/scratchpad/protocol.ts +13 -1
  113. package/src/runtime/scratchpad/transform.ts +206 -12
  114. package/src/runtime/settings-store.ts +219 -0
  115. package/src/runtime/spawn-policy.ts +217 -0
  116. package/src/runtime/stale-reconciler.ts +87 -6
  117. package/src/runtime/subagent-manager.ts +25 -1
  118. package/src/runtime/task-output-context.ts +230 -9
  119. package/src/runtime/task-packet.ts +23 -1
  120. package/src/runtime/task-runner/child-executor.ts +106 -7
  121. package/src/runtime/task-runner/post-execution.ts +125 -1
  122. package/src/runtime/task-runner/pre-execution.ts +39 -1
  123. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  124. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  125. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  126. package/src/runtime/task-runner/state-helpers.ts +26 -24
  127. package/src/runtime/task-runner.ts +11 -0
  128. package/src/runtime/team-runner.ts +129 -1671
  129. package/src/runtime/verification/spec-sandbox.ts +255 -0
  130. package/src/runtime/verification/verification-gates.ts +3 -2
  131. package/src/runtime/verification/verification-worktree.ts +2 -1
  132. package/src/runtime/workflow-phase-advance.ts +100 -0
  133. package/src/runtime/workspace-tree.ts +9 -0
  134. package/src/schema/config-schema.ts +65 -25
  135. package/src/schema/sensitive-config-paths.ts +64 -0
  136. package/src/schema/team-tool-schema.ts +13 -3
  137. package/src/state/README.md +4 -10
  138. package/src/state/atomic-write.ts +20 -3
  139. package/src/state/contracts.ts +38 -0
  140. package/src/state/coordination/mailbox.ts +12 -2
  141. package/src/state/event-log/cursor.ts +223 -0
  142. package/src/state/event-log/event-log-rotation.ts +12 -4
  143. package/src/state/event-log/event-log.ts +152 -369
  144. package/src/state/event-log/sequence-cache.ts +373 -0
  145. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  146. package/src/state/stores/active-run-registry.ts +3 -2
  147. package/src/state/stores/manifest-io.ts +237 -0
  148. package/src/state/stores/ownership-map.ts +162 -0
  149. package/src/state/stores/plan-store.ts +241 -0
  150. package/src/state/stores/run-cache.ts +0 -90
  151. package/src/state/stores/spec-store.ts +189 -0
  152. package/src/state/stores/state-store.ts +66 -229
  153. package/src/state/types.ts +197 -0
  154. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  155. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  156. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  157. package/src/ui/heartbeat-aggregator.ts +9 -1
  158. package/src/ui/keybinding-map.ts +54 -13
  159. package/src/ui/powerbar-publisher.ts +52 -1
  160. package/src/ui/run-dashboard.ts +31 -5
  161. package/src/ui/run-snapshot-cache.ts +57 -30
  162. package/src/ui/snapshot-types.ts +6 -1
  163. package/src/ui/widget/widget-renderer.ts +9 -1
  164. package/src/utils/file-coalescer.ts +0 -4
  165. package/src/utils/fs-errno.ts +66 -0
  166. package/src/utils/fs-watch.ts +1 -1
  167. package/src/utils/internal-error.ts +3 -1
  168. package/src/utils/paths.ts +11 -3
  169. package/src/utils/task-name-generator.ts +1 -8
  170. package/src/workflows/discover-workflows.ts +19 -2
  171. package/src/workflows/validate-workflow.ts +7 -1
  172. package/src/workflows/workflow-config.ts +10 -0
  173. package/src/workflows/workflow-serializer.ts +3 -0
  174. package/src/worktree/worktree-manager.ts +22 -0
  175. package/src/agents/agent-search.ts +0 -98
  176. package/src/benchmark/benchmark-runner.ts +0 -313
  177. package/src/benchmark/feedback-loop.ts +0 -73
  178. package/src/config/resilient-parser.ts +0 -117
  179. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  180. package/src/extension/result-watcher.ts +0 -139
  181. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  182. package/src/observability/metric-retention.ts +0 -64
  183. package/src/runtime/compaction/compaction-summary.ts +0 -278
  184. package/src/runtime/errors/crew-errors.ts +0 -162
  185. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  186. package/src/runtime/loop-gates.ts +0 -128
  187. package/src/runtime/metric-parser.ts +0 -36
  188. package/src/runtime/output/stream-preview.ts +0 -184
  189. package/src/runtime/output/tool-progress.ts +0 -278
  190. package/src/runtime/phase-tracker.ts +0 -385
  191. package/src/runtime/pipeline-runner.ts +0 -523
  192. package/src/runtime/process/process-lifecycle.ts +0 -491
  193. package/src/runtime/recovery/retry-runner.ts +0 -330
  194. package/src/runtime/run-drift.ts +0 -219
  195. package/src/runtime/scratchpad/snapshot-hmac.ts +0 -167
  196. package/src/runtime/task-quality.ts +0 -199
  197. package/src/runtime/task-runner/run-projection.ts +0 -128
  198. package/src/runtime/verification/post-checks.ts +0 -142
  199. package/src/state/coordination/schedule.ts +0 -166
  200. package/src/state/event-log/jsonl-writer.ts +0 -115
  201. package/src/state/hook-instinct-bridge.ts +0 -94
  202. package/src/state/hook-integrations.ts +0 -51
  203. package/src/state/session-state-map.ts +0 -51
  204. package/src/state/stores/blob-store.ts +0 -308
  205. package/src/state/stores/instinct-store.ts +0 -275
  206. package/src/state/stores/observation-store.ts +0 -176
  207. package/src/state/tiered-eval.ts +0 -480
  208. package/src/state/types-eval.ts +0 -58
  209. package/src/tools/safe-bash-extension.ts +0 -54
  210. package/src/tools/safe-bash.ts +0 -505
  211. package/src/ui/agent-management-overlay.ts +0 -160
  212. package/src/ui/crew-footer.ts +0 -102
  213. package/src/ui/crew-select-list.ts +0 -114
  214. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  215. package/src/ui/transcript-entries.ts +0 -256
  216. package/src/utils/conflict-detect.ts +0 -721
  217. package/src/utils/fingerprint.ts +0 -180
  218. package/src/utils/gh-protocol.ts +0 -556
  219. package/src/utils/project-detector.ts +0 -160
  220. package/src/utils/sse-parser.ts +0 -131
  221. package/src/workflows/cost-estimator.ts +0 -34
  222. package/src/workflows/intermediate-store.ts +0 -166
@@ -19,13 +19,15 @@ import * as path from "node:path";
19
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
20
  import { loadConfig } from "../../config/config.ts";
21
21
  import { DEFAULT_UI } from "../../config/defaults.ts";
22
+ import { getCrewEnv } from "../../config/env-vars.ts";
22
23
  import { pruneFinishedRuns, pruneUserLevelRuns } from "../../extension/run-maintenance.ts";
23
24
  import { type BrokerSpawnCredentials, setActiveBrokerIssuer } from "../../runtime/broker/broker-issuer.ts";
24
25
  import { CrewBroker } from "../../runtime/broker/crew-broker.ts";
25
26
  import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
26
27
  import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
27
28
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
28
- import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs, currentCrewDepth } from "../../runtime/model/pi-args.ts";
29
+ import { configuredModelInfosFromPiConfig } from "../../runtime/model/model-fallback.ts";
30
+ import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs, currentCrewDepth, resolveCrewMaxDepth } from "../../runtime/model/pi-args.ts";
29
31
  import { clearProviderQuotaCache, noteProviderResponse } from "../../runtime/model/provider-quota.ts";
30
32
  import { noteSessionModel, noteSessionThinking, resolveProviderForResponse } from "../../runtime/model/session-model.ts";
31
33
  import { cleanupOrphanWorkers } from "../../runtime/orphan-worker-registry.ts";
@@ -33,7 +35,7 @@ import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts"
33
35
  import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
34
36
  import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
35
37
  import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
36
- import { applyCrewSettingsToConfig, loadCrewSettings } from "../../runtime/settings-store.ts";
38
+ import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers } from "../../runtime/settings-store.ts";
37
39
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
38
40
  import type { TeamRunManifest } from "../../state/types.ts";
39
41
  import { summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
@@ -215,8 +217,18 @@ function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext):
215
217
  }, 0);
216
218
 
217
219
  const loadedConfig = loadConfig(extensionCtx.cwd);
218
- const crewSettings = loadCrewSettings(extensionCtx.cwd);
219
- applyCrewSettingsToConfig(loadedConfig.config, crewSettings);
220
+ // Wave 2B (P1 security): crew settings load as TIERS — the project-tier
221
+ // <cwd>/.pi/crew-settings.json is untrusted (a cloned repo can ship it)
222
+ // and now goes through sanitizeProjectConfig + tighten-only guard
223
+ // tiering instead of being applied raw over the sanitized config (the
224
+ // pre-2B `loadCrewSettings` + `applyCrewSettingsToConfig` bypass).
225
+ // See src/runtime/settings-store.ts for the tier pipeline + the
226
+ // scheduledJobs/schedulingEnabled boundary decision.
227
+ const crewSettingsTiers = loadCrewSettingsTiers(extensionCtx.cwd);
228
+ const settingsWarnings = applyCrewSettingsTiersToConfig(loadedConfig.config, crewSettingsTiers);
229
+ if (settingsWarnings.length > 0) {
230
+ (loadedConfig.warnings ??= []).push(...settingsWarnings);
231
+ }
220
232
 
221
233
  // Start scheduler with event-based executor
222
234
  const sessionId =
@@ -229,14 +241,20 @@ function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext):
229
241
  // Wire scheduler into handle-schedule.ts so handlers can add/list jobs.
230
242
  // EXT-9: module-scoped setter (was globalThis[Symbol.for(...)]).
231
243
  registerCrewScheduler(ctx.crewScheduler);
232
- // Load scheduled jobs from settings if present
233
- if (Array.isArray(crewSettings.scheduledJobs)) {
234
- for (const job of crewSettings.scheduledJobs) {
235
- try {
236
- ctx.crewScheduler.add(job as ScheduledJob);
237
- } catch {
238
- /* skip invalid */
239
- }
244
+ // Load scheduled jobs from settings if present.
245
+ // BOUNDARY (Wave B2): project-tier scheduledJobs are OPT-IN GATED — the
246
+ // registration loop reads the gated `effectiveScheduledJobs` view (user
247
+ // jobs always; project jobs ONLY when the user-tier global file has BOTH
248
+ // schedulingEnabled:true AND allowProjectScheduledJobs:true).
249
+ // <cwd>/.pi/crew-settings.json remains the persistence store for the
250
+ // user's own `crew schedule add/update/remove` commands (handle-schedule.ts),
251
+ // so crew-schedule users must set both flags in ~/.pi/crew-settings.json.
252
+ // `schedulingEnabled` is user-tier-only (project values always dropped).
253
+ for (const job of crewSettingsTiers.effectiveScheduledJobs) {
254
+ try {
255
+ ctx.crewScheduler.add(job as ScheduledJob);
256
+ } catch {
257
+ /* skip invalid */
240
258
  }
241
259
  }
242
260
  ctx.autoRecoveryLast.clear();
@@ -540,6 +558,44 @@ export function filterManifestsForHealthNotifications(
540
558
  return manifests.filter((run) => !run.ownerSessionId || run.ownerSessionId === currentSessionId);
541
559
  }
542
560
 
561
+ /**
562
+ * bug-026 sub-issue C: runEventBus event types that mark a run TERMINAL,
563
+ * across BOTH type namespaces seen on the bus — dotted `run.*` strings (kept
564
+ * for parity with classifyEventChannel's WORKER_LIFECYCLE_TYPES, see
565
+ * src/ui/run-event-bus.ts:31-44) and underscore `run_*` (the native
566
+ * RunEventType namespace actually emitted by team-runner, e.g.
567
+ * `run_completed`). Exported for unit testing.
568
+ */
569
+ export const TERMINAL_RUN_EVENT_TYPES: ReadonlySet<string> = new Set([
570
+ "run.completed",
571
+ "run.failed",
572
+ "run.cancelled",
573
+ "run_completed",
574
+ "run_failed",
575
+ "run_cancelled",
576
+ ]);
577
+
578
+ /** True when a runEventBus event type marks the run terminal (either namespace). */
579
+ export function isTerminalRunEventType(type: string): boolean {
580
+ return TERMINAL_RUN_EVENT_TYPES.has(type);
581
+ }
582
+
583
+ /** Pure filter: drop `runId` from a preloaded-manifest frame (bug-026 sub-issue C eviction). */
584
+ export function evictRunFromManifests(manifests: TeamRunManifest[], runId: string): TeamRunManifest[] {
585
+ return manifests.filter((m) => m.runId !== runId);
586
+ }
587
+
588
+ /**
589
+ * Apply a runEventBus payload to a preloaded-manifest frame: evict the run on
590
+ * terminal events, pass through unchanged otherwise. This is the exact logic
591
+ * wired into the setupRenderLoop `runEventBus.onAny` subscription (bug-026
592
+ * sub-issue C); exported so tests can exercise it end-to-end against the
593
+ * real bus without spinning up the full render loop.
594
+ */
595
+ export function applyTerminalRunEventToManifests(manifests: TeamRunManifest[], event: { type: string; runId: string }): TeamRunManifest[] {
596
+ return isTerminalRunEventType(event.type) ? evictRunFromManifests(manifests, event.runId) : manifests;
597
+ }
598
+
543
599
  function setupRenderLoop(
544
600
  pi: ExtensionAPI,
545
601
  ctx: RegistrationContext,
@@ -803,6 +859,12 @@ function setupRenderLoop(
803
859
  // re-renders within debounceMs of any agent lifecycle event.
804
860
  const sched = ctx.renderScheduler;
805
861
  const unsubscribeRunEvents = runEventBus.onAny((event) => {
862
+ // bug-026 sub-issue C: evict terminal runs from the preloaded manifest
863
+ // frame so a stale "running" entry cannot persist for the session
864
+ // lifetime. Additive to the renderTick GATE 1 / FIX #1 / GATE 2 snapshot
865
+ // purges (which invalidate the snapshot cache) — those gates do not
866
+ // touch lastPreloadedManifests itself.
867
+ lastPreloadedManifests = applyTerminalRunEventToManifests(lastPreloadedManifests, event);
806
868
  sched.schedule({
807
869
  runId: event.runId,
808
870
  source: "runEventBus",
@@ -905,7 +967,7 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
905
967
 
906
968
  function effectiveEnabled(): boolean {
907
969
  // Env wins over config. PI_CREW_BROKER=1 forces on, =0 forces off.
908
- const envOverride = process.env.PI_CREW_BROKER;
970
+ const envOverride = getCrewEnv("PI_CREW_BROKER");
909
971
  if (envOverride === "0") return false;
910
972
  // Config block: read fresh so a runtime config update takes effect.
911
973
  try {
@@ -934,7 +996,23 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
934
996
  starting = (async () => {
935
997
  const cfg = (() => {
936
998
  try {
937
- return loadConfig().config.broker;
999
+ // B1 battery 2026-08-18 (third config-layer bug): loadConfig()
1000
+ // WITHOUT cwd reads only the user config — the workspace
1001
+ // .crew/config.json (where broker.waitMethodsEnabled:true would
1002
+ // live) was never merged, so the flag stayed default-false even
1003
+ // after the parser + merge fixes. Pass the SAME cwd the broker
1004
+ // itself uses below.
1005
+ return loadConfig(process.cwd()).config.broker;
1006
+ } catch {
1007
+ return undefined;
1008
+ }
1009
+ })();
1010
+ // T3/R5 (ADR-5 §10): governed-nesting + limits config for the delegate
1011
+ // surface — same cwd discipline as the broker block above.
1012
+ const nestingCfg = (() => {
1013
+ try {
1014
+ const c = loadConfig(process.cwd()).config;
1015
+ return { nesting: c.nesting, limits: c.limits };
938
1016
  } catch {
939
1017
  return undefined;
940
1018
  }
@@ -944,6 +1022,32 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
944
1022
  socketPath: getBrokerSocketPath(sessionId),
945
1023
  maxFrameBytes: cfg?.maxFrameBytes ?? 262144,
946
1024
  outboundQueueCap: cfg?.outboundQueueCap ?? 256,
1025
+ // WP-2 review round 1 (P1): thread the capability gate into the
1026
+ // PRODUCTION broker — the constructor default (false) made
1027
+ // config.broker.waitMethodsEnabled a dead knob and the ADR-0
1028
+ // "then true" flip a silent no-op. Fail-closed when unset.
1029
+ waitMethodsEnabled: cfg?.waitMethodsEnabled ?? false,
1030
+ // T3/R5 (ADR-5 §10): governed-nesting capability gate — fail-closed
1031
+ // default; production threads config.nesting (sensitive: user config
1032
+ // only). Nested-slot sizing + admission-time model catalog (ADR-5 §7 —
1033
+ // the production wiring MUST supply it) + workspace gate mirror.
1034
+ nestingEnabled: nestingCfg?.nesting?.enabled ?? false,
1035
+ ...(nestingCfg?.nesting?.maxSlots !== undefined ? { nestingMaxSlots: nestingCfg.nesting.maxSlots } : {}),
1036
+ ...(nestingCfg?.nesting?.maxDepth !== undefined ? { nestingMaxDepth: nestingCfg.nesting.maxDepth } : {}),
1037
+ // ADR-5 §12: enabling the sensitive USER-config-only flag IS the manual
1038
+ // trust decision for the escalation surface.
1039
+ nestingTrustedEscalation: nestingCfg?.nesting?.enabled === true,
1040
+ ...(nestingCfg?.limits?.maxConcurrentWorkers !== undefined
1041
+ ? { globalWorkerSemaphore: nestingCfg.limits.maxConcurrentWorkers }
1042
+ : {}),
1043
+ serializeOnPathOverlap: nestingCfg?.limits?.serializeOnPathOverlap ?? false,
1044
+ modelCatalog: () => {
1045
+ try {
1046
+ return configuredModelInfosFromPiConfig(process.cwd()).map((info) => info.fullId);
1047
+ } catch {
1048
+ return undefined;
1049
+ }
1050
+ },
947
1051
  enabled: true,
948
1052
  cwd: process.cwd(),
949
1053
  });
@@ -960,10 +1064,17 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
960
1064
  return starting!;
961
1065
  }
962
1066
 
963
- const issueForChild = async (runId: string, taskId?: string): Promise<BrokerSpawnCredentials | undefined> => {
1067
+ const issueForChild = async (runId: string, taskId?: string, childDepth?: number): Promise<BrokerSpawnCredentials | undefined> => {
964
1068
  if (!runId || typeof runId !== "string") return undefined;
965
1069
  if (!isRootSession(process.env)) return undefined;
966
1070
  if (!effectiveEnabled()) return undefined;
1071
+ // ADR-5 §4 (governed nesting): tokens are minted ONLY for children that
1072
+ // may themselves delegate — childDepth < resolved maxDepth. At the default
1073
+ // maxDepth=2 a delegate-spawned depth-2 grandchild gets NO credentials
1074
+ // (env containment: no PI_CREW_BROKER_SOCKET/TOKEN at depth 2; identity
1075
+ // routing via PI_CREW_BROKER_RUN_ID/TASK_ID is threaded unconditionally
1076
+ // elsewhere). Undefined childDepth = legacy worker spawn (depth 1).
1077
+ if (childDepth !== undefined && childDepth >= resolveCrewMaxDepth(undefined)) return undefined;
967
1078
  const sessionId = cachedSessionId;
968
1079
  if (!sessionId) return undefined;
969
1080
  try {
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs";
1
2
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "@sinclair/typebox";
3
4
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
@@ -28,14 +29,18 @@ import type { BatchBarrier } from "../../runtime/scheduling/batch-barrier.ts";
28
29
  import {
29
30
  readPersistedSubagentRecord,
30
31
  type SubagentManager,
32
+ type SubagentRecord,
31
33
  type SubagentSpawnOptions,
32
34
  savePersistedSubagentRecord,
33
35
  } from "../../runtime/subagent-manager.ts";
36
+ import { TEAM_TERMINAL_TASK_STATUSES } from "../../state/contracts.ts";
37
+ import { resolveEntryBySubagentId, upsertOwnershipEntry } from "../../state/stores/ownership-map.ts";
34
38
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
35
39
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
36
40
  import { formatCompactToolProgress } from "../../ui/tool-progress-formatter.ts";
37
41
  import { agentToolRenderer, type ToolRenderContext } from "../../ui/tool-renderers/index.ts";
38
42
  import { logInternalError } from "../../utils/internal-error.ts";
43
+ import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
39
44
  import {
40
45
  __test__subagentSpawnParams,
41
46
  formatSubagentRecord,
@@ -140,8 +145,55 @@ export function registerSubagentTools(
140
145
  // by the Agent tool have proper session ownership for isolation.
141
146
  const ctxWithSession = withSessionId(ctx);
142
147
  spawnOptions.ownerSessionId = ctxWithSession.sessionId;
143
- const runner = async (currentOptions: SubagentSpawnOptions, childSignal?: AbortSignal) =>
144
- handleTeamTool(
148
+ // WP-1/R1 (H6): the one-shot identity link. taskId is NOT knowable at
149
+ // spawn() time — handleTeamTool(action:"run") resolves only after the
150
+ // run is dispatched (scaffold/mock returns immediately; real runs at
151
+ // completion), and runId comes from the result details. So the link is
152
+ // written in the runner wrapper once the manifest resolves: the record
153
+ // gains taskId/depth, the persisted copy is refreshed, and the
154
+ // ownership-map entry (task ⇄ subagentId ⇄ artifactsDir) is upserted
155
+ // under the run lock. Best-effort: a failure logs but never throws
156
+ // into the spawn path. `spawnedRecord` is assigned right after spawn()
157
+ // returns; the runner's continuation runs on a later microtask, so the
158
+ // assignment is always visible when the identity-link block executes.
159
+ let spawnedRecord: SubagentRecord | undefined;
160
+ const runner = async (currentOptions: SubagentSpawnOptions, childSignal?: AbortSignal) => {
161
+ // WP-1/R1 (H6 fix, review round): mid-run steer requires the identity
162
+ // link AT DISPATCH TIME, not after completion. `handleTeamTool` awaits
163
+ // the full run; the only mid-run signal we have is `onRunStarted`
164
+ // (fired in run.ts with updatedManifest.runId as the run starts). Hook
165
+ // it so the subagentId leg of the ownership map lands while the worker
166
+ // is live — steer_subagent can then resolve taskId mid-run instead of
167
+ // saying "not linked". The completion-time block below remains as a
168
+ // fallback for runs that complete between dispatch and this hook.
169
+ const linkAtDispatch = (runId?: string): void => {
170
+ if (typeof runId !== "string" || !spawnedRecord || spawnedRecord.taskId) return;
171
+ try {
172
+ const loaded = loadRunManifestById(currentOptions.cwd, runId);
173
+ const taskId = loaded?.tasks[0]?.id;
174
+ if (taskId && loaded) {
175
+ spawnedRecord.taskId = taskId;
176
+ // runId too: the manager only back-fills record.runId from the
177
+ // awaited result (waitForRun — i.e. at COMPLETION for background
178
+ // runs). Linking it here keeps taskId+runId landing together at
179
+ // dispatch so mid-run consumers (steer) resolve both immediately.
180
+ spawnedRecord.runId = runId;
181
+ spawnedRecord.depth = currentOptions.depth ?? 0;
182
+ savePersistedSubagentRecord(currentOptions.cwd, spawnedRecord);
183
+ upsertOwnershipEntry(loaded.manifest, {
184
+ taskId,
185
+ runId,
186
+ subagentId: spawnedRecord.id,
187
+ artifactsDir: loaded.manifest.artifactsRoot,
188
+ depth: spawnedRecord.depth,
189
+ updatedAt: new Date().toISOString(),
190
+ });
191
+ }
192
+ } catch (err) {
193
+ logInternalError("subagent-tools.identity-dispatch", err, `runId=${runId ?? "(unknown)"}`);
194
+ }
195
+ };
196
+ const result = await handleTeamTool(
145
197
  {
146
198
  action: "run",
147
199
  agent: currentOptions.type,
@@ -153,6 +205,7 @@ export function registerSubagentTools(
153
205
  } as TeamToolParamsValue,
154
206
  {
155
207
  ...ctxWithSession,
208
+ onRunStarted: (runId: string) => linkAtDispatch(runId), // mid-run capture (FIX)
156
209
  signal: childSignal,
157
210
  ...(options.startForegroundRun
158
211
  ? {
@@ -162,7 +215,64 @@ export function registerSubagentTools(
162
215
  : {}),
163
216
  },
164
217
  );
218
+ // WP-1/R1 (H6): identity link — record taskId/depth + ownership entry
219
+ // once the run manifest resolves. Direct-agent one-shot runs have
220
+ // exactly one task (direct-agent workflow); resolve from the manifest
221
+ // rather than hardcoding the id. The ownership upsert is verified and
222
+ // retried: a concurrently-completing run holds the run lock during its
223
+ // terminal writes, so a single upsert can lose the lock race
224
+ // (acquireLockWithRetry throws on a fresh foreign lock) and the
225
+ // store swallows that best-effort. taskId is already on the record at
226
+ // that point, so steer still resolves — but the map's subagentId leg
227
+ // would be missing for widget/status attribution. Ride out the
228
+ // contention window, then verify; still never throw.
229
+ let runId: string | undefined;
230
+ try {
231
+ const detailsRunId = (result.details as { runId?: unknown } | undefined)?.runId;
232
+ runId = typeof detailsRunId === "string" ? detailsRunId : undefined;
233
+ if (typeof runId === "string" && spawnedRecord) {
234
+ const loaded = loadRunManifestById(currentOptions.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
235
+ const taskId = loaded?.tasks[0]?.id;
236
+ // P3 hardening (T1 review round 1): base the completion-time retry on
237
+ // the MAP LEG, not on record.taskId. A dispatch-time upsert
238
+ // (linkAtDispatch) that lost the lock race would leave the map's
239
+ // subagentId leg missing; the old `!spawnedRecord.taskId` guard then
240
+ // skipped the verify+retry entirely. Now: write record fields once
241
+ // (idempotent), then verify+retry the MAP until the subagentId leg
242
+ // is present or budget exhausted. Upsert is merge-idempotent, so
243
+ // double-writing is safe.
244
+ if (taskId && loaded) {
245
+ if (!spawnedRecord.taskId) {
246
+ spawnedRecord.taskId = taskId;
247
+ spawnedRecord.depth = currentOptions.depth ?? 0;
248
+ savePersistedSubagentRecord(currentOptions.cwd, spawnedRecord);
249
+ }
250
+ const entry = {
251
+ taskId,
252
+ runId,
253
+ subagentId: spawnedRecord.id,
254
+ artifactsDir: loaded.manifest.artifactsRoot,
255
+ depth: spawnedRecord.depth,
256
+ updatedAt: new Date().toISOString(),
257
+ };
258
+ for (let attempt = 0; attempt < 5; attempt++) {
259
+ try {
260
+ upsertOwnershipEntry(loaded.manifest, entry);
261
+ } catch (err) {
262
+ logInternalError("subagent-tools.identity-link", err, `taskId=${taskId}, attempt=${attempt}`);
263
+ }
264
+ if (resolveEntryBySubagentId(loaded.manifest, spawnedRecord.id)?.taskId === taskId) break;
265
+ if (attempt < 4) await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
266
+ }
267
+ }
268
+ }
269
+ } catch (err) {
270
+ logInternalError("subagent-tools.identity-link", err, `runId=${runId ?? "(unknown)"}`);
271
+ }
272
+ return result;
273
+ };
165
274
  const record = subagentManager.spawn(spawnOptions, runner, spawnOptions.background ? undefined : signal);
275
+ spawnedRecord = record;
166
276
  // Rule 1: register batch membership so completions can be coalesced.
167
277
  if (spawnOptions.batchId && spawnOptions.background) {
168
278
  options.batchBarrier?.register(spawnOptions.batchId, record.id, { description: record.description, type: record.type });
@@ -365,28 +475,127 @@ export function registerSubagentTools(
365
475
  name: "steer_subagent",
366
476
  label: "Steer Agent",
367
477
  description:
368
- "Send a steering note to a running pi-crew subagent. Live-session steering is planned; child-process runs expose durable status and can be cancelled if needed.",
478
+ "Send a steering note to a running pi-crew subagent. Resolves the subagent record → ownership map → appends artifacts/steering/<taskId>.jsonl, which the child worker polls at its next turn boundary. Use team action=steer (runId+taskId+message) for run-level steering, or team cancel for interruption.",
369
479
  parameters: Type.Object({
370
480
  agent_id: Type.String(),
371
481
  message: Type.String(),
372
482
  }) as never,
373
483
  async execute(_id, params, _signal, _onUpdate, ctx) {
374
484
  const p = params as { agent_id?: string; message?: string };
485
+ if (!p.message?.trim()) return subagentToolResult(t("agent.requiresPrompt"), {}, true);
375
486
  const record = p.agent_id
376
487
  ? (subagentManager.getRecord(p.agent_id) ?? readPersistedSubagentRecord(ctx.cwd, p.agent_id))
377
488
  : undefined;
378
489
  if (!record) return subagentToolResult(t("result.notFound", { id: p.agent_id ?? "" }), {}, true);
490
+ // P2.3: Cross-session ownership check — refuse to steer a record owned by
491
+ // a different session. Legacy records (no ownerSessionId) still pass.
492
+ const currentSessionId = withSessionId(ctx).sessionId;
493
+ if (record.ownerSessionId && record.ownerSessionId !== currentSessionId) {
494
+ return subagentToolResult("Agent belongs to another session.", {}, true);
495
+ }
496
+ // Resolve the live run's taskId + artifactsRoot: prefer the record's
497
+ // taskId link (set by the Agent-tool spawn route), falling back to the
498
+ // per-run ownership map (resolve by subagentId) when the record predates
499
+ // the link.
500
+ let taskId = record.taskId;
501
+ let artifactsRoot: string | undefined;
502
+ let loaded: ReturnType<typeof loadRunManifestById> | undefined;
503
+ if (record.runId) {
504
+ loaded = loadRunManifestById(ctx.cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
505
+ if (loaded) {
506
+ artifactsRoot = loaded.manifest.artifactsRoot;
507
+ taskId ??= resolveEntryBySubagentId(loaded.manifest, record.id)?.taskId;
508
+ }
509
+ }
510
+ // Back-compat: no taskId/link (legacy record or run never dispatched) →
511
+ // the existing "not linked" structured message, NO throw, NO file write.
512
+ if (!taskId || !artifactsRoot) {
513
+ return subagentToolResult(
514
+ [t("steer.unavailable"), record.runId ? t("steer.cancelHint", { runId: record.runId }) : undefined]
515
+ .filter((line): line is string => Boolean(line))
516
+ .join("\n"),
517
+ {
518
+ agentId: record.id,
519
+ runId: record.runId,
520
+ status: record.status,
521
+ },
522
+ );
523
+ }
524
+ // Real steer — append to artifacts/steering/<taskId>.jsonl, same writer util
525
+ // + JSONL schema as `team steer` (handleSteer, team-tool.ts):
526
+ // {type:"steer", message, ts}. The child worker polls this file every
527
+ // 500ms and picks the message up at its next turn boundary.
528
+ // Security review (T1/WP-1 round 1, security 1-3): mirror handleSteer's
529
+ // hardening — (a) T-S1 terminal-task refusal + taskId-belongs-to-manifest
530
+ // validation; (b) cap steering-file growth (unbounded files force a
531
+ // Buffer.alloc(fileSize) on the next worker's first poll and re-deliver
532
+ // every line to each incarnation — see P2 replay finding); (c) no throw.
533
+ if (!loaded) return subagentToolResult("Run manifest unavailable for steer.", {}, true);
534
+ const manifestTask = loaded.tasks.find((t) => t.id === taskId);
535
+ if (!manifestTask) {
536
+ return subagentToolResult(
537
+ `Task '${taskId}' not found in the owning run; cannot steer.`,
538
+ { agentId: record.id, runId: record.runId, taskId, status: record.status },
539
+ true,
540
+ );
541
+ }
542
+ if (TEAM_TERMINAL_TASK_STATUSES.has(manifestTask.status)) {
543
+ return subagentToolResult(
544
+ `Task '${taskId}' is ${manifestTask.status}; cannot steer.`,
545
+ { agentId: record.id, runId: record.runId, taskId, status: record.status },
546
+ true,
547
+ );
548
+ }
549
+ try {
550
+ const steeringDir = `${artifactsRoot}/steering`;
551
+ fs.mkdirSync(steeringDir, { recursive: true });
552
+ const safeSteeringPath = resolveRealContainedPath(steeringDir, `${taskId}.jsonl`);
553
+ // Security 3: cap file growth — refuse when the file exceeds a byte
554
+ // threshold (drop-oldest semantics are not feasible for a plain JSONL
555
+ // append; refuse loudly instead of poisoning the next incarnation).
556
+ const MAX_STEERING_BYTES = 256 * 1024;
557
+ let existingBytes = 0;
558
+ try {
559
+ existingBytes = fs.statSync(safeSteeringPath).size;
560
+ } catch (error) {
561
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
562
+ }
563
+ const line = JSON.stringify({ type: "steer", message: p.message, ts: new Date().toISOString() }) + "\n";
564
+ if (existingBytes + Buffer.byteLength(line) > MAX_STEERING_BYTES) {
565
+ return subagentToolResult(
566
+ `Steering file for task '${taskId}' has reached its size cap (${(MAX_STEERING_BYTES / 1024).toFixed(0)}KiB); steer refused.`,
567
+ { agentId: record.id, runId: record.runId, taskId, status: record.status },
568
+ true,
569
+ );
570
+ }
571
+ fs.appendFileSync(safeSteeringPath, line);
572
+ } catch (err) {
573
+ // B1 battery 2026-08-18 (F7): this catch previously logged at DEBUG
574
+ // level (invisible unless PI_TEAMS_DEBUG) and then STILL returned the
575
+ // success message — a live probe showed "Steer delivered to task …"
576
+ // while the steering file stayed 0 bytes, undiagnosable from the
577
+ // outside. Fail LOUD: error-level log + isError result.
578
+ logInternalError(
579
+ "subagent-tools.steer-write-failed",
580
+ err instanceof Error ? err : new Error(String(err)),
581
+ `taskId=${taskId} runId=${record.runId} artifactsRoot=${artifactsRoot}`,
582
+ "error",
583
+ );
584
+ return subagentToolResult(
585
+ `Steer write failed for task '${taskId}': ${err instanceof Error ? err.message : String(err)} (see pi-crew error log)`,
586
+ { agentId: record.id, runId: record.runId, taskId, status: record.status },
587
+ true,
588
+ );
589
+ }
379
590
  return subagentToolResult(
380
591
  [
381
592
  t("steer.noted", { id: record.id }),
382
- t("steer.unavailable"),
383
- record.runId ? t("steer.cancelHint", { runId: record.runId }) : undefined,
384
- ]
385
- .filter((line): line is string => Boolean(line))
386
- .join("\n"),
593
+ `Steer delivered to task '${taskId}'; it will be picked up at the next turn boundary.`,
594
+ ].join("\n"),
387
595
  {
388
596
  agentId: record.id,
389
597
  runId: record.runId,
598
+ taskId,
390
599
  status: record.status,
391
600
  },
392
601
  );
@@ -410,7 +619,7 @@ export function registerSubagentTools(
410
619
  ...steerSubagentTool,
411
620
  name: "crew_agent_steer",
412
621
  label: "Steer Crew Agent",
413
- description: "Send a steering note to a pi-crew subagent using the conflict-safe tool name.",
622
+ description: "Send a steering note to a pi-crew subagent using the conflict-safe tool name (same behavior as steer_subagent).",
414
623
  };
415
624
  const toolConfig = loadConfig(process.cwd()).config.tools;
416
625
  const enableSteer = toolConfig?.enableSteer !== false;
@@ -1,4 +1,5 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { getCrewEnv } from "../config/env-vars.ts";
2
3
 
3
4
  /**
4
5
  * HMAC-based origin signing for cross-extension RPC.
@@ -23,7 +24,7 @@ const SIGNATURE_VALIDITY_MS = 10 * 60 * 1000; // 10 min
23
24
 
24
25
  /** Get the shared secret. Returns undefined if not configured. */
25
26
  export function getRpcSecret(): string | undefined {
26
- return process.env[SECRET_ENV_VAR];
27
+ return getCrewEnv(SECRET_ENV_VAR);
27
28
  }
28
29
 
29
30
  /** Set the shared secret (for testing or programmatic setup). */
@@ -38,7 +39,8 @@ export function clearRpcSecret(): void {
38
39
 
39
40
  /** Whether HMAC authentication is currently enabled. */
40
41
  export function isHmacEnabled(): boolean {
41
- return typeof process.env[SECRET_ENV_VAR] === "string" && process.env[SECRET_ENV_VAR].length > 0;
42
+ const secret = getCrewEnv(SECRET_ENV_VAR);
43
+ return typeof secret === "string" && secret.length > 0;
42
44
  }
43
45
 
44
46
  // --- Types -------------------------------------------------------------------
@@ -142,7 +144,7 @@ export function withHmacVerification<P extends { requestId: string }>(
142
144
  // Emit a soft info note (once per process) only when an actual RPC
143
145
  // request is being processed — the prior registration-time warning
144
146
  // was too noisy for users who never wire cross-extension RPC.
145
- if (process.env.PI_CREW_SUPPRESS_RPC_WARNING !== "1") {
147
+ if (getCrewEnv("PI_CREW_SUPPRESS_RPC_WARNING") !== "1") {
146
148
  rpcHmacSoftInfoOnce();
147
149
  }
148
150
  return handler(params);
@@ -6,9 +6,11 @@
6
6
  */
7
7
 
8
8
  import { touchWorkerHeartbeat } from "../../../runtime/heartbeat/worker-heartbeat.ts";
9
+ import { isTerminalTaskStatus } from "../../../state/contracts.ts";
9
10
  import { withRunLockSync } from "../../../state/coordination/locks.ts";
10
11
  import { appendEvent } from "../../../state/event-log/event-log.ts";
11
- import { saveRunTasks } from "../../../state/stores/state-store.ts";
12
+ import { loadRunManifestById, saveRunTasks } from "../../../state/stores/state-store.ts";
13
+ import { RUN_NOT_FOUND_HINT } from "../run-not-found.ts";
12
14
  import type { ApiOperationHandler } from "./handler-context.ts";
13
15
 
14
16
  export const handleWriteHeartbeat: ApiOperationHandler = (hctx) => {
@@ -31,28 +33,63 @@ export const handleWriteHeartbeat: ApiOperationHandler = (hctx) => {
31
33
  );
32
34
  try {
33
35
  return withRunLockSync(loaded.manifest, () => {
36
+ // R13-S1: fresh re-read INSIDE the lock (respond.ts:43 canonical pattern).
37
+ const fresh = loadRunManifestById(loaded.manifest.cwd, loaded.manifest.runId);
38
+ if (!fresh)
39
+ return result(
40
+ `Run '${loaded.manifest.runId}' not found.${RUN_NOT_FOUND_HINT}`,
41
+ {
42
+ action: "api",
43
+ status: "error",
44
+ runId: loaded.manifest.runId,
45
+ },
46
+ true,
47
+ );
48
+ const freshTask = fresh.tasks.find((item) => item.id === taskId || item.stepId === taskId);
49
+ if (!freshTask)
50
+ return result(
51
+ `Task '${taskId}' not found in run '${fresh.manifest.runId}'.`,
52
+ {
53
+ action: "api",
54
+ status: "error",
55
+ runId: fresh.manifest.runId,
56
+ },
57
+ true,
58
+ );
59
+ // R13-S1: never resurrect a task that became terminal on disk — a completed/
60
+ // failed task must not be flipped back to running by a stale heartbeat.
61
+ if (isTerminalTaskStatus(freshTask.status))
62
+ return result(
63
+ `Cannot write heartbeat for task '${freshTask.id}': task is in terminal state '${freshTask.status}'.`,
64
+ {
65
+ action: "api",
66
+ status: "error",
67
+ runId: fresh.manifest.runId,
68
+ },
69
+ true,
70
+ );
34
71
  const heartbeat = touchWorkerHeartbeat(
35
- task.heartbeat ?? {
36
- workerId: task.id,
72
+ freshTask.heartbeat ?? {
73
+ workerId: freshTask.id,
37
74
  lastSeenAt: new Date().toISOString(),
38
75
  },
39
76
  {
40
77
  alive: typeof cfg.alive === "boolean" ? cfg.alive : undefined,
41
78
  },
42
79
  );
43
- const tasks = loaded.tasks.map((item) => (item.id === task.id ? { ...item, heartbeat } : item));
44
- saveRunTasks(loaded.manifest, tasks);
45
- appendEvent(loaded.manifest.eventsPath, {
80
+ const tasks = fresh.tasks.map((item) => (item.id === freshTask.id ? { ...item, heartbeat } : item));
81
+ saveRunTasks(fresh.manifest, tasks);
82
+ appendEvent(fresh.manifest.eventsPath, {
46
83
  type: "worker.heartbeat",
47
- runId: loaded.manifest.runId,
48
- taskId: task.id,
84
+ runId: fresh.manifest.runId,
85
+ taskId: freshTask.id,
49
86
  data: { ...heartbeat },
50
87
  });
51
88
  return result(JSON.stringify(heartbeat, null, 2), {
52
89
  action: "api",
53
90
  status: "ok",
54
- runId: loaded.manifest.runId,
55
- artifactsRoot: loaded.manifest.artifactsRoot,
91
+ runId: fresh.manifest.runId,
92
+ artifactsRoot: fresh.manifest.artifactsRoot,
56
93
  });
57
94
  });
58
95
  } catch (error) {
@@ -9,6 +9,7 @@ import { terminateLiveAgentsForRun } from "../../../runtime/live-session/live-ag
9
9
  import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
10
10
  import { withRunLock } from "../../../state/coordination/locks.ts";
11
11
  import { appendEvent } from "../../../state/event-log/event-log.ts";
12
+ import { getCurrentPlanRecord, setPlanApproval } from "../../../state/stores/plan-store.ts";
12
13
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatus } from "../../../state/stores/state-store.ts";
13
14
  import { logInternalError } from "../../../utils/internal-error.ts";
14
15
  import type { ApiOperationHandler } from "./handler-context.ts";
@@ -63,6 +64,10 @@ export const handleApprovePlan: ApiOperationHandler = async (hctx) => {
63
64
  },
64
65
  };
65
66
  await saveRunManifestAsync(manifest);
67
+ // T2/R4 (ADR-4 §8): dual-write the record side — approval names the
68
+ // plan id+version. Pre-v2 runs without a PlanRecord skip silently.
69
+ const currentRecord = getCurrentPlanRecord(manifest);
70
+ if (currentRecord) setPlanApproval(manifest, { status: "approved", planVersion: currentRecord.version, by: "api" });
66
71
  appendEvent(manifest.eventsPath, {
67
72
  type: "plan.approved",
68
73
  runId: manifest.runId,
@@ -140,6 +145,10 @@ export const handleCancelPlan: ApiOperationHandler = async (hctx) => {
140
145
  },
141
146
  };
142
147
  await saveRunManifestAsync(manifest);
148
+ // T2/R4 (ADR-4 §8 vocabulary mapping): manifest gets "cancelled", the
149
+ // record side gets "rejected" (isPlanApprovalDenied accepts both).
150
+ const denyRecord = getCurrentPlanRecord(manifest);
151
+ if (denyRecord) setPlanApproval(manifest, { status: "rejected", planVersion: denyRecord.version, by: "api" });
143
152
  saveRunTasks(manifest, tasks);
144
153
  appendEvent(manifest.eventsPath, {
145
154
  type: "plan.cancelled",