tumwater 0.4.0 → 0.4.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 (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -334,18 +334,8 @@ export class PiStreamParser {
334
334
  this.buffer = this.buffer.slice(start);
335
335
  }
336
336
  feedLine(line) {
337
- // Streaming deltas are ~72% of log bytes in observed runs. pi's JSON protocol strips the
338
- // cumulative message snapshot from them, so the delta event is all they carry — and a
339
- // content-free one (a zombie stream's keepalive, or the pre-strip shape) proves nothing:
340
- // it must not reset the hang watchdog. A content-bearing delta is real generation, and
341
- // counting it is what keeps a long thinking block on a slow model from being quiet-killed
342
- // as hung (BUGS.md 2026-10-17) — the same content rule tool_execution_update gets below.
343
- // Nothing else in this parser acts on the type, so the line is done either way.
344
- if (piEventType(line) === "message_update") {
345
- if (messageUpdateHasContent(parseJsonObject(line)))
346
- this.progressCount += 1;
337
+ if (this.applyMessageUpdate(line))
347
338
  return;
348
- }
349
339
  // pi's stream is one JSON object per line: a torn, non-JSON, or valid-JSON-scalar line is
350
340
  // noise, not an event — parseJsonObject reads it as no data (the same policy parseEventLine
351
341
  // applies to the harness event log and parsePiEventLine applies to pi's log lines) instead
@@ -355,6 +345,37 @@ export class PiStreamParser {
355
345
  if (!parsed)
356
346
  return; // Non-JSON noise on stdout; ignore.
357
347
  const event = parsed;
348
+ this.applyErrorTexts(event);
349
+ this.applyRunCost(event);
350
+ this.applyRunUsage(event);
351
+ // A canonical rate_limit event carries an account-window reading: collect the newest per
352
+ // type, note any overage, and treat a rejected window as the transient rate limit it is.
353
+ if (event.type === "rate_limit")
354
+ this.applyRateLimit(event);
355
+ if (event.type === "billing" && (event.billing === "subscription" || event.billing === "api"))
356
+ this.billing = event.billing;
357
+ this.countProgress(event);
358
+ this.applyToolExecution(event);
359
+ if (event.type !== "message_end" || event.message?.role !== "assistant")
360
+ return;
361
+ this.applyAssistantMessage(event.message);
362
+ }
363
+ // Streaming deltas are ~72% of log bytes in observed runs. pi's JSON protocol strips the
364
+ // cumulative message snapshot from them, so the delta event is all they carry — and a
365
+ // content-free one (a zombie stream's keepalive, or the pre-strip shape) proves nothing:
366
+ // it must not reset the hang watchdog. A content-bearing delta is real generation, and
367
+ // counting it is what keeps a long thinking block on a slow model from being quiet-killed
368
+ // as hung (BUGS.md 2026-10-17) — the same content rule tool_execution_update gets below.
369
+ // Nothing else in this parser acts on the type, so the line is done either way.
370
+ applyMessageUpdate(line) {
371
+ if (piEventType(line) === "message_update") {
372
+ if (messageUpdateHasContent(parseJsonObject(line)))
373
+ this.progressCount += 1;
374
+ return true;
375
+ }
376
+ return false;
377
+ }
378
+ applyErrorTexts(event) {
358
379
  // A translator-classified error (claude's `tumwaterKind`) is trusted before the text
359
380
  // regexes: the backend read schemas, not prose, and its signal wins.
360
381
  if (typeof event.tumwaterKind === "string" && event.tumwaterKind) {
@@ -389,19 +410,23 @@ export class PiStreamParser {
389
410
  }
390
411
  }
391
412
  }
392
- // A canonical run_cost is the run's authoritative cost and REPLACES the per-message sum
393
- // (claude's split-block messages report cost.total 0; the final result carries the real
394
- // number). A backend that emits none keeps the summed per-message figure (pi).
413
+ }
414
+ // A canonical run_cost is the run's authoritative cost and REPLACES the per-message sum
415
+ // (claude's split-block messages report cost.total 0; the final result carries the real
416
+ // number). A backend that emits none keeps the summed per-message figure (pi).
417
+ applyRunCost(event) {
395
418
  if (event.type === "run_cost")
396
419
  this.costUsd = usageNumber(event.total);
397
- // Likewise a run_usage replaces the summed per-message token figures (claude's per-message
398
- // usage is the stream-start snapshot, about 8x low). OpenCode's export drives a run_usage
399
- // that carries the run's whole token split — input, output, cacheRead, cacheWrite — and
400
- // replaces all four summed fields, the way run_cost replaces the cost sum; a stream without
401
- // one keeps its summed figures. A run_usage bearing only outputTokens (claude) keeps the
402
- // historical behavior of replacing just the output sum. The split's sum is a cumulative RUN
403
- // total, not a single request, so it never touches peakContextTokens (a per-request peak
404
- // the stream keeps computing); the export carries no total and none is invented here.
420
+ }
421
+ // Likewise a run_usage replaces the summed per-message token figures (claude's per-message
422
+ // usage is the stream-start snapshot, about 8x low). OpenCode's export drives a run_usage
423
+ // that carries the run's whole token split — input, output, cacheRead, cacheWrite — and
424
+ // replaces all four summed fields, the way run_cost replaces the cost sum; a stream without
425
+ // one keeps its summed figures. A run_usage bearing only outputTokens (claude) keeps the
426
+ // historical behavior of replacing just the output sum. The split's sum is a cumulative RUN
427
+ // total, not a single request, so it never touches peakContextTokens (a per-request peak
428
+ // the stream keeps computing); the export carries no total and none is invented here.
429
+ applyRunUsage(event) {
405
430
  if (event.type === "run_usage") {
406
431
  if (event.input !== undefined || event.cacheRead !== undefined || event.cacheWrite !== undefined) {
407
432
  const input = usageNumber(event.input);
@@ -416,26 +441,24 @@ export class PiStreamParser {
416
441
  this.outputTokens = usageNumber(event.outputTokens);
417
442
  }
418
443
  }
419
- // A canonical rate_limit event carries an account-window reading: collect the newest per
420
- // type, note any overage, and treat a rejected window as the transient rate limit it is.
421
- if (event.type === "rate_limit")
422
- this.applyRateLimit(event);
423
- if (event.type === "billing" && (event.billing === "subscription" || event.billing === "api"))
424
- this.billing = event.billing;
425
- // Every structured event (turn/tool/message boundaries, retries, session) is real
426
- // progress, and so is a content-bearing update — but a content-free one never is: bash
427
- // emits an empty tool_execution_update right after start, and a zombie stream's keepalive
428
- // updates must not reset the hang watchdog any more than a content-free message delta
429
- // would (message_update deltas are counted above, before this point, by the same rule).
444
+ }
445
+ // Every structured event (turn/tool/message boundaries, retries, session) is real
446
+ // progress, and so is a content-bearing update — but a content-free one never is: bash
447
+ // emits an empty tool_execution_update right after start, and a zombie stream's keepalive
448
+ // updates must not reset the hang watchdog any more than a content-free message delta
449
+ // would (message_update deltas are counted above, before this point, by the same rule).
450
+ countProgress(event) {
430
451
  if (!(event.type === "tool_execution_update" && !toolUpdateHasContent(event.partialResult)))
431
452
  this.progressCount += 1;
432
453
  if (event.type === "compaction_start")
433
454
  this.compacted = true;
434
- // Open-tool-call tracking for the stall warning: a call that sits open and silent is
435
- // surfaced by name while the quiet watchdog still counts down. The start/update/end state
436
- // machine lives in applyToolExecutionEvent, shared with the dashboards' live flag
437
- // (src/ui/progress-data.ts); only the label is surface-specific — the warning names the
438
- // command even when pi omits a toolName, where progress falls back to "tool".
455
+ }
456
+ // Open-tool-call tracking for the stall warning: a call that sits open and silent is
457
+ // surfaced by name while the quiet watchdog still counts down. The start/update/end state
458
+ // machine lives in applyToolExecutionEvent, shared with the dashboards' live flag
459
+ // (src/ui/progress-data.ts); only the label is surface-specific — the warning names the
460
+ // command even when pi omits a toolName, where progress falls back to "tool".
461
+ applyToolExecution(event) {
439
462
  if (event.type === "tool_execution_start" ||
440
463
  event.type === "tool_execution_update" ||
441
464
  event.type === "tool_execution_end") {
@@ -468,9 +491,8 @@ export class PiStreamParser {
468
491
  }
469
492
  }
470
493
  }
471
- if (event.type !== "message_end" || event.message?.role !== "assistant")
472
- return;
473
- const msg = event.message;
494
+ }
495
+ applyAssistantMessage(msg) {
474
496
  const text = messageText(msg);
475
497
  this.finalMessageContentless = !messageContent(msg).some((c) => c.type === "toolCall" ||
476
498
  (c.type === "text" && typeof c.text === "string" && Boolean(c.text.trim())));
@@ -7,18 +7,10 @@
7
7
  * handler reads back; run-agent.ts feeds the parser live through the deps accessors, so no
8
8
  * notification path is needed when events land. */
9
9
  import { bufferedCommandStallMs, callBuffersOutput } from "./command-shape.js";
10
- /** Start one pi run's two watchdog clocks: the run deadline (kills the process tree when it
11
- * fires, recording whether the run was still progressing) and the quiet/stall interval
12
- * (quiet-kills a run that stopped making progress and warns on stalled tool calls). Returns
13
- * the live flags the run's close handler reads and the note/stop calls its data handlers and
14
- * settle path make. */
15
- export function startPiWatchdogs(deps) {
16
- let lastProgressAt = Date.now();
17
- let lastProgressCount = 0;
18
- let sawOutput = false;
19
- let timedOut = false;
20
- let timedOutProgressing = false;
21
- let quietKilled = false;
10
+ /** The run-deadline clock: kills the process tree when it fires, after giving a
11
+ * progressing gate run one extension, and records whether the run was still progressing
12
+ * (BUGS.md 2026-09-29, 2026-10-09, 2026-10-07). */
13
+ function startDeadlineWatch(state, deps) {
22
14
  // A gate run earns one deadline extension while it is still making progress, so a large
23
15
  // diff's review can finish instead of timing out mid-verification and being re-read from
24
16
  // scratch on the next attempt (BUGS.md 2026-10-07). "Still making progress" means a
@@ -37,26 +29,28 @@ export function startPiWatchdogs(deps) {
37
29
  // checkpoint covers the extension test and the kill's classification, so the two cannot
38
30
  // disagree, and re-arming it for the extended deadline keeps the bar at `progressWindowMs`
39
31
  // before whichever deadline finally fires.
40
- let checkpoint;
41
32
  const armCheckpoint = (deadlineMs) => {
42
- clearTimeout(checkpoint);
43
- checkpoint = setTimeout(() => {
33
+ clearTimeout(state.checkpoint);
34
+ state.checkpoint = setTimeout(() => {
44
35
  checkpointProgress = deps.progressCount();
45
36
  }, Math.max(0, deadlineMs - progressWindowMs));
46
37
  };
47
38
  armCheckpoint(deps.runTimeoutMs);
39
+ // One short beat the deadline gives a run that has begun producing progress but whose
40
+ // count was flat at fire time: time for a drain-delayed run's output to land before the
41
+ // discard (BUGS.md 2026-10-26).
42
+ const progressGraceMs = 250;
48
43
  let extended = false;
49
- let timeoutBudgetMs = deps.runTimeoutMs;
50
- let timeout;
44
+ let graceArmed = false; // one grace per deadline, so a genuinely stalled run is never granted two
45
+ state.timeoutBudgetMs = deps.runTimeoutMs;
51
46
  const fireDeadline = () => {
52
47
  if (deps.extendOnProgress && !extended && deps.progressCount() > checkpointProgress) {
53
48
  extended = true;
54
- timeoutBudgetMs = deps.runTimeoutMs * 2;
49
+ state.timeoutBudgetMs = deps.runTimeoutMs * 2;
55
50
  armCheckpoint(deps.runTimeoutMs); // the extended deadline gets its own progress window
56
- timeout = setTimeout(fireDeadline, deps.runTimeoutMs); // one more slice; the next firing is final
51
+ state.timeout = setTimeout(fireDeadline, deps.runTimeoutMs); // one more slice; the next firing is final
57
52
  return;
58
53
  }
59
- timedOut = true;
60
54
  // A deadline that fires on a run still making progress killed a slow run, not a hung
61
55
  // one: "recent progress" is progress since the checkpoint `progressWindowMs` before this
62
56
  // deadline — the same window the extension test uses, so the kill's classification
@@ -64,10 +58,30 @@ export function startPiWatchdogs(deps) {
64
58
  // budget (BUGS.md 2026-09-29, 2026-10-09). A run with no progress event at all — never
65
59
  // emitted a byte, or bytes without one structured event — keeps today's discard path: it
66
60
  // has not demonstrably begun.
67
- timedOutProgressing = deps.progressCount() > 0 && deps.progressCount() > checkpointProgress;
61
+ //
62
+ // The checkpoint comparison can mis-classify a run that IS still emitting: the checkpoint
63
+ // and this deadline can fire in the same drain (a firing timers phase precedes the poll
64
+ // phase that delivers stdout), so on a main-thread stall both read the same already-final
65
+ // count and a progressing run looks stalled (BUGS.md 2026-10-26). A run that has begun
66
+ // producing structured progress therefore gets ONE short grace to drain queued output and
67
+ // re-prove itself; only a run still flat after the grace — or one that never progressed —
68
+ // keeps the discard path. The grace is one-shot, so a genuinely stalled run is not
69
+ // granted a second chance.
70
+ if (!graceArmed && deps.progressCount() > 0) {
71
+ graceArmed = true;
72
+ state.timeout = setTimeout(fireDeadline, progressGraceMs);
73
+ return;
74
+ }
75
+ state.timedOut = true;
76
+ state.timedOutProgressing = deps.progressCount() > 0 && deps.progressCount() > checkpointProgress;
68
77
  deps.kill();
69
78
  };
70
- timeout = setTimeout(fireDeadline, deps.runTimeoutMs);
79
+ state.timeout = setTimeout(fireDeadline, deps.runTimeoutMs);
80
+ }
81
+ /** The quiet/stall clock: quiet-kills a run that stopped making progress and warns on
82
+ * stalled tool calls, on one interval against the wall clock (BUGS.md 2026-09-23,
83
+ * 2026-09-28, 2026-09-29, 2026-10-06). */
84
+ function startQuietWatch(state, deps) {
71
85
  // Quiet watchdog: a healthy run makes PROGRESS continuously even when slow — messages
72
86
  // start and end, turns and tool calls complete. Prolonged lack of progress means a hung
73
87
  // tool (an interactive command waiting for input) or a zombie stream (a dead generation
@@ -100,7 +114,7 @@ export function startPiWatchdogs(deps) {
100
114
  // begun; bytes without progress keep the doubled window (the zombie-stream case), and
101
115
  // once real progress has landed quietTimeoutSeconds applies unchanged.
102
116
  const zeroByteSilenceMs = Math.max(deps.quietMs * 2, 30 * 60_000);
103
- const allowedSilenceMs = () => deps.progressCount() > 0 ? deps.quietMs : sawOutput ? deps.quietMs * 2 : zeroByteSilenceMs;
117
+ const allowedSilenceMs = () => deps.progressCount() > 0 ? deps.quietMs : state.sawOutput ? deps.quietMs * 2 : zeroByteSilenceMs;
104
118
  // The stall warning's threshold (distinct from the kill above): one hung tool call is
105
119
  // surfaced by name even while sibling calls keep streaming, so total silence is not
106
120
  // required. One warning per stalled call — the interval keeps firing until the kill or
@@ -149,48 +163,69 @@ export function startPiWatchdogs(deps) {
149
163
  }
150
164
  };
151
165
  const checkEveryMs = deps.quietMs > 0 ? deps.quietMs : deps.stallMs;
152
- const quietCheck = checkEveryMs > 0
153
- ? setInterval(() => {
154
- if (deps.progressCount() > lastProgressCount) {
155
- lastProgressCount = deps.progressCount();
156
- lastProgressAt = Date.now();
157
- }
158
- else if (deps.quietMs > 0 && Date.now() - lastProgressAt > allowedSilenceMs()) {
159
- quietKilled = true;
160
- deps.kill();
161
- }
162
- if (deps.stallMs > 0)
163
- warnStalledToolCalls();
164
- }, Math.min(Math.max(checkEveryMs / 2, 250), 30_000))
165
- : undefined;
166
+ state.quietCheck =
167
+ checkEveryMs > 0
168
+ ? setInterval(() => {
169
+ if (deps.progressCount() > state.lastProgressCount) {
170
+ state.lastProgressCount = deps.progressCount();
171
+ state.lastProgressAt = Date.now();
172
+ }
173
+ else if (deps.quietMs > 0 && Date.now() - state.lastProgressAt > allowedSilenceMs()) {
174
+ state.quietKilled = true;
175
+ deps.kill();
176
+ }
177
+ if (deps.stallMs > 0)
178
+ warnStalledToolCalls();
179
+ }, Math.min(Math.max(checkEveryMs / 2, 250), 30_000))
180
+ : undefined;
181
+ }
182
+ /** Start one pi run's two watchdog clocks: the run deadline (kills the process tree when it
183
+ * fires, recording whether the run was still progressing) and the quiet/stall interval
184
+ * (quiet-kills a run that stopped making progress and warns on stalled tool calls). Returns
185
+ * the live flags the run's close handler reads and the note/stop calls its data handlers and
186
+ * settle path make. */
187
+ export function startPiWatchdogs(deps) {
188
+ const state = {
189
+ lastProgressAt: Date.now(),
190
+ lastProgressCount: 0,
191
+ sawOutput: false,
192
+ timedOut: false,
193
+ timedOutProgressing: false,
194
+ quietKilled: false,
195
+ timeoutBudgetMs: deps.runTimeoutMs,
196
+ };
197
+ // Each watchdog owns its own timer and the rule it enforces: the deadline/extension rule
198
+ // and the quiet/stall rule are each found by name here, and `stop` clears all three below.
199
+ startDeadlineWatch(state, deps);
200
+ startQuietWatch(state, deps);
166
201
  return {
167
202
  get timedOut() {
168
- return timedOut;
203
+ return state.timedOut;
169
204
  },
170
205
  get timeoutBudgetMs() {
171
- return timeoutBudgetMs;
206
+ return state.timeoutBudgetMs;
172
207
  },
173
208
  get timedOutProgressing() {
174
- return timedOutProgressing;
209
+ return state.timedOutProgressing;
175
210
  },
176
211
  get quietKilled() {
177
- return quietKilled;
212
+ return state.quietKilled;
178
213
  },
179
214
  get sawOutput() {
180
- return sawOutput;
215
+ return state.sawOutput;
181
216
  },
182
217
  noteByte() {
183
- sawOutput = true; // any byte ends the starting phase, progress or not
218
+ state.sawOutput = true; // any byte ends the starting phase, progress or not
184
219
  },
185
220
  noteStderr() {
186
- sawOutput = true;
187
- lastProgressAt = Date.now();
221
+ state.sawOutput = true;
222
+ state.lastProgressAt = Date.now();
188
223
  },
189
224
  stop() {
190
- clearTimeout(timeout);
191
- clearTimeout(checkpoint);
192
- if (quietCheck)
193
- clearInterval(quietCheck);
225
+ clearTimeout(state.timeout);
226
+ clearTimeout(state.checkpoint);
227
+ if (state.quietCheck)
228
+ clearInterval(state.quietCheck);
194
229
  },
195
230
  };
196
231
  }
@@ -179,43 +179,67 @@ export function shakePlan(messages, usage, writeFullOutput) {
179
179
  export function shakeNote(elidedCount, reclaimedTokens) {
180
180
  return `[tumwater: elided ${elidedCount} old tool results (~${compactTokens(reclaimedTokens)} tokens); each pointer says where the full text is]`;
181
181
  }
182
+ /** Records the tool-call parts of an assistant message by id, so a later tool result can find
183
+ * its matching call. */
184
+ function recordToolCalls(msg, calls) {
185
+ if (msg.role !== "assistant" || !Array.isArray(msg.content))
186
+ return;
187
+ for (const part of msg.content) {
188
+ if (part?.type === "toolCall" && typeof part.id === "string")
189
+ calls.set(part.id, part);
190
+ }
191
+ }
192
+ /** Resolves the read-input pointer (file:range) from a matching tool call, when it was a read. */
193
+ function readInputFor(call) {
194
+ if (call?.name === "read") {
195
+ return {
196
+ path: call.arguments?.path,
197
+ offset: call.arguments?.offset,
198
+ limit: call.arguments?.limit,
199
+ };
200
+ }
201
+ return undefined;
202
+ }
203
+ /** Copies a tool result's fields (toolName, toolCallId, isError, fullOutputPath, readInput) onto
204
+ * the message from the recorded calls. */
205
+ function applyToolResultFields(message, msg, calls) {
206
+ if (msg.role === "toolResult") {
207
+ if (msg.toolName)
208
+ message.toolName = msg.toolName;
209
+ if (msg.toolCallId)
210
+ message.toolCallId = msg.toolCallId;
211
+ if (msg.isError !== undefined)
212
+ message.isError = msg.isError;
213
+ if (msg.details?.fullOutputPath)
214
+ message.fullOutputPath = msg.details.fullOutputPath;
215
+ const call = msg.toolCallId ? calls.get(msg.toolCallId) : undefined;
216
+ const readInput = readInputFor(call);
217
+ if (readInput)
218
+ message.readInput = readInput;
219
+ }
220
+ }
221
+ /** Assembles one message's fields (text, entry id, role, then its tool-result fields). */
222
+ function buildShakeMessage(msg, entryId, calls) {
223
+ const message = { text: textOf(msg) };
224
+ if (entryId)
225
+ message.entryId = entryId;
226
+ if (msg.role)
227
+ message.role = msg.role;
228
+ applyToolResultFields(message, msg, calls);
229
+ return message;
230
+ }
182
231
  /** Flatten a turn_end event's projected context into the ordered messages the planner reads,
183
- * mapping each assistant tool call's input onto the tool result it produced. */
232
+ * mapping each assistant tool call's input onto the tool result it produced. The per-job work
233
+ * lives in the named helpers `recordToolCalls`, `readInputFor`, `applyToolResultFields` and
234
+ * `buildShakeMessage`; this function is their composition. */
184
235
  export function shakeMessages(event) {
185
236
  const calls = new Map();
186
237
  const out = [];
187
238
  for (const entry of event.context?.contextEntries ?? []) {
239
+ const entryId = entry.sourceEntry?.id;
188
240
  for (const msg of entry.messages ?? []) {
189
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
190
- for (const part of msg.content) {
191
- if (part?.type === "toolCall" && typeof part.id === "string")
192
- calls.set(part.id, part);
193
- }
194
- }
195
- const message = { text: textOf(msg) };
196
- if (entry.sourceEntry?.id)
197
- message.entryId = entry.sourceEntry.id;
198
- if (msg.role)
199
- message.role = msg.role;
200
- if (msg.role === "toolResult") {
201
- if (msg.toolName)
202
- message.toolName = msg.toolName;
203
- if (msg.toolCallId)
204
- message.toolCallId = msg.toolCallId;
205
- if (msg.isError !== undefined)
206
- message.isError = msg.isError;
207
- if (msg.details?.fullOutputPath)
208
- message.fullOutputPath = msg.details.fullOutputPath;
209
- const call = msg.toolCallId ? calls.get(msg.toolCallId) : undefined;
210
- if (call?.name === "read") {
211
- message.readInput = {
212
- path: call.arguments?.path,
213
- offset: call.arguments?.offset,
214
- limit: call.arguments?.limit,
215
- };
216
- }
217
- }
218
- out.push(message);
241
+ recordToolCalls(msg, calls);
242
+ out.push(buildShakeMessage(msg, entryId, calls));
219
243
  }
220
244
  }
221
245
  return out;
@@ -11,10 +11,9 @@
11
11
  * side effects it must not grow. pi itself loads the bundled extensions, so this stays
12
12
  * dependency-free beyond node built-ins and the path helper.
13
13
  */
14
- import fs from "node:fs";
15
14
  import path from "node:path";
16
15
  import { toolOutputDir } from "../paths.js";
17
- import { isDirectory, walkUp, writeTextAtomic } from "../files/files.js";
16
+ import { ensureDir, isDirectory, walkUp, writeTextAtomic } from "../files/files.js";
18
17
  /** Walk up from `startDir` looking for a `.tumwater/` directory — the harness root. Worktrees
19
18
  * under it (`.tumwater/worktrees/<role>`, merger, gate alike) reach it three levels up
20
19
  * (`../../..`, the repo checkout that owns `.tumwater/`); a harness root carries it directly.
@@ -41,7 +40,7 @@ export function writeFullOutput(text, toolCallId, startDir = process.cwd()) {
41
40
  return null;
42
41
  try {
43
42
  const dir = toolOutputDir(root);
44
- fs.mkdirSync(dir, { recursive: true });
43
+ ensureDir(dir);
45
44
  const id = typeof toolCallId === "string" && toolCallId
46
45
  ? toolCallId.replace(/[^\w-]/g, "_")
47
46
  : `result-${Date.now()}`;
@@ -24,7 +24,7 @@ export function execFileAsync(file, args, options = {}) {
24
24
  * added after the abort event never fires, so `addEventListener` alone would leave the caller
25
25
  * waiting on a shutdown that has come and gone. A missing signal (no shutdown wiring)
26
26
  * registers nothing. Shared by spawnSupervised (supervisor.ts), runPi's child spawn
27
- * (src/agent/run-agent.ts), sleepInterruptible (run-timing.ts), and abortOnShutdown
27
+ * (src/agent/run-spawn.ts), sleepInterruptible (run-timing.ts), and abortOnShutdown
28
28
  * (merge-pipeline.ts).
29
29
  *
30
30
  * Returns a disposer that detaches `fn`. A `{ once: true }` listener detaches itself when the
@@ -65,6 +65,29 @@ export function pidAlive(pid) {
65
65
  return false;
66
66
  }
67
67
  }
68
+ /** The identity of the live process on `pid`, or null when no process of ours answers for it —
69
+ * a non-positive or non-integer pid, a reaped pid, or a pid the probe cannot read (a foreign
70
+ * process `ps` will not disclose, or a host whose ps lacks `lstart`). Null reads as "not
71
+ * alive" to `identityAlive`, so the reap checks' bounded poll reports only a genuine hang. */
72
+ export async function pidIdentity(pid) {
73
+ if (!Number.isInteger(pid) || pid <= 0)
74
+ return null;
75
+ try {
76
+ const { stdout } = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)]);
77
+ const start = stdout.trim();
78
+ return start.length > 0 ? { pid, start } : null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** True when the process instance `id` names is still alive: the pid answers and the OS still
85
+ * reports the same start time. A reaped pid (no answer), a pid reused by a different process
86
+ * (a different start), or null reads as not alive. */
87
+ export async function identityAlive(id) {
88
+ const now = await pidIdentity(id.pid);
89
+ return now !== null && now.start === id.start;
90
+ }
68
91
  /** Terminate a detached child and everything it started: SIGTERM to the process group now,
69
92
  * escalating to SIGKILL after 10 s if any of it is still alive. The child is spawned detached
70
93
  * (its own process group leader — runPi does this for pi), so a negative PID reaches every
@@ -4,7 +4,7 @@
4
4
  * probe's runMarkers check and doctor's orphan judgment), and the sweep that SIGTERMs
5
5
  * this run's leftovers whatever process group they ended up in. Split out of
6
6
  * process-table.ts — which keeps the doctor-facing probe interface and the ps/lsof/top
7
- * parsing it is made of — because this half serves src/agent/run-agent.ts's run stamping and
7
+ * parsing it is made of — because this half serves src/agent/run-spawn.ts's run stamping and
8
8
  * the exit sweep, and shares none of the probe's table-parsing concerns; the probe reads marks
9
9
  * through the same parsers exported here. */
10
10
  import crypto from "node:crypto";
@@ -43,7 +43,7 @@ export function runMarkerEnv(base, marker) {
43
43
  * check-in preload removed (`withoutLaunchServicesCheckIn`) and this run's marker appended
44
44
  * (`runMarkerEnv`). All three adapters (pi, claude, opencode) layer their own settings on
45
45
  * top, so the composition lives here rather than being re-derived in each. `binDir`, when
46
- * given, is prepended to PATH — run-agent passes the notes shim dir (notesShimDir) so every
46
+ * given, is prepended to PATH — run-spawn passes the notes shim dir (notesShimDir) so every
47
47
  * run finds the `tumwater-notes` command first (plans/notes-command.md "Reaching it from a
48
48
  * run"). */
49
49
  export function backendBaseEnv(marker, base = process.env, binDir) {
@@ -3,6 +3,7 @@ import { TUMWATER_SOURCE } from "../events/events.js";
3
3
  import { RESTART_EXIT_CODE } from "../redeploy/redeploy-policy.js";
4
4
  import { childEnv, runOnAbort } from "./process.js";
5
5
  import { liveNodeBin } from "./node-bin.js";
6
+ import { parsePositiveInt } from "../text/text.js";
6
7
  /** The respawn loop behind `tumwater start`. The command runs as two processes: this supervisor
7
8
  * (the one the operator started, holding the terminal) and a child that runs the orchestrator.
8
9
  * When the child rebuilt dist/ onto a newer main (src/redeploy/redeployer.ts) it exits
@@ -49,9 +50,22 @@ function intervalPoll(intervalMs) {
49
50
  * disarms the watch (a no-op once it fired or was already stopped). */
50
51
  export function startParentDeathWatch(onGone, opts = {}) {
51
52
  const ppid = opts.ppid ?? (() => process.ppid);
52
- const original = opts.expectedPpid ?? ppid();
53
+ const expectedGiven = opts.expectedPpid !== undefined;
54
+ const original = expectedGiven ? opts.expectedPpid : ppid();
53
55
  let cancel;
54
56
  let fired = false;
57
+ // No expectation was handed down, so the baseline is the watch's own first read. A first
58
+ // read of 1 (PID 1 — launchd/init, never a real parent) means the process was already
59
+ // reparented before the watch armed: the shell that started it exited before this child's
60
+ // first ppid read, the detached-`&` leak. There is no distinct parent left to watch, so
61
+ // the child is already orphaned — fire immediately instead of pinning the baseline to 1,
62
+ // where the watch could never observe a change. This is the same route the follow
63
+ // commands' parent-death rule closes: a follow that cannot see a distinct parent ends now.
64
+ if (!expectedGiven && original === 1) {
65
+ fired = true;
66
+ onGone();
67
+ return () => { };
68
+ }
55
69
  // Release the poll at most once: a fired watch, or an explicit stop, must leave nothing armed.
56
70
  const release = () => {
57
71
  const stopPoll = cancel;
@@ -59,6 +73,9 @@ export function startParentDeathWatch(onGone, opts = {}) {
59
73
  stopPoll?.();
60
74
  };
61
75
  const check = () => {
76
+ // When an expectation was handed down, the first read may legitimately equal it even
77
+ // when the parent is already gone (the expected pid is compared, not the first read);
78
+ // otherwise the baseline IS the first read, so only a later change fires the watch.
62
79
  if (fired || ppid() === original)
63
80
  return;
64
81
  fired = true;
@@ -78,11 +95,13 @@ export function startParentDeathWatch(onGone, opts = {}) {
78
95
  };
79
96
  }
80
97
  /** The supervisor pid a supervised child was handed, or undefined when the mark is absent or
81
- * names no process (a number that is not an integer, or 1 — launchd/init, never a supervisor):
82
- * the watch's own first read stands instead. */
98
+ * names no process (a hex/exponent/whitespace spelling, or 1 — launchd/init, never a supervisor):
99
+ * the watch's own first read stands instead. The mark is written as String(process.pid) (plain
100
+ * decimal), so it is read by the shared plain-decimal rule, not Number()'s silent coercion. */
83
101
  export function expectedSupervisorPid(env = process.env) {
84
- const pid = Number(env[SUPERVISOR_PID_ENV]);
85
- return Number.isInteger(pid) && pid > 1 ? pid : undefined;
102
+ const mark = env[SUPERVISOR_PID_ENV];
103
+ const pid = mark === undefined ? null : parsePositiveInt(mark);
104
+ return pid !== null && pid > 1 ? pid : undefined;
86
105
  }
87
106
  /** Respawns within this window count toward the crash-loop guard. */
88
107
  export const RESPAWN_WINDOW_MS = 60_000;