pi-crew 0.9.68 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/analyst.md +1 -1
  5. package/agents/cold-verifier.md +3 -1
  6. package/agents/critic.md +1 -1
  7. package/agents/executor.md +1 -1
  8. package/agents/explorer.md +1 -1
  9. package/agents/planner.md +1 -1
  10. package/agents/reviewer.md +1 -1
  11. package/agents/security-reviewer.md +1 -1
  12. package/agents/test-engineer.md +1 -1
  13. package/agents/verifier.md +1 -1
  14. package/agents/writer.md +1 -1
  15. package/dist/index.mjs +68113 -60774
  16. package/docs/README.md +2 -0
  17. package/docs/actions-reference.md +31 -0
  18. package/docs/commands-reference.md +17 -6
  19. package/docs/resource-formats.md +13 -0
  20. package/package.json +4 -2
  21. package/schema.json +503 -91
  22. package/scripts/resource-sampler.mjs +36 -2
  23. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  24. package/skills/widget-rendering/SKILL.md +7 -7
  25. package/src/agents/agent-config.ts +2 -1
  26. package/src/agents/discover-agents.ts +23 -14
  27. package/src/config/config-merge.ts +183 -0
  28. package/src/config/config-validation.ts +687 -0
  29. package/src/config/config.ts +22 -864
  30. package/src/config/defaults.ts +43 -2
  31. package/src/config/drift-detector.ts +1 -1
  32. package/src/config/env-vars.ts +691 -0
  33. package/src/config/role-tools.ts +11 -9
  34. package/src/config/sanitize-project-config.ts +172 -0
  35. package/src/config/types.ts +49 -1
  36. package/src/extension/async-notifier.ts +25 -2
  37. package/src/extension/crew-cleanup.ts +13 -0
  38. package/src/extension/crew-vibes/config.ts +2 -1
  39. package/src/extension/crew-vibes/footer.ts +19 -0
  40. package/src/extension/crew-vibes/index.ts +11 -1
  41. package/src/extension/plan-orchestrate.ts +132 -0
  42. package/src/extension/register.ts +8 -0
  43. package/src/extension/registration/command-registration.ts +1 -0
  44. package/src/extension/registration/commands/dashboard.ts +158 -0
  45. package/src/extension/registration/commands/index.ts +35 -0
  46. package/src/extension/registration/commands/manage.ts +303 -0
  47. package/src/extension/registration/commands/run.ts +228 -0
  48. package/src/extension/registration/commands/shared.ts +639 -0
  49. package/src/extension/registration/commands/status.ts +60 -0
  50. package/src/extension/registration/commands.ts +13 -1224
  51. package/src/extension/registration/foreground-run-controller.ts +10 -2
  52. package/src/extension/registration/lifecycle-handlers.ts +178 -17
  53. package/src/extension/registration/runtime-cleanup.ts +23 -5
  54. package/src/extension/registration/subagent-tools.ts +218 -9
  55. package/src/extension/registration/team-tool.ts +5 -1
  56. package/src/extension/registration/ui.ts +5 -4
  57. package/src/extension/rpc-hmac.ts +5 -3
  58. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  59. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  60. package/src/extension/team-tool/api/task-claims.ts +109 -40
  61. package/src/extension/team-tool/cancel.ts +84 -50
  62. package/src/extension/team-tool/dispatch/index.ts +1 -0
  63. package/src/extension/team-tool/dispatch/run.ts +4 -1
  64. package/src/extension/team-tool/doctor.ts +103 -1
  65. package/src/extension/team-tool/orchestrate.ts +66 -1
  66. package/src/extension/team-tool/plans.ts +192 -0
  67. package/src/extension/team-tool/respond.ts +197 -65
  68. package/src/extension/team-tool/run-deadline.ts +35 -3
  69. package/src/extension/team-tool/run-intent.ts +63 -0
  70. package/src/extension/team-tool/run.ts +74 -20
  71. package/src/extension/team-tool/status.ts +84 -26
  72. package/src/extension/team-tool.ts +11 -2
  73. package/src/hooks/registry.ts +1 -6
  74. package/src/i18n.ts +9 -0
  75. package/src/prompt/prompt-runtime.ts +521 -2
  76. package/src/prompt/worker-events-channel.ts +173 -0
  77. package/src/runtime/README.md +8 -8
  78. package/src/runtime/async-runner.ts +7 -3
  79. package/src/runtime/background-runner.ts +42 -14
  80. package/src/runtime/broker/broker-issuer.ts +9 -2
  81. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  82. package/src/runtime/broker/crew-broker.ts +838 -10
  83. package/src/runtime/broker/wait-status-cache.ts +157 -0
  84. package/src/runtime/budget-enforcement.ts +281 -0
  85. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  86. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  87. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  88. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  89. package/src/runtime/child-pi/child-pi.ts +97 -201
  90. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  91. package/src/runtime/crew-agent-records.ts +259 -14
  92. package/src/runtime/delegate-spawn.ts +148 -0
  93. package/src/runtime/detached-run-results.ts +90 -0
  94. package/src/runtime/deterministic-ast.ts +2 -1
  95. package/src/runtime/dispatch-batch.ts +945 -0
  96. package/src/runtime/finalize-run.ts +557 -0
  97. package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
  98. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
  99. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  100. package/src/runtime/group-join.ts +11 -125
  101. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  102. package/src/runtime/merge-gate.ts +32 -10
  103. package/src/runtime/merge-loop.ts +130 -0
  104. package/src/runtime/model/model-budget-summary.ts +53 -0
  105. package/src/runtime/model/model-fallback.ts +36 -2
  106. package/src/runtime/model/pi-args.ts +10 -0
  107. package/src/runtime/model/provider-extensions.ts +10 -0
  108. package/src/runtime/orphan-worker-registry.ts +1 -1
  109. package/src/runtime/output/output-validator.ts +45 -0
  110. package/src/runtime/parent-guard.ts +3 -1
  111. package/src/runtime/peer-dep.ts +2 -1
  112. package/src/runtime/per-write-validator.ts +0 -5
  113. package/src/runtime/pi-spawn.ts +61 -15
  114. package/src/runtime/plan-approval.ts +125 -0
  115. package/src/runtime/plan-replan.ts +151 -0
  116. package/src/runtime/process-status.ts +16 -1
  117. package/src/runtime/recovery/checkpoint.ts +0 -18
  118. package/src/runtime/recovery/crash-recovery.ts +111 -46
  119. package/src/runtime/run-tracker.ts +77 -10
  120. package/src/runtime/scheduler-context.ts +98 -0
  121. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  122. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  123. package/src/runtime/scheduling/nested-slots.ts +70 -0
  124. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  125. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  126. package/src/runtime/settings-store.ts +219 -0
  127. package/src/runtime/spawn-policy.ts +217 -0
  128. package/src/runtime/stale-reconciler.ts +87 -6
  129. package/src/runtime/subagent-manager.ts +25 -1
  130. package/src/runtime/task-output-context.ts +230 -9
  131. package/src/runtime/task-packet.ts +23 -1
  132. package/src/runtime/task-runner/child-executor.ts +106 -7
  133. package/src/runtime/task-runner/post-execution.ts +125 -1
  134. package/src/runtime/task-runner/pre-execution.ts +39 -1
  135. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  136. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  137. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  138. package/src/runtime/task-runner/state-helpers.ts +26 -24
  139. package/src/runtime/task-runner.ts +11 -0
  140. package/src/runtime/team-runner.ts +132 -1673
  141. package/src/runtime/verification/spec-sandbox.ts +255 -0
  142. package/src/runtime/verification/verification-gates.ts +3 -2
  143. package/src/runtime/verification/verification-worktree.ts +2 -1
  144. package/src/runtime/workflow-phase-advance.ts +100 -0
  145. package/src/runtime/workspace-tree.ts +9 -0
  146. package/src/schema/config-schema.ts +66 -26
  147. package/src/schema/sensitive-config-paths.ts +64 -0
  148. package/src/schema/team-tool-schema.ts +13 -3
  149. package/src/state/README.md +4 -10
  150. package/src/state/atomic-write.ts +20 -3
  151. package/src/state/contracts.ts +38 -0
  152. package/src/state/coordination/mailbox.ts +12 -2
  153. package/src/state/event-log/cursor.ts +223 -0
  154. package/src/state/event-log/event-log-rotation.ts +12 -4
  155. package/src/state/event-log/event-log.ts +152 -369
  156. package/src/state/event-log/sequence-cache.ts +373 -0
  157. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  158. package/src/state/stores/active-run-registry.ts +3 -2
  159. package/src/state/stores/manifest-io.ts +237 -0
  160. package/src/state/stores/ownership-map.ts +162 -0
  161. package/src/state/stores/plan-store.ts +241 -0
  162. package/src/state/stores/run-cache.ts +0 -90
  163. package/src/state/stores/spec-store.ts +189 -0
  164. package/src/state/stores/state-store.ts +139 -232
  165. package/src/state/types.ts +199 -0
  166. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  167. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  168. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  169. package/src/ui/dock-footer.ts +49 -0
  170. package/src/ui/heartbeat-aggregator.ts +9 -1
  171. package/src/ui/inline-panel/agent-pane.ts +375 -0
  172. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  173. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  174. package/src/ui/inline-panel/crew-editor.ts +192 -0
  175. package/src/ui/inline-panel/index.ts +290 -0
  176. package/src/ui/inline-panel/panel-rows.ts +37 -0
  177. package/src/ui/inline-panel/panel-selection.ts +157 -0
  178. package/src/ui/inline-panel/panel-store.ts +111 -0
  179. package/src/ui/inline-panel/view-session-store.ts +36 -0
  180. package/src/ui/keybinding-map.ts +54 -13
  181. package/src/ui/pi-ui-compat.ts +9 -0
  182. package/src/ui/powerbar-publisher.ts +52 -1
  183. package/src/ui/run-dashboard.ts +31 -5
  184. package/src/ui/run-snapshot-cache.ts +57 -30
  185. package/src/ui/snapshot-types.ts +6 -1
  186. package/src/ui/widget/index.ts +176 -22
  187. package/src/ui/widget/task-list.ts +198 -0
  188. package/src/ui/widget/widget-formatters.ts +240 -4
  189. package/src/ui/widget/widget-renderer.ts +243 -38
  190. package/src/ui/widget/widget-types.ts +11 -0
  191. package/src/utils/child-process-shield.ts +106 -0
  192. package/src/utils/file-coalescer.ts +0 -4
  193. package/src/utils/fs-errno.ts +66 -0
  194. package/src/utils/fs-watch.ts +1 -1
  195. package/src/utils/internal-error.ts +3 -1
  196. package/src/utils/paths.ts +11 -3
  197. package/src/utils/redaction.ts +7 -0
  198. package/src/utils/safe-abort.ts +45 -0
  199. package/src/utils/task-name-generator.ts +1 -8
  200. package/src/workflows/discover-workflows.ts +20 -2
  201. package/src/workflows/validate-workflow.ts +7 -1
  202. package/src/workflows/workflow-config.ts +17 -0
  203. package/src/workflows/workflow-serializer.ts +3 -0
  204. package/src/worktree/worktree-manager.ts +22 -0
  205. package/workflows/default.workflow.md +36 -26
  206. package/workflows/strict-fast-fix.workflow.md +26 -0
  207. package/src/agents/agent-search.ts +0 -98
  208. package/src/benchmark/benchmark-runner.ts +0 -313
  209. package/src/benchmark/feedback-loop.ts +0 -73
  210. package/src/config/resilient-parser.ts +0 -117
  211. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  212. package/src/extension/result-watcher.ts +0 -139
  213. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  214. package/src/observability/metric-retention.ts +0 -64
  215. package/src/runtime/compaction/compaction-summary.ts +0 -278
  216. package/src/runtime/errors/crew-errors.ts +0 -162
  217. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  218. package/src/runtime/loop-gates.ts +0 -128
  219. package/src/runtime/metric-parser.ts +0 -36
  220. package/src/runtime/output/stream-preview.ts +0 -184
  221. package/src/runtime/output/tool-progress.ts +0 -278
  222. package/src/runtime/phase-tracker.ts +0 -385
  223. package/src/runtime/pipeline-runner.ts +0 -523
  224. package/src/runtime/process/process-lifecycle.ts +0 -491
  225. package/src/runtime/recovery/retry-runner.ts +0 -330
  226. package/src/runtime/run-drift.ts +0 -219
  227. package/src/runtime/task-quality.ts +0 -199
  228. package/src/runtime/task-runner/run-projection.ts +0 -128
  229. package/src/runtime/verification/post-checks.ts +0 -142
  230. package/src/state/coordination/schedule.ts +0 -166
  231. package/src/state/event-log/jsonl-writer.ts +0 -115
  232. package/src/state/hook-instinct-bridge.ts +0 -94
  233. package/src/state/hook-integrations.ts +0 -51
  234. package/src/state/session-state-map.ts +0 -51
  235. package/src/state/stores/blob-store.ts +0 -308
  236. package/src/state/stores/instinct-store.ts +0 -275
  237. package/src/state/stores/observation-store.ts +0 -176
  238. package/src/state/tiered-eval.ts +0 -480
  239. package/src/state/types-eval.ts +0 -58
  240. package/src/tools/safe-bash-extension.ts +0 -54
  241. package/src/tools/safe-bash.ts +0 -505
  242. package/src/ui/agent-management-overlay.ts +0 -160
  243. package/src/ui/crew-footer.ts +0 -102
  244. package/src/ui/crew-select-list.ts +0 -114
  245. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  246. package/src/ui/transcript-entries.ts +0 -256
  247. package/src/utils/conflict-detect.ts +0 -721
  248. package/src/utils/fingerprint.ts +0 -180
  249. package/src/utils/gh-protocol.ts +0 -556
  250. package/src/utils/project-detector.ts +0 -160
  251. package/src/utils/sse-parser.ts +0 -131
  252. package/src/workflows/cost-estimator.ts +0 -34
  253. package/src/workflows/intermediate-store.ts +0 -166
