pi-crew 0.9.53 → 0.9.55

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 (493) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +6 -3
  3. package/scripts/clean-strip-types.mjs +60 -0
  4. package/src/adapters/claude-adapter.ts +23 -0
  5. package/src/adapters/codex-adapter.ts +21 -0
  6. package/src/adapters/cursor-adapter.ts +17 -0
  7. package/src/adapters/export-util.ts +143 -0
  8. package/src/adapters/index.ts +15 -0
  9. package/src/adapters/registry.ts +18 -0
  10. package/src/adapters/types.ts +23 -0
  11. package/src/agents/agent-config.ts +194 -0
  12. package/src/agents/agent-search.ts +98 -0
  13. package/src/agents/agent-serializer.ts +38 -0
  14. package/src/agents/discover-agents.ts +623 -0
  15. package/src/benchmark/benchmark-runner.ts +313 -0
  16. package/src/benchmark/feedback-loop.ts +73 -0
  17. package/src/config/config.ts +1270 -0
  18. package/src/config/defaults.ts +193 -0
  19. package/src/config/drift-detector.ts +290 -0
  20. package/src/config/markers.ts +330 -0
  21. package/src/config/resilient-parser.ts +117 -0
  22. package/src/config/role-tools.ts +118 -0
  23. package/src/config/suggestions.ts +75 -0
  24. package/src/config/types.ts +280 -0
  25. package/src/errors.ts +194 -0
  26. package/src/extension/action-suggestions.ts +117 -0
  27. package/src/extension/async-notifier.ts +179 -0
  28. package/src/extension/autonomous-policy.ts +209 -0
  29. package/src/extension/command-completions.ts +120 -0
  30. package/src/extension/context-status-injection.ts +185 -0
  31. package/src/extension/crew-autocomplete.ts +131 -0
  32. package/src/extension/crew-cleanup.ts +168 -0
  33. package/src/extension/crew-input-router.ts +93 -0
  34. package/src/extension/crew-shortcuts.ts +87 -0
  35. package/src/extension/crew-vibes/cat-frames.ts +18 -0
  36. package/src/extension/crew-vibes/config.ts +195 -0
  37. package/src/extension/crew-vibes/figures.ts +101 -0
  38. package/src/extension/crew-vibes/font-detect.ts +71 -0
  39. package/src/extension/crew-vibes/footer.ts +292 -0
  40. package/src/extension/crew-vibes/index.ts +432 -0
  41. package/src/extension/crew-vibes/provider-usage.ts +396 -0
  42. package/src/extension/crew-vibes/render.ts +206 -0
  43. package/src/extension/crew-vibes/speed.ts +286 -0
  44. package/src/extension/cross-extension-rpc.ts +350 -0
  45. package/src/extension/help.ts +62 -0
  46. package/src/extension/import-index.ts +74 -0
  47. package/src/extension/knowledge-injection.ts +402 -0
  48. package/src/extension/management.ts +571 -0
  49. package/src/extension/message-renderers.ts +108 -0
  50. package/src/extension/notification-router.ts +152 -0
  51. package/src/extension/notification-sink.ts +54 -0
  52. package/src/extension/pi-api.ts +56 -0
  53. package/src/extension/plan-orchestrate.ts +302 -0
  54. package/src/extension/project-init.ts +170 -0
  55. package/src/extension/register.ts +113 -0
  56. package/src/extension/registration/artifact-cleanup.ts +20 -0
  57. package/src/extension/registration/command-registration.ts +58 -0
  58. package/src/extension/registration/command-utils.ts +58 -0
  59. package/src/extension/registration/commands.ts +1228 -0
  60. package/src/extension/registration/compaction-guard.ts +333 -0
  61. package/src/extension/registration/context-builder.ts +135 -0
  62. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  63. package/src/extension/registration/foreground-run-controller.ts +238 -0
  64. package/src/extension/registration/hook-registration.ts +119 -0
  65. package/src/extension/registration/lazy-configurers.ts +124 -0
  66. package/src/extension/registration/lifecycle-handlers.ts +923 -0
  67. package/src/extension/registration/lifecycle.ts +259 -0
  68. package/src/extension/registration/observability.ts +322 -0
  69. package/src/extension/registration/registration-types.ts +158 -0
  70. package/src/extension/registration/runtime-cleanup.ts +230 -0
  71. package/src/extension/registration/subagent-helpers.ts +125 -0
  72. package/src/extension/registration/subagent-manager-setup.ts +330 -0
  73. package/src/extension/registration/subagent-tools.ts +456 -0
  74. package/src/extension/registration/team-tool.ts +225 -0
  75. package/src/extension/registration/tool-registration.ts +50 -0
  76. package/src/extension/registration/ui.ts +172 -0
  77. package/src/extension/registration/viewers.ts +121 -0
  78. package/src/extension/registration/wire-cross-extension.ts +37 -0
  79. package/src/extension/result-watcher.ts +139 -0
  80. package/src/extension/rpc-hmac.ts +256 -0
  81. package/src/extension/run-bundle-schema.ts +104 -0
  82. package/src/extension/run-export.ts +97 -0
  83. package/src/extension/run-import.ts +168 -0
  84. package/src/extension/run-index.ts +131 -0
  85. package/src/extension/run-maintenance.ts +190 -0
  86. package/src/extension/session-summary.ts +18 -0
  87. package/src/extension/team-manager-command.ts +153 -0
  88. package/src/extension/team-onboard.ts +180 -0
  89. package/src/extension/team-recommendation.ts +288 -0
  90. package/src/extension/team-tool/anchor.ts +172 -0
  91. package/src/extension/team-tool/api.ts +1244 -0
  92. package/src/extension/team-tool/auto-summarize.ts +142 -0
  93. package/src/extension/team-tool/cache-control.ts +19 -0
  94. package/src/extension/team-tool/cancel.ts +337 -0
  95. package/src/extension/team-tool/chain-dispatch.ts +95 -0
  96. package/src/extension/team-tool/chain-executor.ts +379 -0
  97. package/src/extension/team-tool/config-patch.ts +50 -0
  98. package/src/extension/team-tool/context.ts +149 -0
  99. package/src/extension/team-tool/destructive-gate.ts +52 -0
  100. package/src/extension/team-tool/dispatch/automate.ts +80 -0
  101. package/src/extension/team-tool/dispatch/control.ts +42 -0
  102. package/src/extension/team-tool/dispatch/index.ts +95 -0
  103. package/src/extension/team-tool/dispatch/manage.ts +161 -0
  104. package/src/extension/team-tool/dispatch/run.ts +53 -0
  105. package/src/extension/team-tool/dispatch/status.ts +187 -0
  106. package/src/extension/team-tool/doctor.ts +431 -0
  107. package/src/extension/team-tool/explain.ts +282 -0
  108. package/src/extension/team-tool/failure-patterns.ts +118 -0
  109. package/src/extension/team-tool/goal-wrap.ts +330 -0
  110. package/src/extension/team-tool/goal.ts +556 -0
  111. package/src/extension/team-tool/handle-schedule.ts +332 -0
  112. package/src/extension/team-tool/handle-settings.ts +520 -0
  113. package/src/extension/team-tool/health-monitor.ts +481 -0
  114. package/src/extension/team-tool/inspect.ts +88 -0
  115. package/src/extension/team-tool/intent-policy.ts +45 -0
  116. package/src/extension/team-tool/lifecycle-actions.ts +615 -0
  117. package/src/extension/team-tool/orchestrate.ts +98 -0
  118. package/src/extension/team-tool/parallel-dispatch.ts +183 -0
  119. package/src/extension/team-tool/plan.ts +50 -0
  120. package/src/extension/team-tool/respond.ts +150 -0
  121. package/src/extension/team-tool/run-deadline.ts +66 -0
  122. package/src/extension/team-tool/run-not-found.ts +49 -0
  123. package/src/extension/team-tool/run.ts +1051 -0
  124. package/src/extension/team-tool/status.ts +260 -0
  125. package/src/extension/team-tool/workflow-manage.ts +261 -0
  126. package/src/extension/team-tool-types.ts +26 -0
  127. package/src/extension/team-tool.ts +750 -0
  128. package/src/extension/tool-result.ts +16 -0
  129. package/src/extension/validate-resources.ts +108 -0
  130. package/src/hooks/registry.ts +200 -0
  131. package/src/hooks/types.ts +69 -0
  132. package/src/i18n.ts +212 -0
  133. package/src/observability/correlation.ts +50 -0
  134. package/src/observability/event-bus.ts +86 -0
  135. package/src/observability/event-to-metric.ts +170 -0
  136. package/src/observability/exporters/adapter.ts +27 -0
  137. package/src/observability/exporters/otlp-exporter.ts +356 -0
  138. package/src/observability/exporters/prometheus-exporter.ts +54 -0
  139. package/src/observability/metric-registry.ts +100 -0
  140. package/src/observability/metric-retention.ts +64 -0
  141. package/src/observability/metric-sink.ts +99 -0
  142. package/src/observability/metrics-primitives.ts +219 -0
  143. package/src/plugins/plugin-define.ts +6 -0
  144. package/src/plugins/plugin-registry.ts +32 -0
  145. package/src/plugins/plugins/index.ts +3 -0
  146. package/src/plugins/plugins/nextjs.ts +19 -0
  147. package/src/plugins/plugins/vite.ts +10 -0
  148. package/src/plugins/plugins/vitest.ts +9 -0
  149. package/src/prompt/prompt-runtime.ts +380 -0
  150. package/src/runtime/adaptive-plan.ts +563 -0
  151. package/src/runtime/agent-control.ts +215 -0
  152. package/src/runtime/agent-memory.ts +81 -0
  153. package/src/runtime/agent-observability.ts +122 -0
  154. package/src/runtime/anchor-manager.ts +475 -0
  155. package/src/runtime/async-marker.ts +32 -0
  156. package/src/runtime/async-runner.ts +381 -0
  157. package/src/runtime/attention-events.ts +28 -0
  158. package/src/runtime/auto-summarize.ts +344 -0
  159. package/src/runtime/background-runner.ts +840 -0
  160. package/src/runtime/batch-barrier.ts +147 -0
  161. package/src/runtime/broker-issuer.ts +39 -0
  162. package/src/runtime/cancellation-token.ts +99 -0
  163. package/src/runtime/cancellation.ts +99 -0
  164. package/src/runtime/capability-inventory.ts +137 -0
  165. package/src/runtime/chain-parser.ts +237 -0
  166. package/src/runtime/chain-runner.ts +606 -0
  167. package/src/runtime/checkpoint.ts +291 -0
  168. package/src/runtime/child-pi-constants.ts +42 -0
  169. package/src/runtime/child-pi-kill.ts +180 -0
  170. package/src/runtime/child-pi-pool.ts +68 -0
  171. package/src/runtime/child-pi-spawn.ts +287 -0
  172. package/src/runtime/child-pi-steering.ts +128 -0
  173. package/src/runtime/child-pi-streams.ts +296 -0
  174. package/src/runtime/child-pi-transcript.ts +169 -0
  175. package/src/runtime/child-pi.ts +1105 -0
  176. package/src/runtime/coalesce-tasks.ts +268 -0
  177. package/src/runtime/code-summary.ts +292 -0
  178. package/src/runtime/command-trace.ts +105 -0
  179. package/src/runtime/compact-pipeline.ts +56 -0
  180. package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
  181. package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
  182. package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
  183. package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
  184. package/src/runtime/compact-stages/index.ts +23 -0
  185. package/src/runtime/compact-stages/tail-capture-stage.ts +77 -0
  186. package/src/runtime/compact-stages/truncation-stage.ts +74 -0
  187. package/src/runtime/compaction-summary.ts +278 -0
  188. package/src/runtime/completion-guard.ts +207 -0
  189. package/src/runtime/concurrency.ts +58 -0
  190. package/src/runtime/crash-classification.ts +229 -0
  191. package/src/runtime/crash-recovery.ts +594 -0
  192. package/src/runtime/crew-agent-records.ts +595 -0
  193. package/src/runtime/crew-agent-runtime.ts +62 -0
  194. package/src/runtime/crew-broker-child.ts +88 -0
  195. package/src/runtime/crew-broker-client.ts +673 -0
  196. package/src/runtime/crew-broker-tokens.ts +159 -0
  197. package/src/runtime/crew-broker.ts +1304 -0
  198. package/src/runtime/crew-hooks.ts +227 -0
  199. package/src/runtime/cross-extension-rpc.ts +143 -0
  200. package/src/runtime/custom-tools/irc-tool.ts +282 -0
  201. package/src/runtime/custom-tools/submit-result-tool.ts +102 -0
  202. package/src/runtime/deadletter.ts +47 -0
  203. package/src/runtime/delivery-coordinator.ts +211 -0
  204. package/src/runtime/delta-conflict.ts +348 -0
  205. package/src/runtime/deterministic-ast.ts +161 -0
  206. package/src/runtime/diagnostic-export.ts +171 -0
  207. package/src/runtime/direct-run.ts +45 -0
  208. package/src/runtime/dwf-state-store.ts +102 -0
  209. package/src/runtime/dynamic-workflow-context.ts +1013 -0
  210. package/src/runtime/dynamic-workflow-runner.ts +325 -0
  211. package/src/runtime/effectiveness.ts +90 -0
  212. package/src/runtime/errors/crew-errors.ts +162 -0
  213. package/src/runtime/event-stream-bridge.ts +98 -0
  214. package/src/runtime/foreground-control.ts +193 -0
  215. package/src/runtime/foreground-watchdog.ts +132 -0
  216. package/src/runtime/global-worker-cap.ts +96 -0
  217. package/src/runtime/goal-achievement.ts +148 -0
  218. package/src/runtime/goal-evaluator.ts +365 -0
  219. package/src/runtime/goal-loop-runner.ts +825 -0
  220. package/src/runtime/goal-state-store.ts +219 -0
  221. package/src/runtime/green-contract.ts +55 -0
  222. package/src/runtime/group-join.ts +267 -0
  223. package/src/runtime/handoff-manager.ts +598 -0
  224. package/src/runtime/heartbeat-gradient.ts +36 -0
  225. package/src/runtime/heartbeat-watcher.ts +239 -0
  226. package/src/runtime/hidden-handoff.ts +407 -0
  227. package/src/runtime/important-line-classifier.ts +140 -0
  228. package/src/runtime/intercom-bridge.ts +187 -0
  229. package/src/runtime/iteration-hooks.ts +305 -0
  230. package/src/runtime/live-agent-control.ts +136 -0
  231. package/src/runtime/live-agent-manager.ts +704 -0
  232. package/src/runtime/live-control-realtime.ts +56 -0
  233. package/src/runtime/live-extension-bridge.ts +148 -0
  234. package/src/runtime/live-irc.ts +97 -0
  235. package/src/runtime/live-session-health.ts +108 -0
  236. package/src/runtime/live-session-runtime.ts +1146 -0
  237. package/src/runtime/loop-gates.ts +128 -0
  238. package/src/runtime/manifest-cache.ts +375 -0
  239. package/src/runtime/mcp-proxy.ts +105 -0
  240. package/src/runtime/metric-parser.ts +36 -0
  241. package/src/runtime/model-fallback.ts +409 -0
  242. package/src/runtime/model-resolver.ts +126 -0
  243. package/src/runtime/model-scope.ts +158 -0
  244. package/src/runtime/orphan-worker-registry.ts +465 -0
  245. package/src/runtime/output-validator.ts +202 -0
  246. package/src/runtime/overflow-recovery.ts +206 -0
  247. package/src/runtime/parallel-research.ts +68 -0
  248. package/src/runtime/parallel-utils.ts +160 -0
  249. package/src/runtime/parent-guard.ts +134 -0
  250. package/src/runtime/path-overlap.ts +150 -0
  251. package/src/runtime/peer-dep.ts +292 -0
  252. package/src/runtime/per-write-validator.ts +181 -0
  253. package/src/runtime/phase-progress.ts +217 -0
  254. package/src/runtime/phase-tracker.ts +385 -0
  255. package/src/runtime/pi-args.ts +655 -0
  256. package/src/runtime/pi-json-output.ts +170 -0
  257. package/src/runtime/pi-spawn.ts +273 -0
  258. package/src/runtime/pipeline-runner.ts +523 -0
  259. package/src/runtime/plan-templates.ts +201 -0
  260. package/src/runtime/policy-engine.ts +115 -0
  261. package/src/runtime/post-checks.ts +142 -0
  262. package/src/runtime/post-exit-stdio-guard.ts +109 -0
  263. package/src/runtime/process-lifecycle.ts +491 -0
  264. package/src/runtime/process-status.ts +154 -0
  265. package/src/runtime/progress-event-coalescer.ts +44 -0
  266. package/src/runtime/progress-tracker.ts +124 -0
  267. package/src/runtime/prose-compressor.ts +162 -0
  268. package/src/runtime/recovery-recipes.ts +203 -0
  269. package/src/runtime/replace.ts +570 -0
  270. package/src/runtime/resilient-edit.ts +153 -0
  271. package/src/runtime/result-extractor.ts +217 -0
  272. package/src/runtime/retry-executor.ts +109 -0
  273. package/src/runtime/retry-runner.ts +336 -0
  274. package/src/runtime/role-permission.ts +59 -0
  275. package/src/runtime/run-coalesced-task-group.ts +308 -0
  276. package/src/runtime/run-drift.ts +219 -0
  277. package/src/runtime/run-tracker.ts +116 -0
  278. package/src/runtime/run-worker.ts +78 -0
  279. package/src/runtime/runtime-policy.ts +29 -0
  280. package/src/runtime/runtime-resolver.ts +172 -0
  281. package/src/runtime/runtime-warmup.ts +160 -0
  282. package/src/runtime/scheduler.ts +353 -0
  283. package/src/runtime/semaphore.ts +141 -0
  284. package/src/runtime/sensitive-paths.ts +93 -0
  285. package/src/runtime/session-resources.ts +25 -0
  286. package/src/runtime/session-snapshot.ts +59 -0
  287. package/src/runtime/session-usage.ts +79 -0
  288. package/src/runtime/settings-store.ts +155 -0
  289. package/src/runtime/sidechain-output.ts +35 -0
  290. package/src/runtime/single-agent-compose.ts +84 -0
  291. package/src/runtime/skill-effectiveness.ts +524 -0
  292. package/src/runtime/skill-instructions.ts +394 -0
  293. package/src/runtime/stale-reconciler.ts +680 -0
  294. package/src/runtime/stream-preview.ts +184 -0
  295. package/src/runtime/streaming-output.ts +50 -0
  296. package/src/runtime/subagent-manager.ts +561 -0
  297. package/src/runtime/subprocess-tool-registry.ts +70 -0
  298. package/src/runtime/supervisor-contact.ts +64 -0
  299. package/src/runtime/task-display.ts +52 -0
  300. package/src/runtime/task-graph-scheduler.ts +227 -0
  301. package/src/runtime/task-graph.ts +290 -0
  302. package/src/runtime/task-health.ts +83 -0
  303. package/src/runtime/task-id.ts +161 -0
  304. package/src/runtime/task-output-context.ts +602 -0
  305. package/src/runtime/task-packet.ts +268 -0
  306. package/src/runtime/task-quality.ts +199 -0
  307. package/src/runtime/task-runner/capabilities.ts +78 -0
  308. package/src/runtime/task-runner/child-executor.ts +790 -0
  309. package/src/runtime/task-runner/context-retrieval.ts +163 -0
  310. package/src/runtime/task-runner/live-executor.ts +227 -0
  311. package/src/runtime/task-runner/output-splitter.ts +152 -0
  312. package/src/runtime/task-runner/post-execution.ts +480 -0
  313. package/src/runtime/task-runner/pre-execution.ts +365 -0
  314. package/src/runtime/task-runner/progress.ts +157 -0
  315. package/src/runtime/task-runner/prompt-builder.ts +278 -0
  316. package/src/runtime/task-runner/prompt-pipeline.ts +88 -0
  317. package/src/runtime/task-runner/result-utils.ts +16 -0
  318. package/src/runtime/task-runner/retrieval-orchestrator.ts +310 -0
  319. package/src/runtime/task-runner/run-projection.ts +125 -0
  320. package/src/runtime/task-runner/scaffold-executor.ts +25 -0
  321. package/src/runtime/task-runner/state-helpers.ts +176 -0
  322. package/src/runtime/task-runner/tail-read.ts +34 -0
  323. package/src/runtime/task-runner.ts +216 -0
  324. package/src/runtime/team-runner-artifacts.ts +13 -0
  325. package/src/runtime/team-runner.ts +2396 -0
  326. package/src/runtime/tool-output-pruner.ts +333 -0
  327. package/src/runtime/tool-progress.ts +278 -0
  328. package/src/runtime/usage-tracker.ts +73 -0
  329. package/src/runtime/verification-gates.ts +579 -0
  330. package/src/runtime/verification-integrity.ts +108 -0
  331. package/src/runtime/verification-worktree.ts +186 -0
  332. package/src/runtime/worker-heartbeat.ts +25 -0
  333. package/src/runtime/worker-startup.ts +83 -0
  334. package/src/runtime/workflow-state.ts +195 -0
  335. package/src/runtime/workspace-lock.ts +443 -0
  336. package/src/runtime/workspace-tree.ts +312 -0
  337. package/src/runtime/yield-handler.ts +224 -0
  338. package/src/runtime/zombie-scanner.ts +298 -0
  339. package/src/schema/config-schema.ts +327 -0
  340. package/src/schema/team-tool-schema.ts +492 -0
  341. package/src/schema/validation-types.ts +159 -0
  342. package/src/skills/discover-skills.ts +203 -0
  343. package/src/skills/skill-templates.ts +456 -0
  344. package/src/skills/validate.ts +285 -0
  345. package/src/state/active-run-registry.ts +439 -0
  346. package/src/state/artifact-store.ts +176 -0
  347. package/src/state/atomic-write.ts +970 -0
  348. package/src/state/blob-store.ts +308 -0
  349. package/src/state/contracts.ts +160 -0
  350. package/src/state/crew-init.ts +269 -0
  351. package/src/state/decision-ledger.ts +372 -0
  352. package/src/state/event-log-rotation.ts +399 -0
  353. package/src/state/event-log.ts +1465 -0
  354. package/src/state/event-reconstructor.ts +229 -0
  355. package/src/state/gitignore-manager.ts +46 -0
  356. package/src/state/health-store.ts +79 -0
  357. package/src/state/hook-instinct-bridge.ts +94 -0
  358. package/src/state/hook-integrations.ts +51 -0
  359. package/src/state/instinct-store.ts +275 -0
  360. package/src/state/jsonl-writer.ts +107 -0
  361. package/src/state/locks.ts +562 -0
  362. package/src/state/mailbox.ts +916 -0
  363. package/src/state/observation-store.ts +176 -0
  364. package/src/state/run-cache.ts +193 -0
  365. package/src/state/run-graph.ts +183 -0
  366. package/src/state/run-metrics.ts +165 -0
  367. package/src/state/schedule.ts +166 -0
  368. package/src/state/session-state-map.ts +51 -0
  369. package/src/state/state-store.ts +973 -0
  370. package/src/state/task-claims.ts +61 -0
  371. package/src/state/tiered-eval.ts +480 -0
  372. package/src/state/types-eval.ts +58 -0
  373. package/src/state/types.ts +447 -0
  374. package/src/state/usage.ts +138 -0
  375. package/src/state/worker-atomic-writer.ts +198 -0
  376. package/src/subagents/async-entry.ts +1 -0
  377. package/src/subagents/index.ts +3 -0
  378. package/src/subagents/live/control.ts +1 -0
  379. package/src/subagents/live/manager.ts +1 -0
  380. package/src/subagents/live/realtime.ts +1 -0
  381. package/src/subagents/live/session-runtime.ts +1 -0
  382. package/src/subagents/manager.ts +1 -0
  383. package/src/subagents/spawn.ts +1 -0
  384. package/src/teams/discover-teams.ts +187 -0
  385. package/src/teams/team-config.ts +27 -0
  386. package/src/teams/team-serializer.ts +38 -0
  387. package/src/tools/safe-bash-extension.ts +54 -0
  388. package/src/tools/safe-bash.ts +505 -0
  389. package/src/types/diff.d.ts +18 -0
  390. package/src/types/new-api-types.ts +35 -0
  391. package/src/ui/agent-management-overlay.ts +160 -0
  392. package/src/ui/card-colors.ts +139 -0
  393. package/src/ui/crew-footer.ts +102 -0
  394. package/src/ui/crew-select-list.ts +112 -0
  395. package/src/ui/dashboard-panes/agents-pane.ts +164 -0
  396. package/src/ui/dashboard-panes/cancellation-pane.ts +66 -0
  397. package/src/ui/dashboard-panes/capability-pane.ts +77 -0
  398. package/src/ui/dashboard-panes/health-pane.ts +31 -0
  399. package/src/ui/dashboard-panes/mailbox-pane.ts +35 -0
  400. package/src/ui/dashboard-panes/metrics-pane.ts +37 -0
  401. package/src/ui/dashboard-panes/progress-pane.ts +38 -0
  402. package/src/ui/dashboard-panes/transcript-pane.ts +10 -0
  403. package/src/ui/deploy-bundled-themes.ts +71 -0
  404. package/src/ui/dwf-phase-display.ts +151 -0
  405. package/src/ui/dynamic-border.ts +35 -0
  406. package/src/ui/format-helpers.ts +31 -0
  407. package/src/ui/heartbeat-aggregator.ts +74 -0
  408. package/src/ui/key-utils.ts +42 -0
  409. package/src/ui/keybinding-map.ts +248 -0
  410. package/src/ui/layout-primitives.ts +106 -0
  411. package/src/ui/live-conversation-overlay.ts +183 -0
  412. package/src/ui/live-duration.ts +55 -0
  413. package/src/ui/live-run-sidebar.ts +293 -0
  414. package/src/ui/loaders.ts +6 -0
  415. package/src/ui/mascot.ts +449 -0
  416. package/src/ui/overlays/agent-picker-overlay.ts +66 -0
  417. package/src/ui/overlays/confirm-overlay.ts +60 -0
  418. package/src/ui/overlays/help-overlay.ts +177 -0
  419. package/src/ui/overlays/mailbox-compose-overlay.ts +181 -0
  420. package/src/ui/overlays/mailbox-compose-preview.ts +78 -0
  421. package/src/ui/overlays/mailbox-detail-overlay.ts +155 -0
  422. package/src/ui/pi-ui-compat.ts +68 -0
  423. package/src/ui/powerbar-publisher.ts +459 -0
  424. package/src/ui/render-coalescer.ts +101 -0
  425. package/src/ui/render-diff.ts +160 -0
  426. package/src/ui/render-scheduler.ts +278 -0
  427. package/src/ui/run-action-dispatcher.ts +182 -0
  428. package/src/ui/run-dashboard.ts +910 -0
  429. package/src/ui/run-event-bus.ts +381 -0
  430. package/src/ui/run-snapshot-cache.ts +1057 -0
  431. package/src/ui/settings-overlay.ts +1067 -0
  432. package/src/ui/shared-overlay-scheduler.ts +96 -0
  433. package/src/ui/snapshot-types.ts +90 -0
  434. package/src/ui/spinner.ts +17 -0
  435. package/src/ui/status-colors.ts +143 -0
  436. package/src/ui/syntax-highlight.ts +136 -0
  437. package/src/ui/terminal-status.ts +269 -0
  438. package/src/ui/theme-adapter.ts +271 -0
  439. package/src/ui/theme-discovery.ts +199 -0
  440. package/src/ui/tool-progress-formatter.ts +93 -0
  441. package/src/ui/tool-renderers/brief-mode.ts +225 -0
  442. package/src/ui/tool-renderers/index.ts +726 -0
  443. package/src/ui/transcript-cache.ts +128 -0
  444. package/src/ui/transcript-entries.ts +256 -0
  445. package/src/ui/transcript-viewer.ts +424 -0
  446. package/src/ui/widget/index.ts +418 -0
  447. package/src/ui/widget/widget-formatters.ts +182 -0
  448. package/src/ui/widget/widget-model.ts +110 -0
  449. package/src/ui/widget/widget-renderer.ts +182 -0
  450. package/src/ui/widget/widget-types.ts +36 -0
  451. package/src/utils/bm25-search.ts +235 -0
  452. package/src/utils/completion-dedupe.ts +63 -0
  453. package/src/utils/conflict-detect.ts +668 -0
  454. package/src/utils/env-allowlist.ts +30 -0
  455. package/src/utils/env-filter.ts +155 -0
  456. package/src/utils/file-coalescer.ts +98 -0
  457. package/src/utils/fingerprint.ts +180 -0
  458. package/src/utils/frontmatter.ts +70 -0
  459. package/src/utils/fs-watch.ts +37 -0
  460. package/src/utils/gh-protocol.ts +556 -0
  461. package/src/utils/git.ts +260 -0
  462. package/src/utils/guards.ts +107 -0
  463. package/src/utils/ids.ts +24 -0
  464. package/src/utils/incremental-reader.ts +220 -0
  465. package/src/utils/internal-error.ts +10 -0
  466. package/src/utils/names.ts +36 -0
  467. package/src/utils/ndjson.ts +115 -0
  468. package/src/utils/paths.ts +227 -0
  469. package/src/utils/project-detector.ts +160 -0
  470. package/src/utils/redaction.ts +370 -0
  471. package/src/utils/resolve-shell.ts +36 -0
  472. package/src/utils/run-watcher-registry.ts +152 -0
  473. package/src/utils/safe-paths.ts +393 -0
  474. package/src/utils/scan-cache.ts +144 -0
  475. package/src/utils/session-utils.ts +110 -0
  476. package/src/utils/sleep.ts +54 -0
  477. package/src/utils/socket-path.ts +164 -0
  478. package/src/utils/sse-parser.ts +131 -0
  479. package/src/utils/task-name-generator.ts +337 -0
  480. package/src/utils/timings.ts +33 -0
  481. package/src/utils/token-counter.ts +200 -0
  482. package/src/utils/visual.ts +207 -0
  483. package/src/workflows/cost-estimator.ts +34 -0
  484. package/src/workflows/discover-workflows.ts +308 -0
  485. package/src/workflows/intermediate-store.ts +166 -0
  486. package/src/workflows/preflight-validator.ts +168 -0
  487. package/src/workflows/topology-analyzer.ts +203 -0
  488. package/src/workflows/validate-workflow.ts +50 -0
  489. package/src/workflows/workflow-config.ts +75 -0
  490. package/src/workflows/workflow-serializer.ts +32 -0
  491. package/src/worktree/branch-freshness.ts +112 -0
  492. package/src/worktree/cleanup.ts +341 -0
  493. package/src/worktree/worktree-manager.ts +1204 -0
