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
@@ -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;
@@ -13,6 +13,7 @@ import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
13
13
  import { statusIcon, type ToolRenderContext, teamToolRenderer } from "../../ui/tool-renderers/index.ts";
14
14
  import type { CrewWidgetState } from "../../ui/widget/index.ts";
15
15
  import { updateCrewWidget } from "../../ui/widget/index.ts";
16
+ import { safeAbort } from "../../utils/safe-abort.ts";
16
17
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
17
18
  // Team tool handler — lazy-loaded because team-tool.ts imports many modules
18
19
  import type { handleTeamTool as HandleTeamToolFn } from "../team-tool.ts";
@@ -174,7 +175,10 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
174
175
  const controller = new AbortController();
175
176
  const toolKey = Symbol();
176
177
  deps.foregroundControllers.set(toolKey, controller);
177
- const abort = (): void => controller.abort();
178
+ // safe-abort: pi's tool-call abort can race a completed run whose
179
+ // spawned children already exited — Node's child_process signal
180
+ // listener throws AbortError out of abort() and kills the host.
181
+ const abort = (): void => safeAbort(controller, "team-tool.caller-abort");
178
182
  signal?.addEventListener("abort", abort, { once: true });
179
183
  const stopProgress = startTeamToolProgressBinder(onUpdate as OnUpdate | undefined);
180
184
  try {
@@ -15,7 +15,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
15
15
  import { loadConfig } from "../../config/config.ts";
16
16
  import { DEFAULT_UI } from "../../config/defaults.ts";
17
17
  import type { LiveRunSidebar as LiveRunSidebarType } from "../../ui/live-run-sidebar.ts";
18
- import { requestRender, setExtensionWidget, showCustom } from "../../ui/pi-ui-compat.ts";
18
+ import { requestRender, setExtensionWidget, showCustom, toPiWidgetPlacement } from "../../ui/pi-ui-compat.ts";
19
19
  import {
20
20
  clearPiCrewPowerbar,
21
21
  registerPiCrewPowerbarSegments,
@@ -76,10 +76,11 @@ export async function installLiveSidebar(ctx: ExtensionContext, runId: string, s
76
76
  state.liveSidebarRunId = runId;
77
77
  state.dashboardOpened = true;
78
78
  const widgetPlacement = uiConfig?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
79
- setExtensionWidget(ctx, "pi-crew", undefined, { placement: widgetPlacement });
80
- setExtensionWidget(ctx, "pi-crew-active", undefined, { placement: widgetPlacement });
79
+ const placement = toPiWidgetPlacement(widgetPlacement);
80
+ setExtensionWidget(ctx, "pi-crew", undefined, { placement });
81
+ setExtensionWidget(ctx, "pi-crew-active", undefined, { placement });
81
82
  deps.widgetState.lastVisibility = "hidden";
82
- deps.widgetState.lastPlacement = widgetPlacement;
83
+ deps.widgetState.lastPlacement = placement;
83
84
  deps.widgetState.lastKey = "pi-crew-active";
84
85
  deps.widgetState.model = undefined;
85
86
  const width = Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? DEFAULT_UI.dashboardWidth));
@@ -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",