@@ -4,7 +4,11 @@
4
4
  * Parses a plan document and outputs agent chain commands.
5
5
  */
6
6
  import * as fs from "node:fs";
7
+ import { currentCrewRole, permissionForRole } from "../../runtime/role-permission.ts";
7
8
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
9
+ import { withRunLock } from "../../state/coordination/locks.ts";
10
+ import { appendPlanRevision } from "../../state/stores/plan-store.ts";
11
+ import { loadRunManifestById, saveRunManifest } from "../../state/stores/state-store.ts";
8
12
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
9
13
  import {
10
14
  buildAgentChain,
@@ -12,7 +16,9 @@ import {
12
16
  type OrchestratedStep,
13
17
  parsePlanDocument,
14
18
  parsePlanDocumentSimple,
19
+ stepsToPlanRecord,
15
20
  } from "../plan-orchestrate.ts";
21
+ import { locateRunCwd } from "../team-tool.ts";
16
22
  import type { PiTeamsToolResult } from "../tool-result.ts";
17
23
  import { result, type TeamContext } from "./context.ts";
18
24
 
@@ -24,7 +30,7 @@ import { result, type TeamContext } from "./context.ts";
24
30
  *
25
31
  * Usage: `team action='orchestrate' planPath='/path/to/plan.md'`
26
32
  */
27
- export function handleOrchestrate(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
33
+ export async function handleOrchestrate(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
28
34
  const planPath = params.planPath as string | undefined;
29
35
 
30
36
  if (!planPath) {
@@ -67,9 +73,67 @@ export function handleOrchestrate(params: TeamToolParamsValue, ctx: TeamContext)
67
73
  const overview = formatPlanOverview(resolvedPath);
68
74
  const commands = buildAgentChain(steps);
69
75
 
76
+ // T2/R4 (ADR-4 §6 producer 1): persist a PlanRecord when the caller binds
77
+ // an explicit run. Opt-in: orchestrate emits one `team action='run'` per
78
+ // step (each creating its OWN run), so silently attaching the record to
79
+ // "some" run would misattribute linkage. Without runId the action stays
80
+ // read-only and says so.
81
+ let planNote = "PlanRecord not persisted (no runId given — pass runId=<runId> to attach a versioned record to that run).";
82
+ let persisted: { id: string; version: number; items: number } | undefined;
83
+ const runId = typeof params.runId === "string" && params.runId.trim() ? params.runId.trim() : undefined;
84
+ if (runId) {
85
+ // Security (T2 review S1/S2): the persist branch is a run-mutating write.
86
+ // (a) role gate — read-only workers must NOT plant plan records;
87
+ // (b) ownership — foreign-session runs require explicit force;
88
+ // (c) locked read-modify-write + fail-closed tool error (never a throw
89
+ // into the caller, never a lost update vs concurrent manifest writers).
90
+ const role = currentCrewRole();
91
+ if (role && permissionForRole(role) === "read_only") {
92
+ return result(
93
+ `Role '${role}' is read-only and cannot persist a plan record to a run.`,
94
+ { action: "orchestrate", status: "error" },
95
+ true,
96
+ );
97
+ }
98
+ const runCwd = locateRunCwd(runId, ctx.cwd);
99
+ const loaded = runCwd ? loadRunManifestById(runCwd, runId) : undefined;
100
+ if (!loaded) {
101
+ return result(`runId not found: ${runId}`, { action: "orchestrate", status: "error" }, true);
102
+ }
103
+ const manifest = loaded.manifest;
104
+ if (typeof manifest.ownerSessionId === "string" && manifest.ownerSessionId !== ctx.sessionId && params.force !== true) {
105
+ return result(
106
+ `Run '${runId}' belongs to another session (owner ${manifest.ownerSessionId.slice(0, 8)}…). Pass force=true to persist anyway.`,
107
+ { action: "orchestrate", status: "error", runId },
108
+ true,
109
+ );
110
+ }
111
+ try {
112
+ const outcome = await withRunLock(manifest, async () => {
113
+ const fresh = loadRunManifestById(runCwd as string, runId); // in-lock consistent read
114
+ if (!fresh) return undefined;
115
+ const record = stepsToPlanRecord(steps, runId, { title: `Orchestrated: ${planPath}` });
116
+ appendPlanRevision(fresh.manifest, record);
117
+ saveRunManifest({
118
+ ...fresh.manifest,
119
+ updatedAt: new Date().toISOString(),
120
+ plan: { id: record.id, version: record.version },
121
+ });
122
+ return record;
123
+ });
124
+ if (!outcome) return result(`runId not found: ${runId}`, { action: "orchestrate", status: "error" }, true);
125
+ persisted = { id: outcome.id, version: outcome.version, items: outcome.items.length };
126
+ planNote = `PlanRecord persisted to run ${runId}: v${outcome.version} (${outcome.items.length} item(s)) — team action='plans' runId='${runId}' to inspect.`;
127
+ } catch (error) {
128
+ const message = error instanceof Error ? error.message : String(error);
129
+ return result(`PlanRecord persistence failed: ${message}`, { action: "orchestrate", status: "error", runId }, true);
130
+ }
131
+ }
132
+
70
133
  const outputLines: string[] = [
71
134
  `Plan: ${resolvedPath}`,
72
135
  `Steps: ${steps.length}`,
136
+ planNote,
73
137
  "",
74
138
  "# Agent Chain Commands",
75
139
  "",
@@ -86,6 +150,7 @@ export function handleOrchestrate(params: TeamToolParamsValue, ctx: TeamContext)
86
150
  planPath: resolvedPath,
87
151
  stepCount: steps.length,
88
152
  commands,
153
+ persisted,
89
154
  steps: steps.map((sqs) => ({
90
155
  stepId: sqs.stepId,
91
156
  tag: sqs.tag,
@@ -0,0 +1,192 @@
1
+ /**
2
+ * T2/R4 (ADR-4 §7, erratum D-1) — `team action='plans'` handler.
3
+ *
4
+ * Read sub-actions (get/list/diff) render the revision list at
5
+ * `<stateRoot>/plans/plans.json` with DERIVED per-item progress. Mutating
6
+ * sub-actions (approve/reject) delegate to the existing api plan-approval ops
7
+ * (approve-plan / cancel-plan), which already dual-write manifest + record
8
+ * (ADR-4 §8) — one approval implementation, no drift.
9
+ *
10
+ * Usage:
11
+ * team action='plans' runId='<id>' → get (current rev)
12
+ * team action='plans' runId='<id>' config='{subAction:"get", rev:2}'
13
+ * team action='plans' runId='<id>' config='{subAction:"list"}'
14
+ * team action='plans' runId='<id>' config='{subAction:"diff", a:1, b:2}'
15
+ * team action='plans' runId='<id>' config='{subAction:"approve"}' | reject
16
+ */
17
+ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
18
+ import { deriveItemProgress, getCurrentPlanRecord, loadPlanRecords } from "../../state/stores/plan-store.ts";
19
+ import { loadRunManifestById } from "../../state/stores/state-store.ts";
20
+ import type { PlanItemRecord, PlanRecord, TeamTaskState } from "../../state/types.ts";
21
+ import { handleTeamTool, locateRunCwd } from "../team-tool.ts";
22
+ import type { PiTeamsToolResult } from "../tool-result.ts";
23
+ import { result, type TeamContext } from "./context.ts";
24
+
25
+ const RUN_NOT_FOUND_HINT = "Use team action='list' to discover runs.";
26
+
27
+ function renderRecord(record: PlanRecord, tasks: TeamTaskState[]): string {
28
+ const progress = deriveItemProgress(record, tasks);
29
+ const lines: string[] = [];
30
+ lines.push(`Plan ${record.id} v${record.version}${record.revisionOf ? ` (revision of v${record.revisionOf.version})` : ""}`);
31
+ lines.push(`Title: ${record.title}`);
32
+ lines.push(`Run: ${record.runId} · created ${record.createdAt}${record.authorTaskId ? ` · author task ${record.authorTaskId}` : ""}`);
33
+ if (record.approval) {
34
+ lines.push(
35
+ `Approval: ${record.approval.status} (v${record.approval.planVersion}${record.approval.by ? ` by ${record.approval.by}` : ""} at ${record.approval.at})`,
36
+ );
37
+ }
38
+ lines.push("");
39
+ for (const phase of record.phases) {
40
+ lines.push(`[${phase.status}] Phase ${phase.id} — ${phase.title} (${phase.itemIds.length} item(s))`);
41
+ for (const itemId of phase.itemIds) {
42
+ const item = record.items.find((i: PlanItemRecord) => i.id === itemId);
43
+ if (!item) continue;
44
+ const p = progress.get(item.id);
45
+ const prog =
46
+ p && p.total > 0
47
+ ? ` · ${p.done}/${p.total} done${p.failed ? `, ${p.failed} failed` : ""}${p.running ? `, ${p.running} running` : ""}`
48
+ : "";
49
+ lines.push(` - [${item.status}] ${item.id}: ${item.title}${prog}`);
50
+ if (item.taskIds.length > 0) lines.push(` tasks: ${item.taskIds.join(", ")}`);
51
+ if (item.acceptance.length > 0) lines.push(` acceptance: ${item.acceptance.join("; ")}`);
52
+ }
53
+ }
54
+ return lines.join("\n");
55
+ }
56
+
57
+ interface PlanDiff {
58
+ added: string[];
59
+ removed: string[];
60
+ dropped: string[];
61
+ statusChanged: Array<{ id: string; from: string; to: string }>;
62
+ retitled: Array<{ id: string; from: string; to: string }>;
63
+ }
64
+
65
+ function diffRecords(a: PlanRecord, b: PlanRecord): PlanDiff {
66
+ const aById = new Map(a.items.map((i) => [i.id, i]));
67
+ const bById = new Map(b.items.map((i) => [i.id, i]));
68
+ const diff: PlanDiff = { added: [], removed: [], dropped: [], statusChanged: [], retitled: [] };
69
+ for (const [id, item] of bById) {
70
+ const prior = aById.get(id);
71
+ if (!prior) {
72
+ diff.added.push(id);
73
+ continue;
74
+ }
75
+ if (prior.status !== item.status) diff.statusChanged.push({ id, from: prior.status, to: item.status });
76
+ if (prior.title !== item.title) diff.retitled.push({ id, from: prior.title, to: item.title });
77
+ if (item.status === "dropped" && prior.status !== "dropped") diff.dropped.push(id);
78
+ }
79
+ for (const id of aById.keys()) if (!bById.has(id)) diff.removed.push(id);
80
+ return diff;
81
+ }
82
+
83
+ function renderDiff(a: PlanRecord, b: PlanRecord): string {
84
+ const diff = diffRecords(a, b);
85
+ const lines = [
86
+ `Diff v${a.version} → v${b.version} (${a.id})`,
87
+ ` added: ${diff.added.length ? diff.added.join(", ") : "—"}`,
88
+ ` removed: ${diff.removed.length ? diff.removed.join(", ") : "—"}`,
89
+ ` dropped: ${diff.dropped.length ? diff.dropped.join(", ") : "—"}`,
90
+ ` status change: ${diff.statusChanged.length ? diff.statusChanged.map((s) => `${s.id} ${s.from}→${s.to}`).join(", ") : "—"}`,
91
+ ` retitled: ${diff.retitled.length ? diff.retitled.map((s) => `${s.id}`).join(", ") : "—"}`,
92
+ ];
93
+ if (diff.added.length + diff.removed.length + diff.dropped.length + diff.statusChanged.length + diff.retitled.length === 0) {
94
+ lines.push(" (no item-level changes)");
95
+ }
96
+ return lines.join("\n");
97
+ }
98
+
99
+ export async function handlePlans(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
100
+ const subAction = (typeof params.subAction === "string" && params.subAction.trim() ? params.subAction.trim() : "get") as
101
+ | "get"
102
+ | "list"
103
+ | "diff"
104
+ | "approve"
105
+ | "reject";
106
+ const runId = typeof params.runId === "string" && params.runId.trim() ? params.runId.trim() : "";
107
+ if (!runId) return result("plans requires runId.", { action: "plans", status: "error" }, true);
108
+ const runCwd = locateRunCwd(runId, ctx.cwd);
109
+ const loaded = runCwd ? loadRunManifestById(runCwd, runId) : undefined;
110
+ if (!loaded) return result(`Run '${runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "plans", status: "error" }, true);
111
+ const { manifest, tasks } = loaded;
112
+
113
+ // Mutating sub-actions delegate to the api plan-approval ops (single
114
+ // implementation of the dual-write + permission checks).
115
+ if (subAction === "approve" || subAction === "reject") {
116
+ return await handleTeamTool(
117
+ {
118
+ action: "api",
119
+ runId,
120
+ config: { operation: subAction === "approve" ? "approve-plan" : "cancel-plan" },
121
+ },
122
+ // Forward the FULL caller context (sessionId etc.) so the delegated ops
123
+ // can enforce ownership checks now or in a future train (review S3) —
124
+ // only the cwd is overridden to the owning run's location.
125
+ { ...ctx, cwd: runCwd ?? ctx.cwd },
126
+ );
127
+ }
128
+
129
+ const revisions = loadPlanRecords(manifest);
130
+ if (revisions.length === 0) {
131
+ // ADR-4 §2 fallback: pre-v2 run — degrade gracefully, manifest gate info.
132
+ const gate = manifest.planApproval;
133
+ return result(
134
+ `Run '${runId}' has no plan record (pre-v2 run).${gate ? ` Manifest gate: ${gate.status}${gate.required ? " (required)" : ""}.` : ""}`,
135
+ { action: "plans", status: "ok", runId },
136
+ );
137
+ }
138
+
139
+ if (subAction === "list") {
140
+ const lines = revisions.map(
141
+ (r) =>
142
+ `v${r.version} · ${r.createdAt} · ${r.items.length} item(s), ${r.items.filter((i) => i.status === "dropped").length} dropped · ${r.title}` +
143
+ (r.approval ? ` · approval ${r.approval.status}` : ""),
144
+ );
145
+ const current = manifest.plan ? ` (current: v${manifest.plan.version})` : " (current: highest — no manifest pointer)";
146
+ return result(`Plan revisions for ${runId}${current}:\n${lines.join("\n")}`, {
147
+ action: "plans",
148
+ status: "ok",
149
+ runId,
150
+ data: { revisions: revisions.length, current: manifest.plan?.version ?? revisions[revisions.length - 1]?.version },
151
+ });
152
+ }
153
+
154
+ if (subAction === "diff") {
155
+ const a = typeof params.a === "number" ? params.a : Number(params.a);
156
+ const b = typeof params.b === "number" ? params.b : Number(params.b);
157
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
158
+ return result(
159
+ "diff requires numeric revision params: config='{subAction:\"diff\", a:1, b:2}'.",
160
+ { action: "plans", status: "error" },
161
+ true,
162
+ );
163
+ }
164
+ const recA = revisions.find((r) => r.version === a);
165
+ const recB = revisions.find((r) => r.version === b);
166
+ if (!recA || !recB) {
167
+ return result(
168
+ `Revision(s) not found: available versions are ${revisions.map((r) => `v${r.version}`).join(", ")}.`,
169
+ { action: "plans", status: "error" },
170
+ true,
171
+ );
172
+ }
173
+ return result(renderDiff(recA, recB), { action: "plans", status: "ok", runId, data: { a, b } });
174
+ }
175
+
176
+ // get (default)
177
+ const rev = params.rev === undefined ? undefined : Number(params.rev);
178
+ const record = rev === undefined || Number.isNaN(rev) ? getCurrentPlanRecord(manifest) : revisions.find((r) => r.version === rev);
179
+ if (!record) {
180
+ return result(
181
+ `Revision v${rev} not found: available versions are ${revisions.map((r) => `v${r.version}`).join(", ")}.`,
182
+ { action: "plans", status: "error" },
183
+ true,
184
+ );
185
+ }
186
+ return result(renderRecord(record, tasks), {
187
+ action: "plans",
188
+ status: "ok",
189
+ runId,
190
+ data: { planId: record.id, version: record.version, items: record.items.length },
191
+ });
192
+ }
@@ -1,9 +1,12 @@
1
+ import { renderAskAnswer } from "../../prompt/prompt-runtime.ts";
1
2
  import { readCrewAgents, recordFromTask, saveCrewAgents } from "../../runtime/crew-agent-records.ts";
3
+ import { isWaitingWorkerAlive } from "../../runtime/dispatch-batch.ts";
2
4
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
3
5
  import { withRunLockSync } from "../../state/coordination/locks.ts";
4
- import { appendMailboxMessage, updateMailboxMessageReply } from "../../state/coordination/mailbox.ts";
6
+ import { appendMailboxMessage, readMailbox, updateMailboxMessageReply } from "../../state/coordination/mailbox.ts";
5
7
  import { appendEventAsync } from "../../state/event-log/event-log.ts";
6
- import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
8
+ import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
9
+ import type { TeamTaskState } from "../../state/types.ts";
7
10
  import { logInternalError } from "../../utils/internal-error.ts";
8
11
  import { locateRunCwd } from "../team-tool.ts";
9
12
  import type { PiTeamsToolResult } from "../tool-result.ts";
@@ -11,10 +14,61 @@ import { result, type TeamContext } from "./context.ts";
11
14
  import { paramRequired } from "./param-error.ts";
12
15
  import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
13
16
 
17
+ /** WP-2/R2 (ADR-0 item 8/security P1-4): fence the injected ask answer as
18
+ * untrusted when it is replayed into the next dispatch prompt via the
19
+ * `pendingSteers` cross-attempt channel. The mailbox is a same-uid
20
+ * unauthenticated channel — raw answer text is never injected without the
21
+ * dependency-context fence. */
22
+ /** Re-queue a waiting task for durable scheduler resume. Optionally carries an
23
+ * injected note (the fenced ask answer / timeout note) on the pendingSteers
24
+ * cross-attempt channel — child-executor re-appends pendingSteers into the
25
+ * next worker incarnation's steering file, and registerLiveAgent drains them
26
+ * for the live-session runtime. The park marker (`waiting`) is cleared: a
27
+ * requeued task must not stay parked (mirrors applyRecoveryPlan discipline). */
28
+ function requeueWaitingTask(tasks: TeamTaskState[], taskId: string, message: string, injection?: string): TeamTaskState[] {
29
+ return tasks.map((task) => {
30
+ if (task.id !== taskId) return task;
31
+ return {
32
+ ...task,
33
+ status: "queued" as const,
34
+ startedAt: undefined,
35
+ finishedAt: undefined,
36
+ error: undefined,
37
+ waiting: undefined,
38
+ adaptive: {
39
+ ...task.adaptive,
40
+ phase: "resumed",
41
+ task: message || task.adaptive?.task || "",
42
+ },
43
+ ...(injection ? { pendingSteers: [...(task.pendingSteers ?? []), injection] } : {}),
44
+ };
45
+ });
46
+ }
47
+
14
48
  /**
15
- * Handle `respond` action: send a message to a waiting (interactive) task.
16
- * The task must be in "waiting" status. The message is stored in the task's
17
- * mailbox and the task is re-queued for durable scheduler resume.
49
+ * Handle `respond` action: answer a waiting (parked) task's question.
50
+ *
51
+ * WP-2/R2 (ADR-0 docs/decisions/2026-08-17-waiting-producer-ask.md item 8):
52
+ * liveness discrimination replaces the old blanket re-queue of ALL waiting
53
+ * tasks.
54
+ * - ALIVE (heartbeat last-beat within the 60s gradient-stale window, or a
55
+ * live in-memory session handle) → append a mailbox `kind:"response"`
56
+ * entry carrying the task's `waiting.questionId` (from leader, to the task)
57
+ * and LEAVE `task.waiting` in place: the parked `ask` tool polls the task
58
+ * mailbox stream, picks the response up and flips waiting→running via its
59
+ * own terminal report.
60
+ * - DEAD → re-queue the task AND inject the fenced answer into the next
61
+ * dispatch prompt (pendingSteers cross-attempt channel). No questionId-
62
+ * tagged mailbox response is written.
63
+ * - EXACTLY-ONE-DISPATCH GUARD: a response for a given questionId dispatches
64
+ * via mailbox-write OR requeue+inject — never both — and is idempotent per
65
+ * questionId. The branch is atomic under the run lock (check + write inside
66
+ * one withRunLockSync + fresh-reload critical section); the requeue path
67
+ * clears `waiting` so a second respond finds no waiting task (no-op).
68
+ * - Legacy waiting parks without `task.waiting` (pre-v2 records) keep the old
69
+ * mailbox + re-queue resume behavior — there is no parked tool to pick a
70
+ * questionId-tagged response up.
71
+ * - `ask.answered` is emitted on both discrimination paths.
18
72
  */
19
73
  export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
20
74
  if (!params.runId)
@@ -80,24 +134,78 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
80
134
  );
81
135
  }
82
136
 
83
- const resumed = new Set(targetTasks.map((t) => t.id));
84
137
  const mailboxIds: string[] = [];
138
+ /** ALIVE parks: response delivered via the mailbox stream the parked ask tool polls. */
139
+ const answeredIds: string[] = [];
140
+ /** DEAD parks (and legacy no-question parks): re-queued for durable scheduler resume. */
141
+ const requeuedIds: string[] = [];
142
+ /** Exactly-once no-ops: a mailbox response already exists for the questionId. */
143
+ const noopIds: string[] = [];
144
+ const answeredEvents: Array<{ taskId: string; questionId: string; delivery: "mailbox" | "requeue" }> = [];
145
+ let updatedTasks = fresh.tasks;
146
+
85
147
  for (const task of targetTasks) {
86
- const mailbox = appendMailboxMessage(fresh.manifest, {
87
- direction: "inbox",
88
- from: "leader",
89
- to: task.id,
90
- taskId: task.id,
91
- body: message || "(resume)",
92
- kind: "response",
93
- priority: "normal",
94
- deliveryMode: "next_turn",
95
- data: { action: "respond", kind: "response" },
96
- replyTo: params.replyTo,
97
- replyFrom: params.replyFrom,
98
- replyDeadline: params.replyDeadline,
99
- });
100
- mailboxIds.push(mailbox.id);
148
+ const questionId = task.waiting?.questionId;
149
+ if (!questionId) {
150
+ // Legacy pre-v2 waiting park (no ask question): keep the old
151
+ // mailbox + re-queue resume behavior verbatim.
152
+ const mailbox = appendMailboxMessage(fresh.manifest, {
153
+ direction: "inbox",
154
+ from: "leader",
155
+ to: task.id,
156
+ taskId: task.id,
157
+ body: message || "(resume)",
158
+ kind: "response",
159
+ priority: "normal",
160
+ deliveryMode: "next_turn",
161
+ data: { action: "respond", kind: "response" },
162
+ replyTo: params.replyTo,
163
+ replyFrom: params.replyFrom,
164
+ replyDeadline: params.replyDeadline,
165
+ });
166
+ mailboxIds.push(mailbox.id);
167
+ updatedTasks = requeueWaitingTask(updatedTasks, task.id, message);
168
+ requeuedIds.push(task.id);
169
+ continue;
170
+ }
171
+ // EXACTLY-ONE-DISPATCH GUARD (idempotent per questionId): if a
172
+ // mailbox response for this questionId already exists, do nothing —
173
+ // never write a second response and never requeue on top of it.
174
+ // Atomic: the scan and the write below share this run-lock section.
175
+ if (readMailbox(fresh.manifest, "inbox", task.id, "response").some((m) => m.questionId === questionId)) {
176
+ noopIds.push(task.id);
177
+ continue;
178
+ }
179
+ if (isWaitingWorkerAlive(task)) {
180
+ // ALIVE: deliver via the task mailbox stream the parked ask tool
181
+ // polls (≤500ms). The park stays — the tool's terminal report
182
+ // flips waiting→running; root-side status must NOT change here.
183
+ const mailbox = appendMailboxMessage(fresh.manifest, {
184
+ direction: "inbox",
185
+ from: "leader",
186
+ to: task.id,
187
+ taskId: task.id,
188
+ body: message || "(resume)",
189
+ kind: "response",
190
+ priority: "normal",
191
+ deliveryMode: "next_turn",
192
+ questionId,
193
+ data: { action: "respond", kind: "response", questionId },
194
+ replyTo: params.replyTo,
195
+ replyFrom: params.replyFrom,
196
+ replyDeadline: params.replyDeadline,
197
+ });
198
+ mailboxIds.push(mailbox.id);
199
+ answeredIds.push(task.id);
200
+ answeredEvents.push({ taskId: task.id, questionId, delivery: "mailbox" });
201
+ continue;
202
+ }
203
+ // DEAD: re-queue AND inject the fenced answer into the next dispatch
204
+ // prompt. No questionId-tagged mailbox response is written — the
205
+ // parked poller is gone; delivery rides the requeue instead.
206
+ updatedTasks = requeueWaitingTask(updatedTasks, task.id, message, renderAskAnswer(questionId, message));
207
+ requeuedIds.push(task.id);
208
+ answeredEvents.push({ taskId: task.id, questionId, delivery: "requeue" });
101
209
  }
102
210
 
103
211
  // If this respond includes a replyTo, update the original message with reply metadata.
@@ -105,40 +213,53 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
105
213
  updateMailboxMessageReply(fresh.manifest, params.replyTo, message || "(resume)");
106
214
  }
107
215
 
108
- // Re-queue waiting tasks so durable scheduler/resume can pick them up again.
109
- const updatedTasks = fresh.tasks.map((task) => {
110
- if (!resumed.has(task.id)) return task;
111
- return {
112
- ...task,
113
- status: "queued" as const,
114
- startedAt: undefined,
115
- finishedAt: undefined,
116
- error: undefined,
117
- adaptive: {
118
- ...task.adaptive,
119
- phase: "resumed",
120
- task: message || task.adaptive?.task || "",
121
- },
122
- };
123
- });
124
-
125
- saveRunTasks(fresh.manifest, updatedTasks);
126
216
  let manifest = fresh.manifest;
127
- if (
128
- manifest.status === "blocked" ||
129
- manifest.status === "completed" ||
130
- manifest.status === "failed" ||
131
- manifest.status === "cancelled"
132
- ) {
133
- manifest = updateRunStatus(manifest, "running", `Resumed ${resumed.size} waiting task(s).`);
217
+ if (requeuedIds.length > 0) {
218
+ saveRunTasks(fresh.manifest, updatedTasks);
219
+ // WP-2 review round 1 (P3): clear manifest.waitState when its park was
220
+ // requeued — mirror the broker's clearWaitState guard (taskId match),
221
+ // else the stale pointer shields the run from staleness repair for
222
+ // the full 24h TTL and leaves a stale marker in the manifest/UI.
223
+ if (manifest.waitState && requeuedIds.includes(manifest.waitState.taskId)) {
224
+ manifest = { ...manifest, waitState: undefined, updatedAt: new Date().toISOString() };
225
+ saveRunManifest(manifest);
226
+ }
227
+ if (
228
+ manifest.status === "blocked" ||
229
+ manifest.status === "completed" ||
230
+ manifest.status === "failed" ||
231
+ manifest.status === "cancelled"
232
+ ) {
233
+ manifest = updateRunStatus(manifest, "running", `Resumed ${requeuedIds.length} waiting task(s).`);
234
+ }
134
235
  }
135
- for (const taskId of resumed) {
236
+ // ask.answered on BOTH discrimination paths (ADR-0 item 8).
237
+ for (const answered of answeredEvents) {
136
238
  // H1 (2026-08-10): handleRespond is a SYNC function inside a sync
137
239
  // run-lock callback — cannot await; fire-and-forget async.
240
+ void appendEventAsync(manifest.eventsPath, {
241
+ type: "ask.answered",
242
+ runId: manifest.runId,
243
+ taskId: answered.taskId,
244
+ message: `Question ${answered.questionId} answered (${
245
+ answered.delivery === "mailbox"
246
+ ? "worker alive — parked ask tool picks up the mailbox response"
247
+ : "worker dead — task requeued with the answer injected"
248
+ }).`,
249
+ data: { questionId: answered.questionId, delivery: answered.delivery },
250
+ }).catch((error) =>
251
+ logInternalError(
252
+ "respond.ask-answered-event",
253
+ error instanceof Error ? error : new Error(String(error)),
254
+ `runId=${manifest.runId}`,
255
+ ),
256
+ );
257
+ }
258
+ for (const requeuedId of requeuedIds) {
138
259
  void appendEventAsync(manifest.eventsPath, {
139
260
  type: "task.resumed",
140
261
  runId: manifest.runId,
141
- taskId,
262
+ taskId: requeuedId,
142
263
  message: message || "Task re-queued after respond.",
143
264
  data: { mailboxIds },
144
265
  }).catch((error) =>
@@ -149,24 +270,35 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
149
270
  ),
150
271
  );
151
272
  }
152
- try {
153
- const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
154
- saveCrewAgents(
155
- fresh.manifest,
156
- updatedTasks.map((task) => recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process")),
157
- );
158
- } catch (error) {
159
- logInternalError("team-tool.handleRespond.crewAgents", error, `runId=${fresh.manifest.runId}`);
273
+ if (requeuedIds.length > 0) {
274
+ try {
275
+ const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
276
+ saveCrewAgents(
277
+ fresh.manifest,
278
+ updatedTasks
279
+ .filter((task) => requeuedIds.includes(task.id))
280
+ .map((task) => recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process")),
281
+ );
282
+ } catch (error) {
283
+ logInternalError("team-tool.handleRespond.crewAgents", error, `runId=${fresh.manifest.runId}`);
284
+ }
160
285
  }
161
286
 
162
- const resumedIds = targetTasks.map((t) => t.id);
163
- return result(`Resumed ${resumedIds.length} waiting task(s): ${resumedIds.join(", ")}. Message: ${message || "(no message)"}`, {
164
- action: "respond",
165
- status: "ok",
166
- runId: fresh.manifest.runId,
167
- resumedIds,
168
- mailboxIds,
169
- intent: `responding to ${resumedIds.join(", ")} in ${fresh.manifest.runId}`,
170
- });
287
+ const summaryParts: string[] = [];
288
+ if (answeredIds.length > 0) summaryParts.push(`answered via mailbox (worker alive): ${answeredIds.join(", ")}`);
289
+ if (requeuedIds.length > 0) summaryParts.push(`requeued with injected answer (worker dead): ${requeuedIds.join(", ")}`);
290
+ if (noopIds.length > 0) summaryParts.push(`already answered, no-op: ${noopIds.join(", ")}`);
291
+ return result(
292
+ `Responded to ${targetTasks.length} waiting task(s). ${summaryParts.join("; ")}. Message: ${message || "(no message)"}`,
293
+ {
294
+ action: "respond",
295
+ status: "ok",
296
+ runId: fresh.manifest.runId,
297
+ resumedIds: requeuedIds,
298
+ mailboxIds,
299
+ intent: `responding to ${targetTasks.map((t) => t.id).join(", ")} in ${fresh.manifest.runId}`,
300
+ data: { answeredIds, noopIds },
301
+ },
302
+ );
171
303
  });
172
304
  }
@@ -1,5 +1,7 @@
1
1
  import type { PiTeamsConfig } from "../../config/config.ts";
2
2
  import { loadConfig } from "../../config/config.ts";
3
+ import { isSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts";
4
+ import { safeAbort } from "../../utils/safe-abort.ts";
3
5
  import type { TeamContext } from "./context.ts";
4
6
 
5
7
  /**
@@ -55,16 +57,46 @@ export function resolveRunDeadline<T extends object>(
55
57
 
56
58
  const controller = new AbortController();
57
59
  // Propagate caller abort (ctx.signal) to the shared controller.
60
+ // R6-F2 (W2/RC-03 consistency): named handler instead of an anonymous
61
+ // { once: true } closure. resolveRunDeadline returns immediately (there is
62
+ // no run-lifetime finally scope inside this module — callers in run.ts only
63
+ // clearTimeout the timer), so removal is wired to the deadline controller's
64
+ // own abort: once the run controller aborts for ANY cause (deadline timer
65
+ // fire, caller-signal propagation, or an explicit controller.abort() from a
66
+ // linked path such as startForegroundRun), the propagation listener is dead
67
+ // weight on ctx.signal and is removed. Behavior-neutral: if the caller's
68
+ // signal fired afterwards, the listener would only call abort() on an
69
+ // already-aborted controller — a no-op.
58
70
  if (ctx.signal) {
59
- if (ctx.signal.aborted) controller.abort();
60
- else ctx.signal.addEventListener("abort", () => controller.abort(), { once: true });
71
+ if (ctx.signal.aborted) safeAbort(controller, "run-deadline.caller-preaborted");
72
+ else {
73
+ const callerSignal = ctx.signal;
74
+ // The deadline signal is passed down to run executors that may spawn
75
+ // children with it; aborting after a child exited throws AbortError
76
+ // from Node's child_process listener — never let that escape a
77
+ // pi event handler (safe-abort).
78
+ const onCallerAbort = () => {
79
+ // Regression: switching sessions (resume/new/fork — including
80
+ // /crew-view) tears the current turn down via session.abort(),
81
+ // firing the tool-call abort while the run is still forming. A
82
+ // foreground run must survive session switches (P0 — it shares
83
+ // the process), so a teardown abort must NOT cancel it. Only a
84
+ // genuine caller abort OUTSIDE a session switch propagates.
85
+ if (isSessionSwitchInFlight()) return;
86
+ safeAbort(controller, "run-deadline.caller-abort");
87
+ };
88
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
89
+ controller.signal.addEventListener("abort", () => callerSignal.removeEventListener("abort", onCallerAbort), { once: true });
90
+ }
61
91
  }
62
92
  // Arm the deadline timer (unref'd so it never blocks process exit).
63
93
  // RC-02: expose the timer so callers can clearTimeout on normal completion —
64
94
  // otherwise every run leaves a dangling 1h timer retaining ctx/params in closure.
95
+ // Same abort-after-exit guard as above: the timer may fire after workers
96
+ // already exited and their spawn-signal listeners are gone — Node can throw.
65
97
  let timer: NodeJS.Timeout | undefined;
66
98
  if (deadlineMs > 0) {
67
- timer = setTimeout(() => controller.abort(), deadlineMs);
99
+ timer = setTimeout(() => safeAbort(controller, "run-deadline.timer"), deadlineMs);
68
100
  timer.unref?.();
69
101
  }
70
102
  return { signal: controller.signal, deadlineMs, controller, timer };