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 { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { atomicWriteJson, atomicWriteJsonCoalesced, flushPendingAtomicWrites, readJsonFile } from "../state/atomic-write.ts";
@@ -143,25 +144,79 @@ function removeStaleAgentsLock(lockPath: string, staleMs: number): boolean {
143
144
  }
144
145
  fs.rmSync(lockPath, { force: true });
145
146
  return true;
146
- } catch {
147
+ } catch (error) {
148
+ // R17-B1 (HIGH): a bare catch here swallowed parse/stat/rm failures, so the
149
+ // root cause became invisible after withAgentsLock's 60s "locked" error.
150
+ logInternalError("crew-agents.remove-stale-lock", error, `lockPath=${lockPath}`, "warn");
147
151
  return false;
148
152
  }
149
153
  }
150
154
 
155
+ /**
156
+ * Phase 3.1 (decision α): token-guarded release for the agents-record lock,
157
+ * mirroring `locks.ts` `releaseLock` semantics — only `rmSync` if the stored
158
+ * token still matches the token we wrote at acquisition. If the lock was
159
+ * stolen while our critical section ran (stale-steal replaced the file with a
160
+ * NEW owner's token), releasing must be a no-op — otherwise we would delete
161
+ * the new holder's lock and break mutual exclusion (the exact race the
162
+ * token guard in locks.ts fixes). Missing/corrupt files are handled safely:
163
+ * ENOENT is a no-op (lock already gone); a symlink is never removed (defense
164
+ * against attacker-planted symlinks, matching locks.ts); an unreadable/
165
+ * unparseable payload is treated like locks.ts `releaseLock` (stored token
166
+ * unknown → remove) so a torn write cannot strand the lock forever — the
167
+ * 30s stale-steal path remains the backstop for genuinely foreign locks.
168
+ */
169
+ function releaseAgentsLock(filePath: string, token: string): void {
170
+ try {
171
+ const stat = fs.lstatSync(filePath);
172
+ if (stat.isSymbolicLink()) return;
173
+ } catch {
174
+ /* ENOENT — lock already gone, nothing to release */
175
+ }
176
+ let stored: string | undefined;
177
+ try {
178
+ const raw = fs.readFileSync(filePath, "utf-8");
179
+ const parsed = JSON.parse(raw) as { token?: unknown };
180
+ stored = typeof parsed.token === "string" ? parsed.token : undefined;
181
+ } catch {
182
+ /* unreadable/corrupt — see doc comment: remove, mirroring locks.ts */
183
+ }
184
+ if (stored === undefined || stored === token) {
185
+ try {
186
+ fs.rmSync(filePath, { force: true });
187
+ } catch (error) {
188
+ const code = (error as NodeJS.ErrnoException).code;
189
+ if (code !== "ENOENT") {
190
+ logInternalError("crew-agents.release-lock", error, `lockPath=${filePath}`, "warn");
191
+ }
192
+ }
193
+ }
194
+ // stored !== token → lock stolen by another process; do NOT touch.
195
+ }
196
+
151
197
  function withAgentsLock<T>(manifest: TeamRunManifest, fn: () => T): T {
152
198
  const filePath = agentsLockPath(manifest);
153
199
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
200
+ // Phase 3.1 (decision α): the agents lock file now carries a randomUUID
201
+ // `token` (format change explicitly accepted by the plan) so the release
202
+ // below is token-guarded — a stale-steal by another process can no longer
203
+ // be released by the wrong owner (previously PID-only release). Mirrors
204
+ // the `writeLockFile` payload shape in locks.ts ({kind,pid,createdAt,token}
205
+ // minus `kind`). Generated ONCE per call; only the successful O_EXCL write
206
+ // persists it, so a single token per acquisition is correct.
207
+ const token = randomUUID();
154
208
  let attempt = 0;
155
209
  const deadline = Date.now() + AGENTS_LOCK_STALE_MS * 2;
156
210
  while (true) {
157
211
  try {
158
- const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
212
+ const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
159
213
  try {
160
214
  fs.writeSync(
161
215
  fd,
162
216
  JSON.stringify({
163
217
  pid: process.pid,
164
218
  createdAt: new Date().toISOString(),
219
+ token,
165
220
  }),
166
221
  );
167
222
  } finally {
@@ -188,11 +243,8 @@ function withAgentsLock<T>(manifest: TeamRunManifest, fn: () => T): T {
188
243
  try {
189
244
  return fn();
190
245
  } finally {
191
- try {
192
- fs.rmSync(filePath, { force: true });
193
- } catch {
194
- /* best-effort */
195
- }
246
+ // Phase 3.1 (decision α): unconditional rmSync → token-guarded release.
247
+ releaseAgentsLock(filePath, token);
196
248
  }
197
249
  }
198
250
 
@@ -221,7 +273,10 @@ export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
221
273
  // 2.5: ensure intra-process coalesced writes are visible to subsequent
222
274
  // readers in the same process. Cross-process readers still see the file
223
275
  // after at most one coalesce window (250 ms).
224
- flushPendingAtomicWrites();
276
+ // R10-2: scoped flush — readCrewAgents only reads agents.json, so it must
277
+ // not drain unrelated coalesced writes (tasks.json of live runs, other
278
+ // runs' agents.json) process-wide on every read.
279
+ flushPendingAtomicWrites(agentsPath(manifest));
225
280
  try {
226
281
  const records = readJsonFileCoalesced(
227
282
  agentsPath(manifest),
@@ -236,7 +291,13 @@ export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
236
291
  seen.add(r.id);
237
292
  return true;
238
293
  });
239
- if (deduped.length !== records.length) {
294
+ // R10-7: write back only when the corrected list actually differs from
295
+ // the current content. A filter() only removes entries, so element
296
+ // identity + length is an exact (and cheap) change check — deep-equal is
297
+ // unnecessary, and this eliminates the redundant durable write + lock
298
+ // churn on unchanged reads.
299
+ const changed = deduped.length !== records.length || records.some((record, index) => record !== deduped[index]);
300
+ if (changed) {
240
301
  // Schema mismatch or duplicates detected — save corrected state
241
302
  saveCrewAgents(manifest, deduped);
242
303
  }
@@ -264,7 +325,10 @@ export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<Cr
264
325
  seen.add(r.id);
265
326
  return true;
266
327
  });
267
- if (deduped.length !== raw.length) {
328
+ // R10-7: write back only on actual change (element identity + length —
329
+ // see the sync readCrewAgents path for the reasoning).
330
+ const changed = deduped.length !== raw.length || raw.some((record, index) => record !== deduped[index]);
331
+ if (changed) {
268
332
  try {
269
333
  saveCrewAgents(manifest, deduped);
270
334
  } catch {
@@ -295,7 +359,7 @@ export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<Cr
295
359
  export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
296
360
  // P0-3: flush any pending coalesced (best-effort) write first so a stale
297
361
  // debounced progress snapshot can't clobber this durable write.
298
- flushPendingAgentWrites();
362
+ flushPendingAgentWrites(manifest, records);
299
363
  withAgentsLock(manifest, () => {
300
364
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
301
365
  const filePath = agentsPath(manifest);
@@ -430,9 +494,14 @@ export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record:
430
494
  });
431
495
  }
432
496
 
433
- /** @internal Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
434
- function flushPendingAgentWrites(): void {
435
- flushPendingAtomicWrites();
497
+ /** @internal Flush coalesced agent-record writes synchronously. Hook into cleanup paths. */
498
+ function flushPendingAgentWrites(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
499
+ // R10-2: scope the pre-durable-save flush to the agent-record files this
500
+ // save can be clobbered by (agents.json + the per-task status.json files).
501
+ // Unrelated coalesced writes (tasks.json, other runs) stay pending on their
502
+ // own timers — draining them here only added latency to this path.
503
+ flushPendingAtomicWrites(agentsPath(manifest));
504
+ for (const record of records) flushPendingAtomicWrites(agentStatusPath(manifest, record.taskId));
436
505
  }
437
506
 
438
507
  export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: string): CrewAgentRecord | undefined {
@@ -528,6 +597,10 @@ function nextAgentEventSeq(filePath: string): number {
528
597
  }
529
598
 
530
599
  export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string, event: unknown): void {
600
+ // Mixed-usage guard: if a buffered batch is pending for this task, land it
601
+ // first so seq allocation stays monotonic and file order matches seq order
602
+ // (reader cursors advance by seq in file order).
603
+ flushCrewAgentRecordBuffer(manifest, taskId);
531
604
  ensureAgentStateDir(manifest, taskId);
532
605
  const filePath = agentStateFile(manifest, taskId, "events.jsonl");
533
606
  const seq = nextAgentEventSeq(filePath);
@@ -617,10 +690,182 @@ export function readCrewAgentEventsCursor(
617
690
 
618
691
  export function appendCrewAgentOutput(manifest: TeamRunManifest, taskId: string, text: string): void {
619
692
  if (!text.trim()) return;
693
+ // Mixed-usage guard (see appendCrewAgentEvent): land any pending buffered
694
+ // lines first so buffered/direct lines never interleave out of order.
695
+ flushCrewAgentRecordBuffer(manifest, taskId);
620
696
  ensureAgentStateDir(manifest, taskId);
621
697
  fs.appendFileSync(agentStateFile(manifest, taskId, "output.log"), `${redactSecretString(text)}\n`, "utf-8");
622
698
  }
623
699
 
700
+ // ---------------------------------------------------------------------------
701
+ // R10-5 (Wave 2B item 4): per-task batching for the agent-record append sinks.
702
+ // ---------------------------------------------------------------------------
703
+ // child-executor's hot path previously did, PER CHILD EVENT:
704
+ // appendCrewAgentEvent -> nextAgentEventSeq (stat + sidecar read) + appendFileSync
705
+ // appendCrewAgentOutput -> appendFileSync
706
+ // (~4 syscalls/event after the P1-5 path memo, ×N events/task). The buffered
707
+ // variants below collapse adjacent appends into ONE appendFileSync per flush.
708
+ // Flush triggers: task boundary (child-executor finally), process exit / signal,
709
+ // 32-event cap, or the 250ms window timer — whichever comes first.
710
+ //
711
+ // CRASH-WINDOW TRADEOFF (deliberate, documented): buffering widens the SIGKILL
712
+ // loss window for AGENT PROGRESS events to <=250ms (AGENT_RECORD_BUFFER_WINDOW_MS
713
+ // below) instead of 0. Precedent: the event-log coalescer already accepts a
714
+ // documented 50ms loss window for non-terminal task.progress events; agent
715
+ // events.jsonl/output.log lines are the same class of re-derivable progress
716
+ // telemetry — authoritative terminal state lives in tasks.json/agents.json,
717
+ // which stay on their existing durable/coalesced-atomic paths. Durable
718
+ // artifacts (result.md, steering, run-level events.jsonl) are NOT routed
719
+ // through this buffer.
720
+ //
721
+ // SEQUENCE-NUMBER CORRECTNESS: seqs are RESERVED from nextAgentEventSeq() at
722
+ // the first buffered event and incremented in memory, so buffered events can
723
+ // never collide with each other, and the post-flush seq cache + sidecar are
724
+ // updated with the last reserved seq exactly like a direct append. The direct
725
+ // appendCrewAgentEvent()/appendCrewAgentOutput() flush any pending buffer for
726
+ // the task BEFORE appending, so a mixed buffered/direct caller can neither
727
+ // double-allocate a seq nor land a higher-seq line before lower-seq buffered
728
+ // lines. Within pi-crew a task's sinks have exactly one writer at a time
729
+ // (child-executor OR live-executor, never both), so that guard is
730
+ // defense-in-depth, not a load-bearing lock.
731
+ //
732
+ // v0.9.26 lesson (event-log.ts:237,416-425): this buffer is for the AGENT
733
+ // record sinks ONLY — core run events.jsonl appends must NOT be routed
734
+ // through buffered machinery.
735
+ const AGENT_RECORD_BUFFER_MAX_EVENTS = 32;
736
+ const AGENT_RECORD_BUFFER_WINDOW_MS = 250;
737
+
738
+ interface AgentRecordBuffer {
739
+ manifest: TeamRunManifest;
740
+ taskId: string;
741
+ events: string[];
742
+ output: string[];
743
+ lastReservedSeq: number; // 0 = not yet reserved from disk state
744
+ timer?: NodeJS.Timeout;
745
+ }
746
+ const agentRecordBuffers = new Map<string, AgentRecordBuffer>();
747
+
748
+ function agentRecordBufferKey(manifest: TeamRunManifest, taskId: string): string {
749
+ return `${manifest.stateRoot}\0${safeAgentTaskId(taskId)}`;
750
+ }
751
+
752
+ function flushAgentRecordBuffer(buffer: AgentRecordBuffer): void {
753
+ const { manifest, taskId } = buffer;
754
+ if (buffer.timer) {
755
+ clearTimeout(buffer.timer);
756
+ buffer.timer = undefined;
757
+ }
758
+ if (buffer.events.length > 0) {
759
+ const filePath = agentStateFile(manifest, taskId, "events.jsonl");
760
+ try {
761
+ fs.appendFileSync(filePath, buffer.events.join(""), "utf-8");
762
+ // Mirror appendCrewAgentEvent's post-append bookkeeping so subsequent
763
+ // nextAgentEventSeq calls (direct or buffered) see the reserved seqs.
764
+ const stat = fs.statSync(filePath);
765
+ setAgentEventSeqCache(filePath, {
766
+ size: stat.size,
767
+ mtimeMs: stat.mtimeMs,
768
+ seq: buffer.lastReservedSeq,
769
+ });
770
+ writeSeqToSidecar(filePath, buffer.lastReservedSeq);
771
+ } catch (error) {
772
+ // Best-effort progress telemetry: a fs failure here (e.g. run dir
773
+ // deleted by prune/forget) must not fail the task; drop the batch.
774
+ logInternalError("crew-agent-records.buffered-events-flush", error, `filePath=${filePath}`);
775
+ }
776
+ }
777
+ if (buffer.output.length > 0) {
778
+ try {
779
+ fs.appendFileSync(agentStateFile(manifest, taskId, "output.log"), buffer.output.join(""), "utf-8");
780
+ } catch (error) {
781
+ logInternalError("crew-agent-records.buffered-output-flush", error, `taskId=${taskId}`);
782
+ }
783
+ }
784
+ buffer.events.length = 0;
785
+ buffer.output.length = 0;
786
+ buffer.lastReservedSeq = 0;
787
+ agentRecordBuffers.delete(agentRecordBufferKey(manifest, taskId));
788
+ }
789
+
790
+ /**
791
+ * Flush any pending buffered agent-record appends for `taskId` synchronously.
792
+ * Task-boundary hook — child-executor calls this at attempt completion/failure
793
+ * and process exit so no buffered line outlives the task.
794
+ */
795
+ export function flushCrewAgentRecordBuffer(manifest: TeamRunManifest, taskId: string): void {
796
+ const buffer = agentRecordBuffers.get(agentRecordBufferKey(manifest, taskId));
797
+ if (buffer) flushAgentRecordBuffer(buffer);
798
+ }
799
+
800
+ /** @internal Flush ALL buffered agent-record writes (process-exit / signal hook). */
801
+ export function flushAllCrewAgentRecordBuffers(): void {
802
+ for (const buffer of [...agentRecordBuffers.values()]) flushAgentRecordBuffer(buffer);
803
+ }
804
+
805
+ export function appendCrewAgentEventBuffered(manifest: TeamRunManifest, taskId: string, event: unknown): void {
806
+ const filePath = agentStateFile(manifest, taskId, "events.jsonl");
807
+ const key = agentRecordBufferKey(manifest, taskId);
808
+ let buffer = agentRecordBuffers.get(key);
809
+ if (!buffer) {
810
+ buffer = { manifest, taskId, events: [], output: [], lastReservedSeq: 0 };
811
+ agentRecordBuffers.set(key, buffer);
812
+ }
813
+ if (buffer.lastReservedSeq === 0) {
814
+ // Reserve the next seq ONCE from the on-disk state; further buffered
815
+ // events increment in memory (no per-event stat/sidecar read).
816
+ buffer.lastReservedSeq = nextAgentEventSeq(filePath) - 1;
817
+ }
818
+ const seq = ++buffer.lastReservedSeq;
819
+ buffer.events.push(`${JSON.stringify(redactSecrets({ seq, time: new Date().toISOString(), event }))}\n`);
820
+ scheduleAgentRecordBufferFlush(buffer);
821
+ }
822
+
823
+ export function appendCrewAgentOutputBuffered(manifest: TeamRunManifest, taskId: string, text: string): void {
824
+ if (!text.trim()) return;
825
+ // Ensure the state dir at BUFFER time (the event variant gets this for free
826
+ // via agentStateFile): fs.appendFileSync at flush time does NOT create parent
827
+ // dirs, so an output-only task with no events buffered yet would drop its
828
+ // whole batch on ENOENT. Memoized (ensuredAgentDirs) — one mkdir per task,
829
+ // not per line — preserving the R10-5 batching win.
830
+ ensureAgentStateDir(manifest, taskId);
831
+ const key = agentRecordBufferKey(manifest, taskId);
832
+ let buffer = agentRecordBuffers.get(key);
833
+ if (!buffer) {
834
+ buffer = { manifest, taskId, events: [], output: [], lastReservedSeq: 0 };
835
+ agentRecordBuffers.set(key, buffer);
836
+ }
837
+ buffer.output.push(`${redactSecretString(text)}\n`);
838
+ scheduleAgentRecordBufferFlush(buffer);
839
+ }
840
+
841
+ function scheduleAgentRecordBufferFlush(buffer: AgentRecordBuffer): void {
842
+ // Cap: flush now so buffer memory and the crash window stay bounded.
843
+ if (buffer.events.length >= AGENT_RECORD_BUFFER_MAX_EVENTS || buffer.output.length >= AGENT_RECORD_BUFFER_MAX_EVENTS) {
844
+ flushAgentRecordBuffer(buffer);
845
+ return;
846
+ }
847
+ // Window: a REAL (unref'd) timer enforces the <=250ms loss-window bound even
848
+ // when no further events arrive (lazy check-on-next-append would not).
849
+ if (!buffer.timer) {
850
+ buffer.timer = setTimeout(() => {
851
+ buffer.timer = undefined;
852
+ flushAgentRecordBuffer(buffer);
853
+ }, AGENT_RECORD_BUFFER_WINDOW_MS);
854
+ buffer.timer.unref();
855
+ }
856
+ }
857
+
858
+ // Defense-in-depth (mirrors atomic-write.ts): land buffered agent-record lines
859
+ // on normal process exit and best-effort on termination signals.
860
+ /** @internal Test-only: number of pending buffered agent-record batches. */
861
+ export function __test__agentRecordBufferCount(): number {
862
+ return agentRecordBuffers.size;
863
+ }
864
+
865
+ process.on("exit", () => flushAllCrewAgentRecordBuffers());
866
+ process.on("SIGTERM", () => setImmediate(() => flushAllCrewAgentRecordBuffers()));
867
+ process.on("SIGINT", () => setImmediate(() => flushAllCrewAgentRecordBuffers()));
868
+
624
869
  export function emptyCrewAgentProgress(): CrewAgentProgress {
625
870
  return { recentTools: [], recentOutput: [], toolCount: 0 };
626
871
  }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * delegate-spawn.ts — root-side grandchild spawner for governed nesting
3
+ * (ADR-5 §1/§2/§6, WP-5 step 5).
4
+ *
5
+ * The broker's `delegate.request` handler calls this on an admitted request:
6
+ * a DIRECT `runChildPi` call-site that never touches the global worker
7
+ * semaphore (the MAJ#3 anti-deadlock precedent generalized — the nested-slot
8
+ * budget is managed by the broker handler, not here), with `depthOverride`
9
+ * from the parent task record, namespaced artifacts, and a timeout-driven
10
+ * soft cancel (`delegate-timeout`).
11
+ *
12
+ * Security review round 1 (T3):
13
+ * - S1#2: the grandchild's ROLE is threaded so role-based tool restrictions
14
+ * apply (`--tools`/`--exclude-tools` from role-tools config) — a
15
+ * "read-only" explorer/analyst grandchild must actually be read-only
16
+ * (ADR-5 §9's serialization exemption depends on it).
17
+ * - S1#1: the grandchild authenticates under its OWN subId identity
18
+ * (grandchild-scoped broker token pre-minted by the handler) — never the
19
+ * parent's task token. Identity fields (`agentId: subId`) match.
20
+ * - S2#1: namespaced artifacts via the `artifactsRoot` input field (the env
21
+ * spread never survived the spawn allowlist).
22
+ * - S2#3: usage roll-up — message_end usage events are accumulated from the
23
+ * child's JSON event stream and reported for the parent-task reconciliation.
24
+ */
25
+
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import type { AgentConfig } from "../agents/agent-config.ts";
29
+ import { type ChildPiRunResult, runChildPi } from "./child-pi/child-pi.ts";
30
+
31
+ export interface GrandchildSpawnInput {
32
+ cwd: string;
33
+ runId: string;
34
+ parentTaskId: string;
35
+ /** Nested-slot holder id (minted by the broker handler). */
36
+ subId: string;
37
+ prompt: string;
38
+ role: string;
39
+ model?: string;
40
+ maxTurns?: number;
41
+ /** Mandatory default 900 (spawn-policy normalized). */
42
+ timeoutSec: number;
43
+ /** Parent-task-record depth + 1 (ADR-5 §3). */
44
+ depthOverride: number;
45
+ /** Manifest eventsPath — threads PI_CREW_STATE_ROOT so the grandchild's
46
+ * ask tool + scratchpad metrics work (stateRoot = dirname(eventsPath)). */
47
+ eventsPath?: string;
48
+ /** Grandchild-scoped broker credentials (S1#1 — NEVER the parent token). */
49
+ brokerSpawn?: { socketPath: string; token: string };
50
+ onSpawn?: (pid: number | null) => void;
51
+ }
52
+
53
+ export interface GrandchildSpawnResult {
54
+ ok: boolean;
55
+ /** Fenced-ish result text (caller fences before mailbox delivery). */
56
+ resultText: string;
57
+ /** Usage in tokens for roll-up (input+output+cache summed over
58
+ * message_end events); undefined = unattributed. */
59
+ usageTokens?: number;
60
+ timedOut?: boolean;
61
+ }
62
+
63
+ /** Minimal in-memory agent config for a delegate grandchild role. */
64
+ function agentForRole(role: string): AgentConfig {
65
+ return {
66
+ name: role,
67
+ description: `delegate grandchild (${role})`,
68
+ source: "builtin",
69
+ filePath: "",
70
+ systemPrompt: "",
71
+ };
72
+ }
73
+
74
+ /** Sum usage tokens from a message_end JSON event record (S2#3 roll-up). */
75
+ export function usageTokensFromEvent(event: unknown): number | undefined {
76
+ if (!event || typeof event !== "object" || Array.isArray(event)) return undefined;
77
+ const record = event as { type?: unknown; usage?: unknown; message?: { usage?: unknown } };
78
+ if (record.type !== "message_end") return undefined;
79
+ const usage = (record.usage ?? record.message?.usage) as Record<string, unknown> | undefined;
80
+ if (!usage || typeof usage !== "object") return undefined;
81
+ let total = 0;
82
+ let seen = false;
83
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"]) {
84
+ const value = usage[key];
85
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
86
+ total += value;
87
+ seen = true;
88
+ }
89
+ }
90
+ return seen ? total : undefined;
91
+ }
92
+
93
+ /** Namespaced artifacts root: artifacts/<runId>/<parentTaskId>/nested/<subId>/ */
94
+ export function grandchildArtifactsRoot(cwd: string, runId: string, parentTaskId: string, subId: string): string {
95
+ return path.join(cwd, ".crew", "artifacts", runId, parentTaskId, "nested", subId);
96
+ }
97
+
98
+ export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Promise<GrandchildSpawnResult> {
99
+ const artifactsRoot = path.join(input.cwd, ".crew", "artifacts", input.runId, input.parentTaskId, "nested", input.subId);
100
+ fs.mkdirSync(artifactsRoot, { recursive: true });
101
+
102
+ const abort = new AbortController();
103
+ const timer = setTimeout(() => abort.abort(), input.timeoutSec * 1000);
104
+ timer.unref?.();
105
+
106
+ // S2#3: accumulate grandchild usage from message_end events.
107
+ let usageTokens: number | undefined;
108
+ const onJsonEvent = (event: unknown): void => {
109
+ const tokens = usageTokensFromEvent(event);
110
+ if (tokens !== undefined) usageTokens = (usageTokens ?? 0) + tokens;
111
+ };
112
+
113
+ try {
114
+ const result: ChildPiRunResult = await runChildPi({
115
+ cwd: input.cwd,
116
+ task: input.prompt,
117
+ agent: agentForRole(input.role),
118
+ // S1#2: thread the role so role-based tool restrictions apply.
119
+ role: input.role,
120
+ ...(input.model !== undefined ? { model: input.model } : {}),
121
+ ...(input.maxTurns !== undefined ? { maxTurns: input.maxTurns } : {}),
122
+ depthOverride: input.depthOverride,
123
+ runId: input.runId,
124
+ // S1#1: the grandchild carries its OWN identity + credentials —
125
+ // never the parent's task token.
126
+ agentId: input.subId,
127
+ ...(input.brokerSpawn ? { brokerSpawn: input.brokerSpawn } : {}),
128
+ ...(input.eventsPath ? { eventsPath: input.eventsPath } : {}),
129
+ // S2#1: namespaced artifacts via the typed field.
130
+ artifactsRoot,
131
+ signal: abort.signal,
132
+ onJsonEvent,
133
+ onSpawn: input.onSpawn,
134
+ });
135
+ const ok = result.exitCode === 0;
136
+ const text = (result.rawFinalText ?? result.stdout ?? "").trim() || (ok ? "" : result.stderr.trim());
137
+ return { ok, resultText: text, ...(usageTokens !== undefined ? { usageTokens } : {}) };
138
+ } catch (error) {
139
+ const timedOut = abort.signal.aborted;
140
+ return {
141
+ ok: false,
142
+ timedOut,
143
+ resultText: timedOut ? "[delegate timed out]" : `delegate spawn failed: ${(error as Error).message}`,
144
+ };
145
+ } finally {
146
+ clearTimeout(timer);
147
+ }
148
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Result delivery for runs detached by an agent-view switch.
3
+ *
4
+ * Opening an agent view mid-run releases the foreground waiter (see
5
+ * `detachRunPromise`), so the parent turn settles with a "detached" tool
6
+ * result and the run keeps executing. Without this module the run's FINAL
7
+ * result would never reach the conversation: the async notifier only toasts,
8
+ * and its toast is suppressed while a view session is active (different
9
+ * session id) or when the run finished before the operator returned to main.
10
+ *
11
+ * The registry is in-process; a pending result is parked while an agent view is
12
+ * open so the worker's own view session never receives the parent's report.
13
+ */
14
+ import { loadRunManifestById } from "../state/stores/state-store.ts";
15
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
16
+ import { isFinishedRunStatus } from "./process-status.ts";
17
+
18
+ interface DetachedRun {
19
+ runId: string;
20
+ cwd: string;
21
+ }
22
+
23
+ const detachedRuns = new Map<string, DetachedRun>();
24
+
25
+ /** Record a run whose foreground waiter was released by a view switch. */
26
+ export function markRunDetached(runId: string, cwd: string): void {
27
+ if (!runId || !cwd) return;
28
+ detachedRuns.set(runId, { runId, cwd });
29
+ }
30
+
31
+ /** Cheap guard for hot paths (render tick): nothing to do when empty. */
32
+ export function hasDetachedRuns(): boolean {
33
+ return detachedRuns.size > 0;
34
+ }
35
+
36
+ export function forgetDetachedRun(runId: string): void {
37
+ detachedRuns.delete(runId);
38
+ }
39
+
40
+ export function clearDetachedRunsForTest(): void {
41
+ detachedRuns.clear();
42
+ }
43
+
44
+ /** Human-readable completion summary for a detached run. */
45
+ export function formatDetachedRunResult(manifest: TeamRunManifest, tasks: TeamTaskState[]): string {
46
+ const lines = [
47
+ `pi-crew run ${manifest.status}: ${manifest.runId} (${manifest.team}/${manifest.workflow ?? "none"})`,
48
+ `Goal: ${manifest.goal}`,
49
+ "",
50
+ "This run was detached when you opened an agent view; it finished in the background.",
51
+ ];
52
+ if (tasks.length > 0) {
53
+ lines.push("", "Tasks:");
54
+ for (const task of tasks) {
55
+ const detail = task.error ? ` — ${task.error}` : "";
56
+ lines.push(` • ${task.id} (${task.role ?? "?"}): ${task.status}${detail}`);
57
+ }
58
+ }
59
+ if (manifest.artifactsRoot) lines.push("", `Artifacts: ${manifest.artifactsRoot}`);
60
+ lines.push(`State: ${manifest.stateRoot}`);
61
+ return lines.join("\n");
62
+ }
63
+
64
+ /**
65
+ * Results of finished detached runs that may be shown in the CURRENT session.
66
+ * Entries are NOT removed: the caller drops each one with `forgetDetachedRun`
67
+ * only after the delivery actually succeeded, so a failed send retries on the
68
+ * next tick instead of losing the run's outcome.
69
+ *
70
+ * Gating is by "am I inside an agent view", not by session id: the report must
71
+ * never land inside a worker's view session, but matching the owner's id is too
72
+ * brittle (the main session can be replaced/renamed while the view is open, and
73
+ * the registry is per pi process anyway, so any non-view session here IS the
74
+ * operator's own).
75
+ */
76
+ export function peekFinishedDetachedRunResults(options: { inViewSession?: boolean } = {}): { runId: string; text: string }[] {
77
+ if (detachedRuns.size === 0 || options.inViewSession) return [];
78
+ const ready: { runId: string; text: string }[] = [];
79
+ for (const entry of [...detachedRuns.values()]) {
80
+ const loaded = loadRunManifestById(entry.cwd, entry.runId);
81
+ if (!loaded) {
82
+ // Run state vanished (pruned/deleted) — nothing left to report.
83
+ detachedRuns.delete(entry.runId);
84
+ continue;
85
+ }
86
+ if (!isFinishedRunStatus(loaded.manifest.status)) continue;
87
+ ready.push({ runId: entry.runId, text: formatDetachedRunResult(loaded.manifest, loaded.tasks) });
88
+ }
89
+ return ready;
90
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { parse } from "acorn";
21
+ import { getCrewEnv } from "../config/env-vars.ts";
21
22
 
22
23
  const NONDETERMINISM_ERROR =
23
24
  "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable. These introduce non-reproducible behavior across runs. Use ctx.vars for cached state, or pass a fixed seed via ctx.setArgs(). To bypass this check (escape hatch), set PI_CREW_DWF_SKIP_DETERMINISM_CHECK=1.";
@@ -57,7 +58,7 @@ export function assertDeterministicScript(script: string): void {
57
58
  * (e.g. randomized benchmark scripts).
58
59
  */
59
60
  export function isDeterminismCheckEnabled(): boolean {
60
- return process.env.PI_CREW_DWF_SKIP_DETERMINISM_CHECK !== "1";
61
+ return getCrewEnv("PI_CREW_DWF_SKIP_DETERMINISM_CHECK") !== "1";
61
62
  }
62
63
 
63
64
  // ---------------------------------------------------------------------------