@@ -0,0 +1,1105 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { AgentConfig } from "../agents/agent-config.ts";
6
+ import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
7
+ import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
8
+ import { atomicWriteFile } from "../state/atomic-write.ts";
9
+ import type { WorkerExitStatus } from "../state/types.ts";
10
+ import { logInternalError } from "../utils/internal-error.ts";
11
+ import { redactSecretString } from "../utils/redaction.ts";
12
+ import { getActiveBrokerIssuer } from "./broker-issuer.ts";
13
+ import { FINAL_DRAIN_MS, HARD_KILL_MS, POST_EXIT_STDIO_GUARD_MS, RESPONSE_TIMEOUT_MS } from "./child-pi-constants.ts";
14
+ import { appendBoundedTail, clearHardKillTimer, killProcessTree, registerActiveChild, unregisterActiveChild } from "./child-pi-kill.ts";
15
+ import { buildFinalChildPiSpawnOptions, prepareSpawnContext } from "./child-pi-spawn.ts";
16
+ import { ChildPiSteeringController } from "./child-pi-steering.ts";
17
+ // Internal helpers for active-child bookkeeping (extracted to child-pi-kill.ts).
18
+ import { ChildPiLineObserver } from "./child-pi-streams.ts";
19
+
20
+ // ── Re-exports from child-pi-kill.ts (H-7 decomposition step 2) ──
21
+ // killProcessTree is internal (not previously exported) — keep that invariant.
22
+ export {
23
+ killProcessPid,
24
+ terminateActiveChildPiProcesses,
25
+ } from "./child-pi-kill.ts";
26
+ // ── Re-export from child-pi-spawn.ts (H-7 decomposition step 6) ──
27
+ // buildChildPiSpawnOptions was previously exported from child-pi.ts. Keep the
28
+ // public API surface stable by re-exporting from the new module.
29
+ // buildFinalChildPiSpawnOptions (BLOCKER 2 / S5) — composed spawn helper that
30
+ // owns the canary + filter + spread sequence; lives in child-pi-spawn.ts.
31
+ export { buildChildPiSpawnOptions, buildFinalChildPiSpawnOptions } from "./child-pi-spawn.ts";
32
+ // ── Re-export from child-pi-streams.ts (H-7 decomposition step 4) ──
33
+ export { ChildPiLineObserver } from "./child-pi-streams.ts";
34
+
35
+ import { classifyProcessCrash } from "./crash-classification.ts";
36
+ import { checkCrewDepth, cleanupTempDir } from "./pi-args.ts";
37
+
38
+ import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
39
+
40
+ /** Maximum size (bytes) for the ChildPiLineObserver's line accumulation buffer.
41
+ * When exceeded, the buffer is force-flushed to prevent unbounded memory growth
42
+ * from chatty child processes that produce output without newlines.
43
+ * (Constant moved to child-pi-constants.ts.) */
44
+
45
+ // Periodic cleanup of dead child process entries to prevent memory leaks.
46
+ /**
47
+ * SEC-1: Extract a redacted stderr/stdout excerpt for embedding in lifecycle
48
+ * events and error messages. The in-memory stdout/stderr accumulators receive
49
+ * RAW worker output (only structurally compacted via compactChildPiEvent —
50
+ * NOT secret-redacted), so any slice embedded into a persisted event must be
51
+ * redacted here. Otherwise worker-emitted secrets (API keys, tokens returned
52
+ * from a tool call) leak through diagnostic logs that bypass artifact-store
53
+ * redaction.
54
+ *
55
+ * Extracted as a single helper (8 call sites were duplicating this) so the
56
+ * redaction boundary is unit-testable directly. The real spawn error/timeout
57
+ * paths are integration-level and NOT reachable via PI_TEAMS_MOCK_CHILD_PI
58
+ * (the mock returns before the lifecycle-event handlers run), so a behavior
59
+ * test must target this helper rather than the full runChildPi path.
60
+ */
61
+ export function redactStderrExcerpt(stderr: string, maxChars: number): string {
62
+ return redactSecretString(stderr.slice(-maxChars));
63
+ }
64
+
65
+ /**
66
+ * B6: spawn taskkill and attach an 'error' listener. spawn() emits ENOENT/EACCES
67
+ * asynchronously via the 'error' event (not as a throw), so an unlistened spawn
68
+ * can crash the parent as an uncaught exception. taskkill is a standard Windows
69
+ * binary so this is defensive, but the listener keeps failures bounded.
70
+ */
71
+
72
+ /** Structured lifecycle event emitted by child-pi for critical transitions. */
73
+ export interface ChildPiLifecycleEvent {
74
+ /** Event discriminator. */
75
+ type: "spawned" | "spawn_error" | "response_timeout" | "final_drain" | "hard_kill" | "exit" | "close";
76
+ /** Process ID when available. */
77
+ pid?: number;
78
+ /** Exit code for exit/close events. */
79
+ exitCode?: number | null;
80
+ /** Error message for error events. */
81
+ error?: string;
82
+ /** Stderr captured at timeout moment (for response_timeout events). */
83
+ stderr?: string;
84
+ /** Last N chars of stderr for error context (exit/error events). */
85
+ stderrExcerpt?: string;
86
+ /** Timestamp (ISO). */
87
+ ts: string;
88
+ /** F12: optional cause for `final_drain` events. `"stdout-quiet"` indicates
89
+ * the drain was triggered by the quiet-window early-exit rather than the
90
+ * default 5 s ceiling. Other drain reasons (default) leave this undefined. */
91
+ reason?: "stdout-quiet";
92
+ /** Phase-0 diagnostic (HB-003a): the signal that killed the child (when
93
+ * available). Was previously discarded after building the error string. */
94
+ signal?: string;
95
+ /** Phase-0 diagnostic (HB-003a): final-drain race timing, present only on
96
+ * exit events where a drain timer was armed. Surfaces the exit-null race. */
97
+ diagnostic?: {
98
+ finalDrainArmed: boolean;
99
+ forcedFinalDrain: boolean;
100
+ finalDrainFiredMonotonicMs?: number;
101
+ finalAssistantEventMonotonicMs?: number;
102
+ exitMonotonicMs: number;
103
+ };
104
+ }
105
+
106
+ export interface ChildPiRunInput {
107
+ cwd: string;
108
+ task: string;
109
+ agent: AgentConfig;
110
+ model?: string;
111
+ skillPaths?: string[];
112
+ signal?: AbortSignal;
113
+ transcriptPath?: string;
114
+ onStdoutLine?: (line: string) => void;
115
+ onJsonEvent?: (event: unknown) => void;
116
+ onSpawn?: (pid: number) => void;
117
+ /** Structured lifecycle events for durable logging (spawn, crash, timeout, kill, exit). */
118
+ onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
119
+ maxDepth?: number;
120
+ finalDrainMs?: number;
121
+ /** F12: early-exit the drain when stdout has been silent for this many ms
122
+ * after the final assistant event. Set to ≥ finalDrainMs to disable. */
123
+ finalDrainQuietMs?: number;
124
+ hardKillMs?: number;
125
+ responseTimeoutMs?: number;
126
+ /** Soft limit on assistant turns — inject steer at this count. */
127
+ maxTurns?: number;
128
+ /** Extra turns after soft limit before hard abort. Default: 5. */
129
+ graceTurns?: number;
130
+ /** Parent conversation context to inherit when inheritContext is true. */
131
+ parentContext?: string;
132
+ /** When true, prepend parentContext to the task prompt. */
133
+ inheritContext?: boolean;
134
+ /** Pass to pi to mark certain commands as context-excluded. Default: false */
135
+ excludeContextBash?: boolean;
136
+ /** pi session ID for session naming (aligns with pi-crew run ID) */
137
+ sessionId?: string;
138
+ /** Path to steering JSONL file for real-time steer injection. */
139
+ steeringFile?: string;
140
+ /** Run ID for cleanup tracking */
141
+ runId?: string;
142
+ /** Agent ID for cleanup tracking */
143
+ agentId?: string;
144
+ /** Role for tool restrictions (from role-tools.ts) */
145
+ role?: string;
146
+ /** Root directory for artifacts (used to validate transcriptPath). */
147
+ artifactsRoot?: string;
148
+ /**
149
+ * Optional broker spawn context (Phase 0 inter-pi broker). When present,
150
+ * `prepareSpawnContext` injects `PI_CREW_BROKER_SOCKET` and
151
+ * `PI_CREW_BROKER_TOKEN` into the child env (control-namespace keys,
152
+ * accepted by `assertOnlyControlEnvKeys`). The token is a 128-bit random
153
+ * value issued by the parent's CrewBroker at spawn time; it lives only
154
+ * in the parent's in-memory `Map<runId, token>` and in the child's env.
155
+ * NEVER persisted to manifest, events, mailbox, or any run-dir file.
156
+ */
157
+ brokerSpawn?: { socketPath: string; token: string };
158
+ /**
159
+ * Optional Phase 0 broker credentials issuer. Called only when
160
+ * `brokerSpawn` is not already set on the input. The default no-op
161
+ * issuer returns undefined, leaving the child to run without broker
162
+ * credentials (today's behavior — file paths remain authoritative).
163
+ * The production wiring passes a closure that delegates to the
164
+ * session's `CrewBrokerLifecycleController.issueForChild`.
165
+ */
166
+ brokerIssuer?: (runId: string, taskId?: string) => Promise<{ socketPath: string; token: string } | undefined>;
167
+ }
168
+
169
+ export interface ChildPiRunResult {
170
+ exitCode: number | null;
171
+ stdout: string;
172
+ stderr: string;
173
+ error?: string;
174
+ /** RAW (uncapped) final assistant text, captured at stream-parse time BEFORE
175
+ * the 16K transcript compaction. This is the AUTHORITATIVE worker output —
176
+ * it becomes results/<id>.txt so downstream dependencies are not bounded by
177
+ * the transcript's telemetry cap. Undefined when no assistant text was seen
178
+ * (mock paths, error paths) — callers MUST fall back to transcript-derived
179
+ * finalText. See research-findings/output-handling-deep-dive.md §A. */
180
+ rawFinalText?: string;
181
+ exitStatus?: WorkerExitStatus;
182
+ /** True if the agent was hard-aborted (max_turns + grace exceeded). */
183
+ aborted?: boolean;
184
+ /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
185
+ steered?: boolean;
186
+ /** #7 hardening: bounded digest of intermediate findings (last N tool results or
187
+ * assistant text lines) from the run. Populated by ChildPiLineObserver so that
188
+ * workers that exhaust their budget on tool calls (never emit final assistant
189
+ * text) still produce a non-empty result. Consumers should prefer rawFinalText
190
+ * first — this is a last-resort fallback. */
191
+ intermediateFindings?: string;
192
+ }
193
+
194
+ // Base allowlist of non-provider env vars always passed to child workers.
195
+ // ── Transcript batching + compaction (H-7 decomposition step 1) ────────
196
+ // Extracted to ./child-pi-transcript.ts. Re-exported here to preserve the
197
+ // existing public API surface.
198
+ export {
199
+ appendTranscript,
200
+ compactString,
201
+ compactValue,
202
+ flushPendingTranscriptWrites,
203
+ resetTranscriptBatchState,
204
+ } from "./child-pi-transcript.ts";
205
+
206
+ /** Mock-only path — real code path reuses a single observer.
207
+ * OPT-06 follow-up: returns a Promise so callers can await the transcript
208
+ * drain before resolving runChildPi. Without this, mock-mode callers that
209
+ * read the transcript file post-run see ENOENT (the async file handle had
210
+ * not yet been opened). */
211
+ async function observeStdoutChunk(input: ChildPiRunInput, text: string): Promise<void> {
212
+ const observer = new ChildPiLineObserver(input);
213
+ observer.observe(text);
214
+ await observer.flush();
215
+ }
216
+
217
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
218
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
219
+ }
220
+
221
+ function isFinalAssistantEvent(event: unknown): boolean {
222
+ const obj = asRecord(event);
223
+ if (obj?.type !== "message_end") return false;
224
+ const message = asRecord(obj.message);
225
+ const role = message?.role;
226
+ if (role !== undefined && role !== "assistant") return false;
227
+ const stopReason =
228
+ typeof message?.stopReason === "string" ? message.stopReason : typeof obj.stopReason === "string" ? obj.stopReason : undefined;
229
+ if (stopReason !== undefined && stopReason !== "stop") return false;
230
+ const content = Array.isArray(message?.content) ? message.content : [];
231
+ return !content.some((part) => asRecord(part)?.type === "toolCall");
232
+ }
233
+
234
+ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
235
+ // Phase 1 (live-session parity): prepend parent context when inheritContext is true.
236
+ // This mirrors the effectivePrompt logic in live-session-runtime.ts so that
237
+ // child-process workers receive the same inherited-context treatment.
238
+ const effectiveTask =
239
+ input.inheritContext === true && input.parentContext
240
+ ? `${input.parentContext}\n\n---\n# Child Worker Task\n${input.task}`
241
+ : input.task;
242
+ const depth = checkCrewDepth(input.maxDepth);
243
+ if (depth.blocked)
244
+ return {
245
+ exitCode: 1,
246
+ stdout: "",
247
+ stderr: `pi-crew depth guard blocked child worker: depth ${depth.depth} >= max ${depth.maxDepth}`,
248
+ };
249
+ const mock = process.env.PI_TEAMS_MOCK_CHILD_PI;
250
+ if (mock) {
251
+ // SECURITY (Issue #2): Mock mode security model is intentionally asymmetric.
252
+ // PI_TEAMS_MOCK_CHILD_PI is in the allowlist (passed to children) but
253
+ // PI_CREW_ALLOW_MOCK is NOT in the allowlist — it is only checked in the
254
+ // parent process scope. This means:
255
+ // (1) If an attacker sets PI_CREW_ALLOW_MOCK in the parent's environment,
256
+ // it will NOT be passed to child processes (safe).
257
+ // (2) Mock mode activation in the child always fails the PI_CREW_ALLOW_MOCK
258
+ // check, so mock mode can only be triggered from the parent process.
259
+ // This asymmetry is intentional: PI_CREW_ALLOW_MOCK must be set in the Pi root
260
+ // process (the entry point that spawns children), not inherited from a parent.
261
+ // Setup hooks cannot inject PI_CREW_ALLOW_MOCK into the parent's env.
262
+ const allowMock = process.env.PI_CREW_ALLOW_MOCK === "1" || process.env.PI_CREW_ALLOW_MOCK === "true";
263
+ if (!allowMock) {
264
+ return {
265
+ exitCode: 1,
266
+ stdout: "",
267
+ stderr: "Mock mode requires PI_CREW_ALLOW_MOCK=1",
268
+ };
269
+ }
270
+ // SECURITY: Log mock mode activation prominently for audit trail
271
+ logInternalError("child-pi.mock", new Error(`Mock mode active: ${mock}`), "NOT running real agents");
272
+ if (mock === "success") {
273
+ const stdout = `[MOCK] Success for ${input.agent.name}\n`;
274
+ await observeStdoutChunk(input, stdout);
275
+ return { exitCode: 0, stdout, stderr: "" };
276
+ }
277
+ if (mock === "json-success" || mock === "adaptive-plan") {
278
+ const text =
279
+ mock === "adaptive-plan" && effectiveTask.includes("ADAPTIVE_PLAN_JSON_START")
280
+ ? `[MOCK] Adaptive plan\nADAPTIVE_PLAN_JSON_START\n${JSON.stringify({
281
+ phases: [
282
+ {
283
+ name: "research",
284
+ tasks: [
285
+ {
286
+ role: "explorer",
287
+ task: "Explore adaptive target",
288
+ },
289
+ {
290
+ role: "analyst",
291
+ task: "Analyze adaptive target",
292
+ },
293
+ {
294
+ role: "planner",
295
+ task: "Plan adaptive target",
296
+ },
297
+ ],
298
+ },
299
+ {
300
+ name: "build",
301
+ tasks: [
302
+ {
303
+ role: "executor",
304
+ task: "Implement adaptive target",
305
+ },
306
+ ],
307
+ },
308
+ {
309
+ name: "check",
310
+ tasks: [
311
+ {
312
+ role: "reviewer",
313
+ task: "Review adaptive target",
314
+ },
315
+ {
316
+ role: "test-engineer",
317
+ task: "Test adaptive target",
318
+ },
319
+ {
320
+ role: "writer",
321
+ task: "Summarize adaptive target",
322
+ },
323
+ ],
324
+ },
325
+ ],
326
+ })}\nADAPTIVE_PLAN_JSON_END`
327
+ : `[MOCK] JSON success for ${input.agent.name}`;
328
+ const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
329
+ await observeStdoutChunk(input, stdout);
330
+ return { exitCode: 0, stdout, stderr: "" };
331
+ }
332
+ if (mock === "retryable-failure")
333
+ return {
334
+ exitCode: 1,
335
+ stdout: "",
336
+ stderr: "[MOCK] rate limit: mock failure",
337
+ };
338
+ // E2E fallback-chain fixture: invocation #1 returns a SILENT retryable
339
+ // failure (exit code 0, no real assistant text, message_end carries a
340
+ // retryable-pattern errorMessage). Invocation #2+ delegates to the
341
+ // standard json-success shape. Counter lives in os.tmpdir() keyed by
342
+ // process.pid + mock name so concurrent test processes don't collide.
343
+ // The test cleans up the file in its finally block.
344
+ if (mock === "retryable-failure-then-success") {
345
+ const counterFile = path.join(os.tmpdir(), `pi-crew-mock-counter-${process.pid}-retryable-failure-then-success`);
346
+ let count = 0;
347
+ try {
348
+ const raw = fs.readFileSync(counterFile, "utf-8");
349
+ const parsed = Number.parseInt(raw.trim(), 10);
350
+ if (Number.isFinite(parsed) && parsed >= 0) count = parsed;
351
+ } catch {
352
+ // file missing or unreadable — first invocation in this process
353
+ }
354
+ count += 1;
355
+ try {
356
+ atomicWriteFile(counterFile, String(count));
357
+ } catch (error) {
358
+ logInternalError("child-pi.mock-counter-write", error as Error, `file=${counterFile}`);
359
+ }
360
+ if (count === 1) {
361
+ // Silent retryable failure: exit 0, no real text, message_end
362
+ // carries errorMessage matching `/provider[_ ]?error/i` so that
363
+ // `detectRetryableModelFailureFromOutput` surfaces it as an error
364
+ // and `isRetryableModelFailure` routes the next attempt to the
365
+ // next candidate model. `stopReason:"error"` (NOT "stop") so
366
+ // `isFinalAssistantEvent` does NOT prematurely terminate the run.
367
+ const failureEvent = {
368
+ type: "message_end",
369
+ message: {
370
+ role: "assistant",
371
+ content: [],
372
+ errorMessage: "Provider error: api_error",
373
+ stopReason: "error",
374
+ },
375
+ };
376
+ const stdout = `${JSON.stringify(failureEvent)}\n`;
377
+ await observeStdoutChunk(input, stdout);
378
+ return { exitCode: 0, stdout, stderr: "" };
379
+ }
380
+ // Subsequent invocations: delegate to json-success shape so the
381
+ // fallback chain's second attempt succeeds and the run completes.
382
+ const text = `[MOCK] JSON success for ${input.agent.name}`;
383
+ const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
384
+ await observeStdoutChunk(input, stdout);
385
+ return { exitCode: 0, stdout, stderr: "" };
386
+ }
387
+ return { exitCode: 1, stdout: "", stderr: `[MOCK] failure: ${mock}` };
388
+ }
389
+ // H-7 step 6: spawn/env/args preparation extracted to child-pi-spawn.ts.
390
+ // prepareSpawnContext builds the worker args, attaches the steering file env,
391
+ // and handles the pre-spawn abort check (returns an immediate-abort result
392
+ // if the parent signal has already fired).
393
+ //
394
+ // Phase 0 broker: if the caller did not pre-fill `brokerSpawn`, ask the
395
+ // optional issuer for one. The issuer is gated by the lifecycle controller
396
+ // (root-session + flag); a no-op issuer yields undefined (no credentials).
397
+ let brokerSpawn = input.brokerSpawn;
398
+ const brokerIssuer = input.brokerIssuer ?? getActiveBrokerIssuer();
399
+ if (!brokerSpawn && brokerIssuer && input.runId) {
400
+ try {
401
+ brokerSpawn = await brokerIssuer(input.runId, input.agentId);
402
+ } catch {
403
+ brokerSpawn = undefined;
404
+ }
405
+ }
406
+ const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...input, brokerSpawn } : input, effectiveTask);
407
+ if (spawnPrep.kind === "aborted") return spawnPrep.result;
408
+ const { spawnSpec, mergedEnv, tempDir, builtEnv } = spawnPrep.ctx;
409
+ try {
410
+ return await new Promise<ChildPiRunResult>((resolve) => {
411
+ // Compose the final SpawnOptions: canary + filter + spread are now
412
+ // owned by buildFinalChildPiSpawnOptions (see child-pi-spawn.ts, BLOCKER 2 / S5).
413
+ const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model);
414
+ const child = spawn(spawnSpec.command, spawnSpec.args, spawnOptions);
415
+ if (child.pid) {
416
+ registerActiveChild(child.pid, child);
417
+ input.onSpawn?.(child.pid);
418
+ input.onLifecycleEvent?.({
419
+ type: "spawned",
420
+ pid: child.pid,
421
+ ts: new Date().toISOString(),
422
+ });
423
+ // Register with cleanup handler for graceful shutdown
424
+ if (input.runId && input.agentId) {
425
+ registerChildProcess(child.pid, input.runId, input.agentId);
426
+ }
427
+ } else {
428
+ input.onLifecycleEvent?.({
429
+ type: "spawn_error",
430
+ error: "spawn returned no pid",
431
+ ts: new Date().toISOString(),
432
+ });
433
+ }
434
+ let stdout = "";
435
+ let stderr = "";
436
+ let settled = false;
437
+ let childExited = false;
438
+ let postExitGuardCleanup: (() => void) | undefined;
439
+ let finalDrainTimer: NodeJS.Timeout | undefined;
440
+ let hardKillTimer: NodeJS.Timeout | undefined;
441
+ let noResponseTimer: NodeJS.Timeout | undefined;
442
+ const finalDrainMs = input.finalDrainMs ?? FINAL_DRAIN_MS;
443
+ const hardKillMs = input.hardKillMs ?? HARD_KILL_MS;
444
+ // Phase-0 diagnostic (HB-003a): track the final-drain race that produces
445
+ // `exit null` for ctx.agent({disableTools:true}). These vars are READ-ONLY
446
+ // instrumentation — no behavior change. finalDrainArmed lets the close
447
+ // handler know a drain timer existed even after clearFinalDrainTimers() ran;
448
+ // spawnMonotonicMs gives us relative timing to distinguish a race from a crash.
449
+ let finalDrainArmed = false;
450
+ // F12: monotonic timestamp of the last stdout JSON event (any event —
451
+ // we want to know when stdout *stopped*, not when the final assistant
452
+ // event arrived). Updated on every onJsonEvent dispatch.
453
+ let lastStdoutActivityMonotonicMs = performance.now();
454
+ let finalDrainFiredMonotonicMs: number | undefined;
455
+ const spawnMonotonicMs = performance.now();
456
+ let finalAssistantEventMonotonicMs: number | undefined;
457
+ // FIX (Round 14): Bound the env-controlled response timeout to
458
+ // [1_000ms, 3_600_000ms] (1s–1h) so a hostile or accidental value
459
+ // (e.g. 1, or 999_999_999) cannot disable the timeout or cause
460
+ // instant kills. Out-of-range values fall back to the input or
461
+ // built-in default.
462
+ const RESPONSE_TIMEOUT_MIN_MS = 1_000;
463
+ const RESPONSE_TIMEOUT_MAX_MS = 3_600_000;
464
+ const responseTimeoutEnv = Number.parseInt(process.env.PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS ?? "", 10);
465
+ const envInRange =
466
+ Number.isFinite(responseTimeoutEnv) &&
467
+ responseTimeoutEnv >= RESPONSE_TIMEOUT_MIN_MS &&
468
+ responseTimeoutEnv <= RESPONSE_TIMEOUT_MAX_MS;
469
+ const responseTimeoutMs = envInRange ? responseTimeoutEnv : (input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS);
470
+ let responseTimeoutHit = false;
471
+ let forcedFinalDrain = false;
472
+ let abortRequested = input.signal?.aborted === true;
473
+ let hardKilled = false;
474
+ const cleanupErrors: string[] = [];
475
+ const steeringController = new ChildPiSteeringController(input.maxTurns, input.graceTurns);
476
+ // Track in-flight operations for proper rejection on unexpected exit
477
+ interface PendingOperation {
478
+ id: string;
479
+ type: "prompt" | "steer" | "json_event";
480
+ startedAt: number;
481
+ }
482
+ const pendingOperations = new Map<string, PendingOperation>();
483
+ let operationIdCounter = 0;
484
+
485
+ const startOperation = (type: PendingOperation["type"]): string => {
486
+ const id = `op-${++operationIdCounter}`;
487
+ pendingOperations.set(id, { id, type, startedAt: Date.now() });
488
+ return id;
489
+ };
490
+
491
+ const completeOperation = (id: string): void => {
492
+ pendingOperations.delete(id);
493
+ };
494
+
495
+ const rejectPendingOperations = (error: Error): void => {
496
+ pendingOperations.forEach((op, id) => {
497
+ logInternalError(
498
+ "child-pi.pending-operation-rejected",
499
+ error,
500
+ `opId=${id} type=${op.type} elapsed=${Date.now() - op.startedAt}ms`,
501
+ );
502
+ });
503
+ pendingOperations.clear();
504
+ };
505
+
506
+ const steerInjectionFailed = false;
507
+ let abortDueToParentSignal = false;
508
+ // CP-1: track whether the turn-limit hard-abort has been initiated. Once
509
+ // true, we must NOT restart the no-response timer — the child is already
510
+ // being killed via killProcessTree (SIGTERM → SIGKILL after 3s), and
511
+ // restarting the timer would delay detection of a SIGTERM-ignoring child.
512
+ // Round 27 (BUG 4): extract to a named handler so settle() can remove it.
513
+ // The previous anonymous listener was never removed → on runs with >10
514
+ // tasks sharing one AbortSignal (background-runner), Node emitted
515
+ // MaxListenersExceededWarning and each leaked listener pinned the task's
516
+ // stack frame (abortDueToParentSignal closure) in memory. { once: true }
517
+ // only auto-removes AFTER the signal fires; on normal completion it leaks.
518
+ const onParentAbort = (): void => {
519
+ abortDueToParentSignal = true;
520
+ };
521
+ input.signal?.addEventListener("abort", onParentAbort, {
522
+ once: true,
523
+ });
524
+ const restartNoResponseTimer = (): void => {
525
+ if (responseTimeoutMs <= 0) return;
526
+ if (noResponseTimer) clearTimeout(noResponseTimer);
527
+ noResponseTimer = setTimeout(() => {
528
+ responseTimeoutHit = true;
529
+ // Capture stderr at timeout moment for debugging
530
+ // SEC-1: redact secrets before embedding in lifecycle event so
531
+ // worker-emitted secrets (API keys etc.) don't bypass redaction.
532
+ const timeoutStderr = redactStderrExcerpt(stderr, 1024); // Last 1KB of stderr (redacted, SEC-1)
533
+ input.onLifecycleEvent?.({
534
+ type: "response_timeout",
535
+ pid: child.pid,
536
+ error: `No output for ${responseTimeoutMs}ms`,
537
+ ts: new Date().toISOString(),
538
+ stderr: timeoutStderr || undefined,
539
+ });
540
+ killProcessTree(child.pid, child);
541
+ try {
542
+ child.kill(process.platform === "win32" ? undefined : "SIGTERM");
543
+ } catch (error) {
544
+ logInternalError("child-pi.response-timeout-term", error, `pid=${child.pid}`);
545
+ }
546
+ // #3 hardening: if the child never exits (zombie) and neither the
547
+ // 'exit' nor 'close' event ever fires, the promise would hang forever.
548
+ // SIGKILL fires ~3s after SIGTERM via hardKillTimer in killProcessPid,
549
+ // but on platforms where SIGKILL also fails (e.g. permission issues),
550
+ // add a bounded safety settle so the promise always resolves. Using
551
+ // hardKillMs + 2s as the safety window: enough for SIGKILL to work
552
+ // normally, but forces settle if the process is truly immortal.
553
+ // NOTE: we do NOT clear hardKillTimer here (that would defeat its purpose);
554
+ // we intentionally add a parallel safety path.
555
+ const SAFETY_SETTLE_MS = HARD_KILL_MS + 2000;
556
+ const safetyTimer = setTimeout(() => {
557
+ if (settled || childExited) return;
558
+ logInternalError(
559
+ "child-pi.settle-safety-fired",
560
+ new Error(`Child did not exit within ${SAFETY_SETTLE_MS}ms of kill; forcing settle`),
561
+ `pid=${child.pid}, responseTimeoutMs=${responseTimeoutMs}`,
562
+ );
563
+ // Verify the child is still alive before forcing settle.
564
+ // If it somehow exited between childExited=false and here, the
565
+ // settled/childExited guard prevents double-settle (harmless but noisy).
566
+ try {
567
+ process.kill(child.pid!, 0);
568
+ // Child still alive — force settle with timeout error.
569
+ const timeoutErr = `Child Pi produced no new output for ${responseTimeoutMs}ms; killed but did not exit within ${SAFETY_SETTLE_MS}ms (possible zombie).`;
570
+ void settle({
571
+ exitCode: null,
572
+ stdout,
573
+ stderr,
574
+ error: timeoutErr,
575
+ exitStatus: {
576
+ exitCode: null,
577
+ cancelled: abortRequested,
578
+ timedOut: true,
579
+ killed: hardKilled,
580
+ cleanupErrors,
581
+ finalDrainMs,
582
+ crashClass: "timeout",
583
+ },
584
+ });
585
+ } catch {
586
+ // ESRCH / EPERM — child is already gone. The 'exit'/'close' handler
587
+ // will fire shortly (or already fired in a race). Let it settle normally.
588
+ }
589
+ }, SAFETY_SETTLE_MS);
590
+ safetyTimer.unref();
591
+ }, responseTimeoutMs);
592
+ noResponseTimer.unref();
593
+ };
594
+ const clearNoResponseTimer = (): void => {
595
+ if (noResponseTimer) clearTimeout(noResponseTimer);
596
+ noResponseTimer = undefined;
597
+ };
598
+ restartNoResponseTimer();
599
+ const lineObserver = new ChildPiLineObserver({
600
+ ...input,
601
+ onStdoutLine: (line) => {
602
+ if (!steeringController.isHardAbortInitiated()) restartNoResponseTimer();
603
+ stdout = appendBoundedTail(stdout, `${line}\n`);
604
+ input.onStdoutLine?.(line);
605
+ },
606
+ onJsonEvent: (event) => {
607
+ if (!steeringController.isHardAbortInitiated()) restartNoResponseTimer();
608
+ const eventOpId = startOperation("json_event");
609
+ try {
610
+ // Turn-count-based steering: soft limit steer + hard abort after graceTurns
611
+ if (event && typeof event === "object" && !Array.isArray(event)) {
612
+ const obj = event as Record<string, unknown>;
613
+ if (obj.type === "turn_end") {
614
+ // H-7 step 5: steering state machine extracted to ChildPiSteeringController.
615
+ const action = steeringController.onTurnEnd(child.pid, child, input.steeringFile);
616
+ if (action.kind === "hardAbort") killProcessTree(action.pid, action.child);
617
+ }
618
+ }
619
+ completeOperation(eventOpId);
620
+ } catch (err) {
621
+ completeOperation(eventOpId);
622
+ throw err;
623
+ }
624
+ // F12: capture monotonic timestamp BEFORE dispatching — any stdout
625
+ // JSON event counts as activity. This lets the quiet-window
626
+ // detection measure "time since last byte of stdout" accurately
627
+ // regardless of what onJsonEvent does.
628
+ lastStdoutActivityMonotonicMs = performance.now();
629
+ input.onJsonEvent?.(event);
630
+ if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
631
+ finalAssistantEventMonotonicMs = performance.now();
632
+ finalDrainArmed = true; // Phase-0 diagnostic: track that a drain timer was created.
633
+ // F12: alongside the 5 s ceiling timer, start a polling watcher
634
+ // that fires the drain early if stdout goes quiet for `quietMs`
635
+ // after the final assistant event. Heavy children that emit a
636
+ // stopReason=stop message_end and then sit idle will exit in
637
+ // ~quietMs (default 800 ms) instead of up to up to 5 s. unref() so
638
+ // the poller never holds the event loop on shutdown.
639
+ // NOTE: The polling watcher is NOT explicitly cleared on process exit.
640
+ // This is safe because: (1) it's unref()'d, so it won't prevent exit;
641
+ // (2) the `settled || childExited` guard at the top prevents firing
642
+ // after the child has exited; (3) sending SIGTERM to an already-
643
+ // exiting process is harmless. The `finalDrainQuietMs` config allows
644
+ // disabling this behavior (set >= finalDrainMs, e.g., 10000).
645
+ const quietMs = input.finalDrainQuietMs ?? DEFAULT_CHILD_PI.finalDrainQuietMs;
646
+ if (quietMs < (input.finalDrainMs ?? DEFAULT_CHILD_PI.finalDrainMs)) {
647
+ const pollHandle = setInterval(() => {
648
+ if (settled || childExited) {
649
+ clearInterval(pollHandle);
650
+ pollHandle.unref();
651
+ return;
652
+ }
653
+ const sinceLast = performance.now() - lastStdoutActivityMonotonicMs;
654
+ if (sinceLast >= quietMs) {
655
+ clearInterval(pollHandle);
656
+ pollHandle.unref();
657
+ // Trigger the same drain path as the 5 s timer:
658
+ // mark forced, fire final_drain lifecycle, SIGTERM.
659
+ forcedFinalDrain = true;
660
+ finalDrainFiredMonotonicMs = performance.now();
661
+ input.onLifecycleEvent?.({
662
+ type: "final_drain",
663
+ pid: child.pid,
664
+ ts: new Date().toISOString(),
665
+ reason: "stdout-quiet",
666
+ });
667
+ try {
668
+ child.kill(process.platform === "win32" ? undefined : "SIGTERM");
669
+ } catch (error) {
670
+ logInternalError("child-pi.quiet-drain-term", error, `pid=${child.pid}`);
671
+ }
672
+ // Mark for hard kill fallback so the existing timer is
673
+ // still reaped if it ever fires later.
674
+ hardKillTimer = setTimeout(() => {
675
+ if (settled || childExited) return;
676
+ try {
677
+ hardKilled = true;
678
+ input.onLifecycleEvent?.({
679
+ type: "hard_kill",
680
+ pid: child.pid,
681
+ ts: new Date().toISOString(),
682
+ });
683
+ child.kill(process.platform === "win32" ? undefined : "SIGKILL");
684
+ } catch (error) {
685
+ logInternalError("child-pi.quiet-drain-hard-kill", error, `pid=${child.pid}`);
686
+ }
687
+ }, hardKillMs);
688
+ hardKillTimer.unref();
689
+ // Cancel the 5 s ceiling so we don't double-fire.
690
+ if (finalDrainTimer) {
691
+ clearTimeout(finalDrainTimer);
692
+ finalDrainTimer = undefined;
693
+ }
694
+ }
695
+ }, 200);
696
+ pollHandle.unref();
697
+ }
698
+ finalDrainTimer = setTimeout(() => {
699
+ if (settled || childExited) return;
700
+ forcedFinalDrain = true;
701
+ finalDrainFiredMonotonicMs = performance.now(); // Phase-0 diagnostic: race timing.
702
+ input.onLifecycleEvent?.({
703
+ type: "final_drain",
704
+ pid: child.pid,
705
+ ts: new Date().toISOString(),
706
+ });
707
+ try {
708
+ child.kill(process.platform === "win32" ? undefined : "SIGTERM");
709
+ } catch (error) {
710
+ logInternalError("child-pi.final-drain-term", error, `pid=${child.pid}`);
711
+ }
712
+ hardKillTimer = setTimeout(() => {
713
+ if (settled || childExited) return;
714
+ try {
715
+ hardKilled = true;
716
+ input.onLifecycleEvent?.({
717
+ type: "hard_kill",
718
+ pid: child.pid,
719
+ ts: new Date().toISOString(),
720
+ });
721
+ child.kill(process.platform === "win32" ? undefined : "SIGKILL");
722
+ } catch (error) {
723
+ logInternalError("child-pi.final-drain-kill", error, `pid=${child.pid}`);
724
+ }
725
+ }, hardKillMs);
726
+ hardKillTimer.unref();
727
+ }, finalDrainMs);
728
+ finalDrainTimer.unref();
729
+ },
730
+ });
731
+
732
+ const clearFinalDrainTimers = (): void => {
733
+ if (finalDrainTimer) clearTimeout(finalDrainTimer);
734
+ if (hardKillTimer) clearTimeout(hardKillTimer);
735
+ finalDrainTimer = undefined;
736
+ hardKillTimer = undefined;
737
+ };
738
+ const clearPostExitGuard = (): void => {
739
+ if (postExitGuardCleanup) {
740
+ postExitGuardCleanup();
741
+ postExitGuardCleanup = undefined;
742
+ }
743
+ };
744
+ const clearChildPiTimeouts = (): void => {
745
+ clearNoResponseTimer();
746
+ clearFinalDrainTimers();
747
+ clearPostExitGuard();
748
+ };
749
+
750
+ const settle = (result: ChildPiRunResult): Promise<void> => {
751
+ if (settled) return Promise.resolve();
752
+ settled = true;
753
+ clearChildPiTimeouts();
754
+ // OPT-06 follow-up: lineObserver.flush() is now async (returns
755
+ // Promise<void>) and drains the module-scoped transcript batch buffer
756
+ // before resolving. We must await it before calling `resolve()`
757
+ // below so callers that read the transcript file post-`runChildPi`
758
+ // see all written lines. Caller invocations of `settle` from
759
+ // sync event handlers (`child.on('close'|'exit'|'error')`,
760
+ // safety timer) use `void settle(...)` — errors are caught and
761
+ // logged inside, and `resolve()` only fires after the drain
762
+ // completes, so runChildPi's outer Promise resolves with a
763
+ // durable transcript on disk.
764
+ return lineObserver
765
+ .flush()
766
+ .then(() => {
767
+ input.signal?.removeEventListener("abort", abort);
768
+ input.signal?.removeEventListener("abort", onParentAbort);
769
+ try {
770
+ cleanupTempDir(tempDir);
771
+ } catch (error) {
772
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
773
+ }
774
+ // Catch all errors from settle to prevent unhandled rejection from propagating
775
+ try {
776
+ resolve({
777
+ ...result,
778
+ rawFinalText: lineObserver.getRawFinalText(),
779
+ intermediateFindings: lineObserver.getIntermediateFindings(),
780
+ exitStatus: result.exitStatus ?? {
781
+ exitCode: result.exitCode,
782
+ cancelled: abortRequested,
783
+ timedOut: responseTimeoutHit,
784
+ killed: hardKilled,
785
+ // Phase-0 diagnostic (HB-003a): surface the final-drain race state.
786
+ // finalDrainArmed lets Phase 1 decide whether a signal-death (exitCode=null)
787
+ // should be treated as a forced final drain. READ-ONLY for now.
788
+ ...(finalDrainArmed || forcedFinalDrain
789
+ ? {
790
+ finalDrainArmed,
791
+ forcedFinalDrain,
792
+ finalDrainFiredMonotonicMs,
793
+ }
794
+ : {}),
795
+ cleanupErrors,
796
+ finalDrainMs,
797
+ },
798
+ });
799
+ } catch (resolveError) {
800
+ logInternalError(
801
+ "child-pi.settle-resolve",
802
+ resolveError,
803
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
804
+ );
805
+ }
806
+ })
807
+ .catch((flushError) => {
808
+ // Drain failed — log and still resolve so runChildPi doesn't hang.
809
+ logInternalError(
810
+ "child-pi.settle-flush-failed",
811
+ flushError,
812
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
813
+ );
814
+ input.signal?.removeEventListener("abort", abort);
815
+ input.signal?.removeEventListener("abort", onParentAbort);
816
+ try {
817
+ cleanupTempDir(tempDir);
818
+ } catch (error) {
819
+ cleanupErrors.push(error instanceof Error ? error.message : String(error));
820
+ }
821
+ try {
822
+ resolve({
823
+ ...result,
824
+ rawFinalText: lineObserver.getRawFinalText(),
825
+ intermediateFindings: lineObserver.getIntermediateFindings(),
826
+ exitStatus: result.exitStatus ?? {
827
+ exitCode: result.exitCode,
828
+ cancelled: abortRequested,
829
+ timedOut: responseTimeoutHit,
830
+ killed: hardKilled,
831
+ ...(finalDrainArmed || forcedFinalDrain
832
+ ? {
833
+ finalDrainArmed,
834
+ forcedFinalDrain,
835
+ finalDrainFiredMonotonicMs,
836
+ }
837
+ : {}),
838
+ cleanupErrors,
839
+ finalDrainMs,
840
+ },
841
+ });
842
+ } catch (resolveError) {
843
+ logInternalError(
844
+ "child-pi.settle-resolve",
845
+ resolveError,
846
+ `result=${JSON.stringify({ exitCode: result.exitCode })}`,
847
+ );
848
+ }
849
+ });
850
+ };
851
+
852
+ const abort = (): void => {
853
+ abortRequested = true;
854
+ clearNoResponseTimer();
855
+ killProcessTree(child.pid, child);
856
+ if (process.platform !== "win32") {
857
+ trySignalChild(child, "SIGTERM");
858
+ }
859
+ try {
860
+ child.kill(process.platform === "win32" ? undefined : "SIGTERM");
861
+ } catch {
862
+ // Ignore kill races.
863
+ }
864
+ // 3.5 — fast-escalate to SIGKILL within 200ms on explicit cancel
865
+ // so /team-cancel completes round-trip well under the operator
866
+ // expectation. The standard finalDrainMs / HARD_KILL_MS paths
867
+ // are for graceful drain, not user-initiated cancel.
868
+ const cancelHardKill = setTimeout(() => {
869
+ if (settled || childExited) return;
870
+ try {
871
+ hardKilled = true;
872
+ child.kill(process.platform === "win32" ? undefined : "SIGKILL");
873
+ } catch (error) {
874
+ logInternalError("child-pi.cancel-fast-kill", error, `pid=${child.pid}`);
875
+ }
876
+ }, 200);
877
+ cancelHardKill.unref();
878
+ };
879
+
880
+ input.signal?.addEventListener("abort", abort, { once: true });
881
+ // 3.1 — soft watermark backpressure. When inbound stdout exceeds
882
+ // 256KB before the next macrotask, pause for 50ms so the line
883
+ // observer + ancillary handlers get to drain. Prevents the runaway
884
+ // case where a chatty child saturates the parent event loop.
885
+ const BACKPRESSURE_HIGH = 256 * 1024;
886
+ let backpressureBytes = 0;
887
+ const releaseBackpressure = (): void => {
888
+ backpressureBytes = 0;
889
+ try {
890
+ child.stdout?.resume();
891
+ } catch {
892
+ /* ignore */
893
+ }
894
+ };
895
+ child.stdout?.on("data", (chunk: Buffer) => {
896
+ if (!steeringController.isHardAbortInitiated()) restartNoResponseTimer();
897
+ const text = chunk.toString("utf-8");
898
+ backpressureBytes += text.length;
899
+ try {
900
+ lineObserver.observe(text);
901
+ } catch (err) {
902
+ logInternalError("child-pi.line-observer-observe", err, `text=${text.slice(0, 100)}`);
903
+ }
904
+ if (backpressureBytes > BACKPRESSURE_HIGH && child.stdout && !child.stdout.isPaused()) {
905
+ try {
906
+ child.stdout.pause();
907
+ } catch {
908
+ /* ignore */
909
+ }
910
+ const timer = setTimeout(releaseBackpressure, 50);
911
+ timer.unref();
912
+ }
913
+ });
914
+ child.stderr?.on("data", (chunk: Buffer) => {
915
+ if (!steeringController.isHardAbortInitiated()) restartNoResponseTimer();
916
+ stderr = appendBoundedTail(stderr, chunk.toString("utf-8"));
917
+ });
918
+ child.on("error", (error) => {
919
+ // Reject pending operations with process error context
920
+ // SEC-1: redact stderr secrets embedded in the error message + excerpt.
921
+ const processError = new Error(
922
+ `Child Pi process error: ${error.message}. Stderr: ${redactStderrExcerpt(stderr, 500) || "(none)"}`,
923
+ );
924
+ rejectPendingOperations(processError);
925
+ try {
926
+ input.onLifecycleEvent?.({
927
+ type: "spawn_error",
928
+ pid: child.pid,
929
+ error: processError.message,
930
+ ts: new Date().toISOString(),
931
+ stderrExcerpt: redactStderrExcerpt(stderr, 500) || undefined,
932
+ });
933
+ } catch (err) {
934
+ logInternalError("child-pi.on-lifecycle-event", err, `event=error, pid=${child.pid}`);
935
+ }
936
+ void settle({
937
+ exitCode: null,
938
+ stdout,
939
+ stderr,
940
+ error: processError.message,
941
+ exitStatus: {
942
+ exitCode: null,
943
+ cancelled: abortRequested,
944
+ timedOut: responseTimeoutHit,
945
+ killed: false,
946
+ cleanupErrors,
947
+ finalDrainMs,
948
+ crashClass: classifyProcessCrash({
949
+ exitCode: null,
950
+ cancelled: abortRequested,
951
+ timedOut: responseTimeoutHit,
952
+ spawnError: error,
953
+ stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined,
954
+ }).crashClass,
955
+ },
956
+ });
957
+ });
958
+ child.on("exit", (code, signal) => {
959
+ if (child.pid) {
960
+ unregisterActiveChild(child.pid);
961
+ clearHardKillTimer(child.pid);
962
+ // Unregister from cleanup handler
963
+ unregisterChildProcess(child.pid);
964
+ }
965
+ // Build comprehensive exit error for unexpected exits
966
+ // Round-10 test fix: also require non-zero exit code OR a known abnormal condition.
967
+ // Previously fired "exited unexpectedly" on every clean exit (code=0) because the
968
+ // OS-level 'exit' event fires BEFORE pi's 'agent_end' JSON event reaches the line
969
+ // observer (race). Worker actually succeeded but onLifecycleEvent reported an error.
970
+ const abnormalExit = code !== 0 && code !== null;
971
+ const isUnexpectedExit = !childExited && !settled && !responseTimeoutHit && !abortRequested && abnormalExit;
972
+ const exitError = isUnexpectedExit
973
+ ? new Error(
974
+ `Child Pi process exited unexpectedly (code=${code ?? "null"} signal=${signal ?? "null"}). ` +
975
+ `Stderr: ${redactStderrExcerpt(stderr, 1000) || "(none)"}`,
976
+ )
977
+ : null;
978
+ if (exitError) {
979
+ rejectPendingOperations(exitError);
980
+ }
981
+ try {
982
+ // Phase-0 diagnostic (HB-003a): capture signal + drain timing in the
983
+ // exit lifecycle event so the exit-null race is diagnosable instead of
984
+ // opaque. `signal` was previously discarded after building the error msg.
985
+ input.onLifecycleEvent?.({
986
+ type: "exit",
987
+ pid: child.pid,
988
+ exitCode: code,
989
+ ts: new Date().toISOString(),
990
+ error: exitError?.message,
991
+ stderrExcerpt: isUnexpectedExit ? redactStderrExcerpt(stderr, 1000) || undefined : undefined,
992
+ // Phase-0 diagnostic fields (kept optional — no type change required).
993
+ ...(signal ? { signal } : {}),
994
+ ...(finalDrainArmed || forcedFinalDrain
995
+ ? {
996
+ diagnostic: {
997
+ finalDrainArmed,
998
+ forcedFinalDrain,
999
+ finalDrainFiredMonotonicMs,
1000
+ finalAssistantEventMonotonicMs,
1001
+ exitMonotonicMs: performance.now() - spawnMonotonicMs,
1002
+ },
1003
+ }
1004
+ : {}),
1005
+ });
1006
+ } catch (err) {
1007
+ logInternalError("child-pi.on-lifecycle-event", err, `event=exit, pid=${child.pid}`);
1008
+ }
1009
+ childExited = true;
1010
+ clearNoResponseTimer();
1011
+ clearFinalDrainTimers();
1012
+ if (!postExitGuardCleanup) {
1013
+ postExitGuardCleanup = attachPostExitStdioGuard(child, {
1014
+ idleMs: POST_EXIT_STDIO_GUARD_MS,
1015
+ hardMs: HARD_KILL_MS,
1016
+ });
1017
+ }
1018
+ });
1019
+ child.on("close", (exitCode) => {
1020
+ if (child.pid) {
1021
+ unregisterActiveChild(child.pid);
1022
+ clearHardKillTimer(child.pid);
1023
+ // Unregister from cleanup handler
1024
+ unregisterChildProcess(child.pid);
1025
+ }
1026
+ try {
1027
+ input.onLifecycleEvent?.({
1028
+ type: "close",
1029
+ pid: child.pid,
1030
+ exitCode,
1031
+ ts: new Date().toISOString(),
1032
+ });
1033
+ } catch (err) {
1034
+ logInternalError("child-pi.on-lifecycle-event", err, `event=close, pid=${child.pid}`);
1035
+ }
1036
+ const timeoutError =
1037
+ responseTimeoutHit && !stderr.trim()
1038
+ ? {
1039
+ error: `Child Pi produced no new output for ${responseTimeoutMs}ms; process was terminated as unresponsive.`,
1040
+ }
1041
+ : responseTimeoutHit && stderr.trim()
1042
+ ? {
1043
+ error: `Child Pi timed out after ${responseTimeoutMs}ms with stderr: ${redactStderrExcerpt(stderr, 500)}`,
1044
+ }
1045
+ : undefined;
1046
+ // M6 fix: log when forced final drain converts non-zero exit to 0.
1047
+ // This is expected in normal operation (child finished cleanly but linger was killed),
1048
+ // but the telemetry helps detect regressions where crashes are hidden.
1049
+ if (forcedFinalDrain && !timeoutError && exitCode !== 0) {
1050
+ logInternalError(
1051
+ "child-pi.final-drain-zero-exit",
1052
+ new Error(`Child exit code overridden to 0 after forced final drain (original=${exitCode})`),
1053
+ `pid=${child.pid}, finalDrainMs=${finalDrainMs}`,
1054
+ );
1055
+ }
1056
+ const finalExitCode = forcedFinalDrain && !timeoutError ? 0 : exitCode;
1057
+ const wasGraceAborted =
1058
+ steeringController.isSoftLimitReached() &&
1059
+ steeringController.getTurnCount() >=
1060
+ (steeringController.getMaxTurns() ?? 0) + (steeringController.getGraceTurns() ?? 5);
1061
+ const wasParentAborted = abortDueToParentSignal && !wasGraceAborted;
1062
+ // steerInjectionFailed is now always false (Phase-1 fix: steer backpressure
1063
+ // is logged, not fatal). The steerError branch is retained for safety in
1064
+ // case a future change reintroduces a fatal steer path.
1065
+ const steerError = steerInjectionFailed ? "Steer injection failed due to stdin backpressure; process killed" : undefined;
1066
+ // P0 crash taxonomy: classify the exit so callers/dashboards can bucket
1067
+ // failure modes (timeout vs cancel vs native panic vs signal …).
1068
+ // The classifier is a pure function; this is the single integration point.
1069
+ const crashClassification = classifyProcessCrash({
1070
+ exitCode: finalExitCode,
1071
+ signal: child.signalCode ?? undefined,
1072
+ cancelled: abortRequested,
1073
+ timedOut: responseTimeoutHit,
1074
+ killed: hardKilled,
1075
+ spawnError: undefined,
1076
+ stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined,
1077
+ });
1078
+ void settle({
1079
+ exitCode: finalExitCode,
1080
+ stdout,
1081
+ stderr,
1082
+ ...(timeoutError ? { error: timeoutError.error } : {}),
1083
+ ...(steerError ? { error: steerError } : {}),
1084
+ aborted: wasGraceAborted || wasParentAborted,
1085
+ steered: steeringController.isSoftLimitReached() && !wasGraceAborted,
1086
+ exitStatus: {
1087
+ exitCode: finalExitCode,
1088
+ cancelled: abortRequested,
1089
+ timedOut: responseTimeoutHit,
1090
+ killed: hardKilled,
1091
+ cleanupErrors,
1092
+ finalDrainMs,
1093
+ crashClass: crashClassification.crashClass,
1094
+ },
1095
+ });
1096
+ });
1097
+ });
1098
+ } finally {
1099
+ // cleanupTempDir is already called inside settle(), but guard against
1100
+ // the case where settle() was never reached (spawn throws synchronously).
1101
+ if (tempDir && fs.existsSync(tempDir)) {
1102
+ cleanupTempDir(tempDir);
1103
+ }
1104
+ }
1105
+ }