claude-code-session-manager 0.87.0 → 0.88.1

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 +26 -53
  2. package/dist/assets/{AgentLibrary-DyLWzZDf.js → AgentLibrary-kQF8_wAw.js} +1 -1
  3. package/dist/assets/{DataModel--mISIJ6h.js → DataModel-CY_mMnsr.js} +1 -1
  4. package/dist/assets/{History-C2ahUXTg.js → History-Sji2brb0.js} +1 -1
  5. package/dist/assets/{Hooks-BiC6oyR2.js → Hooks-CtqpO-p5.js} +1 -1
  6. package/dist/assets/{HostBilko-BPleEOld.js → HostBilko-DN0ydOCr.js} +1 -1
  7. package/dist/assets/{Library-Dc8Qst1R.js → Library-CoiLZTKh.js} +1 -1
  8. package/dist/assets/{ListDetail-DIXh-OLX.js → ListDetail-CmqiQ_io.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-C90bkLXK.js → MarkdownEditor-HPoNeKhT.js} +1 -1
  10. package/dist/assets/{McpServers-DqcbLOLZ.js → McpServers-Bj1X3_NT.js} +1 -1
  11. package/dist/assets/{Memory-CW62MXlh.js → Memory-1PKjsM3b.js} +1 -1
  12. package/dist/assets/{Panel-Bw1FhRuF.js → Panel-I1s6ghPo.js} +1 -1
  13. package/dist/assets/{Permissions-BcUC-5y8.js → Permissions-BhiiZjHQ.js} +1 -1
  14. package/dist/assets/{Plugins-BnKx9flD.js → Plugins-gv3_fkPX.js} +2 -2
  15. package/dist/assets/{ProvenanceBadge-Bw5vNVPT.js → ProvenanceBadge-BGcgBd8V.js} +1 -1
  16. package/dist/assets/{SaveBar-CWr0O_w-.js → SaveBar-BGr9eZ4g.js} +1 -1
  17. package/dist/assets/{Scheduler-DYdLuUqq.js → Scheduler-Bdo_awPq.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-CrBLbg8s.js → ScopeSwitcher-ukl0qWaA.js} +1 -1
  19. package/dist/assets/{Settings-DluB-vN1.js → Settings-DJkFj22G.js} +1 -1
  20. package/dist/assets/{SkillReferenceGraph-CHLSseay.js → SkillReferenceGraph-0t1Qblp6.js} +1 -1
  21. package/dist/assets/{Skills-gNdo_HNK.js → Skills-CeVdBGE-.js} +1 -1
  22. package/dist/assets/{SystemPrompt-Cru05-Ia.js → SystemPrompt-CfKvNHi3.js} +1 -1
  23. package/dist/assets/{TagLibrary-DNHY0xou.js → TagLibrary-DAzNaUPd.js} +1 -1
  24. package/dist/assets/{TiptapBody-I4lmbCgP.js → TiptapBody--S_rGXoA.js} +1 -1
  25. package/dist/assets/{Toggle-bWMHjmRh.js → Toggle-Dszm0xnp.js} +1 -1
  26. package/dist/assets/{index-DV3PorRY.css → index-BHTX4OTc.css} +1 -1
  27. package/dist/assets/{index-fc_JjdxL.js → index-Dp4Rc--q.js} +356 -356
  28. package/dist/assets/{settingsSchema-BfhtZnGD.js → settingsSchema-BLbssMYo.js} +1 -1
  29. package/dist/assets/{whisperWorker-Dbia1OpC.js → whisperWorker-C7ZGQwKg.js} +7 -7
  30. package/dist/index.html +2 -2
  31. package/dist/vad/ort-wasm-simd-threaded.asyncify.mjs +106 -110
  32. package/dist/vad/ort-wasm-simd-threaded.asyncify.wasm +0 -0
  33. package/dist/vad/ort-wasm-simd-threaded.jsep.mjs +98 -98
  34. package/dist/vad/ort-wasm-simd-threaded.jsep.wasm +0 -0
  35. package/dist/vad/ort-wasm-simd-threaded.jspi.mjs +99 -102
  36. package/dist/vad/ort-wasm-simd-threaded.jspi.wasm +0 -0
  37. package/dist/vad/ort-wasm-simd-threaded.mjs +46 -46
  38. package/dist/vad/ort-wasm-simd-threaded.wasm +0 -0
  39. package/package.json +10 -13
  40. package/scripts/README.md +59 -0
  41. package/scripts/audit-ops-hygiene.cjs +350 -0
  42. package/scripts/hooks/guard-destructive-git.cjs +10 -34
  43. package/scripts/hooks/guard-inline-implementation.cjs +14 -8
  44. package/scripts/hooks/guard-prd-writes.cjs +7 -36
  45. package/scripts/hooks/guard-self-schedule.cjs +175 -0
  46. package/scripts/ops-sweep.cjs +355 -0
  47. package/scripts/scheduler-mcp-server.cjs +28 -71
  48. package/src/main/__tests__/bilkoHost-integration.test.cjs +3 -3
  49. package/src/main/__tests__/chat-cancel-terminal.test.cjs +6 -9
  50. package/src/main/__tests__/chat-exit-close-race.test.cjs +3 -3
  51. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +4 -5
  52. package/src/main/__tests__/chat-queue.test.cjs +2 -2
  53. package/src/main/__tests__/chat-stop-signal.test.cjs +2 -2
  54. package/src/main/__tests__/dep-orphan-archive-health.test.cjs +77 -0
  55. package/src/main/__tests__/dod-batchkey.test.cjs +2 -2
  56. package/src/main/__tests__/dod-drain-hook.test.cjs +2 -2
  57. package/src/main/__tests__/dod-report.test.cjs +2 -2
  58. package/src/main/__tests__/dod-reverify.test.cjs +2 -2
  59. package/src/main/__tests__/epicMint.test.cjs +2 -2
  60. package/src/main/__tests__/exchanges.test.cjs +2 -2
  61. package/src/main/__tests__/extractJson.test.cjs +2 -2
  62. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  63. package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +556 -0
  64. package/src/main/__tests__/health-build-freshness.test.cjs +39 -0
  65. package/src/main/__tests__/health-delegation-chain.test.cjs +15 -1
  66. package/src/main/__tests__/health-queue-dispatch.test.cjs +58 -7
  67. package/src/main/__tests__/health-tick-liveness.test.cjs +27 -3
  68. package/src/main/__tests__/health-usage-poller.test.cjs +70 -23
  69. package/src/main/__tests__/historyRollup.test.cjs +2 -2
  70. package/src/main/__tests__/kg-augment.test.cjs +2 -2
  71. package/src/main/__tests__/mcpStatus.test.cjs +2 -2
  72. package/src/main/__tests__/memoryAggregate.test.cjs +1 -1
  73. package/src/main/__tests__/memoryStale.test.cjs +1 -1
  74. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +25 -1
  75. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +33 -3
  76. package/src/main/__tests__/prd-group-allocator.test.cjs +2 -2
  77. package/src/main/__tests__/prdAdminRouteParity.test.cjs +2 -0
  78. package/src/main/__tests__/prdAdminRoutes.test.cjs +14 -2
  79. package/src/main/__tests__/prdAuthoringSeed.test.cjs +39 -0
  80. package/src/main/__tests__/prdLocationsArchived.test.cjs +20 -13
  81. package/src/main/__tests__/proc-role-env.test.cjs +125 -0
  82. package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +304 -0
  83. package/src/main/__tests__/procname-sm-processes.test.cjs +127 -0
  84. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +81 -401
  85. package/src/main/__tests__/projectPages.test.cjs +63 -149
  86. package/src/main/__tests__/queue-health-verdict.test.cjs +9 -9
  87. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +117 -20
  88. package/src/main/__tests__/queueHistory.test.cjs +2 -2
  89. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +38 -3
  90. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +181 -0
  91. package/src/main/__tests__/runVerify.test.cjs +5 -5
  92. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +3 -3
  93. package/src/main/__tests__/scheduleJobTransitions.test.cjs +2 -2
  94. package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +143 -0
  95. package/src/main/__tests__/scheduler-autofix-select.test.cjs +2 -2
  96. package/src/main/__tests__/scheduler-autopromote.test.cjs +2 -2
  97. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +3 -5
  98. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +78 -97
  99. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +161 -0
  100. package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +58 -0
  101. package/src/main/__tests__/scheduler-epic-digest.test.cjs +3 -5
  102. package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +2 -2
  103. package/src/main/__tests__/scheduler-gate-shadow.test.cjs +119 -0
  104. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +46 -0
  105. package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +80 -0
  106. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +25 -18
  107. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +4 -4
  108. package/src/main/__tests__/scheduler-launch-failure.test.cjs +3 -5
  109. package/src/main/__tests__/scheduler-looks-done.test.cjs +26 -7
  110. package/src/main/__tests__/scheduler-manual-pause.test.cjs +118 -0
  111. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +26 -3
  112. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +17 -2
  113. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +3 -5
  114. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +41 -6
  115. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +62 -6
  116. package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +3 -5
  117. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +23 -4
  118. package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +100 -0
  119. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +3 -3
  120. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +4 -4
  121. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +110 -0
  122. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +81 -1
  123. package/src/main/__tests__/scheduler-stall-per-project.test.cjs +14 -0
  124. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +4 -4
  125. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +245 -0
  126. package/src/main/__tests__/scheduler-supervisor-record.test.cjs +81 -0
  127. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +2 -2
  128. package/src/main/__tests__/scheduler-tick-wedge.test.cjs +172 -0
  129. package/src/main/__tests__/scheduler-transient-failure.test.cjs +2 -2
  130. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +37 -11
  131. package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +3 -5
  132. package/src/main/__tests__/usageSingleFlight.test.cjs +157 -0
  133. package/src/main/__tests__/workTypeLibrary.test.cjs +1 -1
  134. package/src/main/bilkoHost.cjs +16 -7
  135. package/src/main/build-info.json +8 -0
  136. package/src/main/chatRunner.cjs +4 -2
  137. package/src/main/config.cjs +2 -3
  138. package/src/main/docEdit.cjs +4 -2
  139. package/src/main/health.cjs +276 -52
  140. package/src/main/heapSnapshot.cjs +2 -2
  141. package/src/main/historyAggregator.cjs +1 -1
  142. package/src/main/index.cjs +61 -10
  143. package/src/main/ipcSchemas.cjs +6 -17
  144. package/src/main/lib/__tests__/auditLog.test.cjs +38 -0
  145. package/src/main/lib/__tests__/buildIdentity.test.cjs +121 -0
  146. package/src/main/lib/__tests__/cwdClassify.test.cjs +111 -0
  147. package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +95 -0
  148. package/src/main/lib/__tests__/delegationReadiness.test.cjs +1 -1
  149. package/src/main/lib/__tests__/dispatchLoop.test.cjs +63 -0
  150. package/src/main/lib/__tests__/gateFixtures.json +20 -0
  151. package/src/main/lib/__tests__/gitWorktree.test.cjs +8 -1
  152. package/src/main/lib/__tests__/instanceLock.test.cjs +93 -7
  153. package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +78 -0
  154. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +11 -0
  155. package/src/main/lib/__tests__/localAdminHttp.test.cjs +1 -1
  156. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +6 -1
  157. package/src/main/lib/__tests__/procIdentity.test.cjs +119 -0
  158. package/src/main/lib/__tests__/procName.test.cjs +92 -0
  159. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +67 -0
  160. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +6 -7
  161. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +37 -204
  162. package/src/main/lib/__tests__/schedulerPaths.test.cjs +226 -0
  163. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +94 -0
  164. package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +56 -0
  165. package/src/main/lib/__tests__/sessionSlots.test.cjs +45 -0
  166. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +1 -1
  167. package/src/main/lib/__tests__/upgradeDrain.test.cjs +130 -0
  168. package/src/main/lib/__tests__/usageCircuit.test.cjs +61 -0
  169. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +63 -0
  170. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +73 -0
  171. package/src/main/lib/activeIndexRebuild.cjs +1 -3
  172. package/src/main/lib/activeSessions.cjs +20 -88
  173. package/src/main/lib/adoptedRunSupervisor.cjs +136 -0
  174. package/src/main/lib/agentModelResolve.cjs +33 -1
  175. package/src/main/lib/agentPersonaSchema.cjs +2 -2
  176. package/src/main/lib/auditLog.cjs +30 -5
  177. package/src/main/lib/buildIdentity.cjs +113 -0
  178. package/src/main/lib/classifyPromptTicket.cjs +3 -2
  179. package/src/main/lib/claudeBin.cjs +37 -2
  180. package/src/main/lib/cleanEnv.cjs +27 -1
  181. package/src/main/lib/credentials.cjs +4 -2
  182. package/src/main/lib/cwdClassify.cjs +185 -0
  183. package/src/main/lib/definitionOfDone.cjs +296 -52
  184. package/src/main/lib/dispatchLoop.cjs +40 -0
  185. package/src/main/lib/effectiveModelInfo.cjs +9 -10
  186. package/src/main/lib/ephemeralCwd.cjs +7 -28
  187. package/src/main/lib/gitWorktree.cjs +95 -8
  188. package/src/main/lib/guardShims.cjs +3 -3
  189. package/src/main/lib/historyRollup.cjs +6 -7
  190. package/src/main/lib/instanceLock.cjs +32 -5
  191. package/src/main/lib/jobSupervisorRecord.cjs +147 -0
  192. package/src/main/lib/jobWorktreeBootLive.cjs +9 -5
  193. package/src/main/lib/localAdminHttp.cjs +9 -27
  194. package/src/main/lib/mcpToolCatalog.cjs +29 -77
  195. package/src/main/lib/opsOwnership.cjs +27 -19
  196. package/src/main/lib/prdAuthoringSeed.cjs +36 -0
  197. package/src/main/lib/prdLocations.cjs +48 -1
  198. package/src/main/lib/procIdentity.cjs +126 -0
  199. package/src/main/lib/procName.cjs +98 -0
  200. package/src/main/lib/projectHomeAdminRoutes.cjs +56 -327
  201. package/src/main/lib/queueHistory.cjs +8 -7
  202. package/src/main/lib/queueStore.cjs +57 -37
  203. package/src/main/lib/quietMachineLease.cjs +19 -2
  204. package/src/main/lib/reservationExpiry.cjs +30 -0
  205. package/src/main/lib/runClaudeP.cjs +4 -2
  206. package/src/main/lib/runLogRetention.cjs +3 -2
  207. package/src/main/lib/scheduleJobSchema.cjs +2 -2
  208. package/src/main/lib/scheduleJobTransitions.cjs +29 -2
  209. package/src/main/lib/schedulerBatch.cjs +27 -2
  210. package/src/main/lib/schedulerPaths.cjs +175 -0
  211. package/src/main/lib/schedulerRuntimeState.cjs +59 -0
  212. package/src/main/lib/sessionSlots.cjs +47 -9
  213. package/src/main/lib/smProcNames.cjs +51 -0
  214. package/src/main/lib/upgradeDrain.cjs +188 -0
  215. package/src/main/lib/usageCircuit.cjs +53 -6
  216. package/src/main/lib/watchdogHelpers.cjs +70 -37
  217. package/src/main/lib/withTimeout.cjs +33 -0
  218. package/src/main/mcpStatus.cjs +4 -2
  219. package/src/main/pluginInstall.cjs +6 -2
  220. package/src/main/projectPages.cjs +41 -145
  221. package/src/main/pty.cjs +8 -1
  222. package/src/main/queueOps.cjs +10 -10
  223. package/src/main/runVerify.cjs +69 -3
  224. package/src/main/scheduler.cjs +1607 -383
  225. package/src/main/seedAgentPersonas.cjs +1 -1
  226. package/src/main/seedDevPlugin.cjs +1 -1
  227. package/src/main/seedSchedulerMcp.cjs +7 -6
  228. package/src/main/seedStatus.cjs +1 -1
  229. package/src/main/supervisor.cjs +5 -3
  230. package/src/main/usage.cjs +126 -62
  231. package/src/preload/api.d.ts +18 -64
  232. package/src/preload/index.cjs +2 -4
  233. package/src/seed/agents/project-home-builder.md +31 -48
  234. package/screenshots/.gitkeep +0 -0
  235. package/screenshots/README-screenshots.md +0 -13
  236. package/src/main/lib/projectPageSummarySchema.cjs +0 -181
  237. package/src/main/teams.cjs +0 -95
  238. package/src/main/templates/project-pages-catalog.json +0 -741
  239. package/src/main/templates/project-pages-default-home.html +0 -123
  240. package/src/main/templates/project-pages-pipeline.md +0 -417
  241. package/src/seed/prompts/code-review/ac-coverage-check.md +0 -8
  242. package/src/seed/prompts/code-review/correctness-only.md +0 -8
  243. package/src/seed/prompts/code-review/full-spectrum-high.md +0 -8
  244. package/src/seed/prompts/code-review/hallucination-check.md +0 -8
  245. package/src/seed/prompts/code-review/public-api-compat.md +0 -8
  246. package/src/seed/prompts/code-review/readability-naming.md +0 -8
  247. package/src/seed/prompts/debugging/bug-as-failing-test.md +0 -8
  248. package/src/seed/prompts/debugging/git-bisect-regression.md +0 -8
  249. package/src/seed/prompts/debugging/instrument-intermittent-bug.md +0 -8
  250. package/src/seed/prompts/debugging/localize-pipeline-failure.md +0 -8
  251. package/src/seed/prompts/debugging/reproduce-then-diagnose.md +0 -8
  252. package/src/seed/prompts/documentation/adr-from-change.md +0 -8
  253. package/src/seed/prompts/documentation/module-readme.md +0 -8
  254. package/src/seed/prompts/documentation/onboarding-plan.md +0 -8
  255. package/src/seed/prompts/documentation/refresh-claude-md.md +0 -8
  256. package/src/seed/prompts/documentation/tsdoc-public-exports.md +0 -8
  257. package/src/seed/prompts/git-pr/conventional-commit.md +0 -8
  258. package/src/seed/prompts/git-pr/draft-pr-title-body.md +0 -8
  259. package/src/seed/prompts/git-pr/pre-commit-safety-sweep.md +0 -8
  260. package/src/seed/prompts/git-pr/release-notes-block.md +0 -8
  261. package/src/seed/prompts/git-pr/split-large-pr.md +0 -8
  262. package/src/seed/prompts/performance/bundle-startup-audit.md +0 -8
  263. package/src/seed/prompts/performance/complexity-audit.md +0 -8
  264. package/src/seed/prompts/performance/cpu-profile-hot-path.md +0 -8
  265. package/src/seed/prompts/performance/db-query-plan-review.md +0 -8
  266. package/src/seed/prompts/performance/memory-leak-hunt.md +0 -8
  267. package/src/seed/prompts/qa/api-contract-tests.md +0 -8
  268. package/src/seed/prompts/qa/e2e-critical-path.md +0 -8
  269. package/src/seed/prompts/qa/failing-test-for-bug.md +0 -8
  270. package/src/seed/prompts/qa/find-missing-test-coverage.md +0 -8
  271. package/src/seed/prompts/qa/stabilize-flaky-test.md +0 -8
  272. package/src/seed/prompts/qa/tdd-red-first.md +0 -8
  273. package/src/seed/prompts/qa/visual-regression-review.md +0 -8
  274. package/src/seed/prompts/qa/wcag-axe-scan.md +0 -8
  275. package/src/seed/prompts/refactoring/dead-code-sweep.md +0 -8
  276. package/src/seed/prompts/refactoring/extract-duplicated-pattern.md +0 -8
  277. package/src/seed/prompts/refactoring/modernize-legacy-file.md +0 -8
  278. package/src/seed/prompts/refactoring/reduce-cyclomatic-complexity.md +0 -8
  279. package/src/seed/prompts/refactoring/tighten-module-boundaries.md +0 -8
  280. package/src/seed/prompts/security/authz-audit.md +0 -8
  281. package/src/seed/prompts/security/crypto-correctness.md +0 -8
  282. package/src/seed/prompts/security/cwe-top-25-hunt.md +0 -8
  283. package/src/seed/prompts/security/dependency-audit.md +0 -8
  284. package/src/seed/prompts/security/ipc-boundary-hardening.md +0 -8
  285. package/src/seed/prompts/security/owasp-top-10-staged-diff.md +0 -8
  286. package/src/seed/prompts/security/secret-credential-scan.md +0 -8
  287. package/web/README.md +0 -41
  288. package/web/project-pages/logic/dist/logic.cjs +0 -4709
  289. package/web/project-pages/render.cjs +0 -70
  290. package/web/project-pages/renderer/dist/renderer.cjs +0 -18900
  291. package/web/project-pages/validate-summary.cjs +0 -62
@@ -35,44 +35,23 @@
35
35
  * unrelated use that a blanket match would wrongly refuse.
36
36
  */
37
37
 
38
- const os = require('node:os');
39
38
  const path = require('node:path');
40
- const { worktreeMainRootOf } = require('./activeSessions.cjs');
41
- const { KIND_CONFIG } = require('./gitWorktree.cjs');
42
-
43
- const TMPDIR = path.resolve(os.tmpdir());
44
- const MANAGED_WORKTREE_ROOTS = [KIND_CONFIG.job.root, KIND_CONFIG.epic.root].map((p) => path.resolve(p));
45
-
46
- function isExactlyTmpdir(absCwd) {
47
- return absCwd === TMPDIR;
48
- }
49
-
50
- function isUnderManagedWorktreeRoot(absCwd) {
51
- return MANAGED_WORKTREE_ROOTS.some((root) => {
52
- const rel = path.relative(root, absCwd);
53
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
54
- });
55
- }
56
-
57
- function isLinkedWorktreeRoot(absCwd) {
58
- const mainRoot = worktreeMainRootOf(absCwd);
59
- return Boolean(mainRoot) && path.resolve(mainRoot) !== absCwd;
60
- }
39
+ const { classifyCwd } = require('./cwdClassify.cjs');
61
40
 
62
41
  /**
63
42
  * isEphemeralCwd(cwd) → true when `cwd` must never have ops state written
64
43
  * into it (tmpdir-resident, or a linked git worktree root). Pure, synchronous,
65
44
  * never throws — an unresolvable or non-absolute cwd is treated as NOT
66
45
  * ephemeral so this predicate only ever adds refusals, never masks the
67
- * existing "cwd must be absolute" checks callers already perform.
46
+ * existing "cwd must be absolute" checks callers already perform. The
47
+ * unprovable-worktree case is NOT ephemeral here: it is kind `unknown`, which
48
+ * opsOwnership.assertOpsWrite refuses separately (writes only — dispatch and
49
+ * reads stay open).
68
50
  */
69
51
  function isEphemeralCwd(cwd) {
70
52
  if (!cwd || typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
71
- const absCwd = path.resolve(cwd);
72
- if (isExactlyTmpdir(absCwd)) return true;
73
- if (isUnderManagedWorktreeRoot(absCwd)) return true;
74
- if (isLinkedWorktreeRoot(absCwd)) return true;
75
- return false;
53
+ const { kind } = classifyCwd(path.resolve(cwd));
54
+ return kind === 'ephemeral' || kind === 'worktree';
76
55
  }
77
56
 
78
57
  module.exports = { isEphemeralCwd };
@@ -55,6 +55,7 @@ const os = require('node:os');
55
55
  const crypto = require('node:crypto');
56
56
  const { execFile } = require('node:child_process');
57
57
  const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
58
+ const schedulerPaths = require('./schedulerPaths.cjs');
58
59
 
59
60
  // Per-kind configuration. Roots are kept OUTSIDE any project's own tree
60
61
  // (os.tmpdir(), not `<cwd>/.git/...`) so a managed worktree never shows up in
@@ -86,7 +87,7 @@ const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
86
87
  // days, same rationale as defaultMax above.
87
88
  const KIND_CONFIG = {
88
89
  job: {
89
- root: path.join(os.tmpdir(), 'session-manager-job-worktrees'),
90
+ get root() { return schedulerPaths.worktreeRoot('job'); },
90
91
  branchPrefix: 'sm-job/',
91
92
  disableEnv: 'SM_JOB_WORKTREE_DISABLE',
92
93
  maxEnv: 'SM_JOB_WORKTREE_MAX',
@@ -95,7 +96,7 @@ const KIND_CONFIG = {
95
96
  defaultStaleSweepAgeMs: 24 * 60 * 60 * 1000,
96
97
  },
97
98
  epic: {
98
- root: path.join(os.tmpdir(), 'session-manager-epic-worktrees'),
99
+ get root() { return schedulerPaths.worktreeRoot('epic'); },
99
100
  branchPrefix: 'sm-epic/',
100
101
  disableEnv: 'SM_EPIC_WORKTREE_DISABLE',
101
102
  maxEnv: 'SM_EPIC_WORKTREE_MAX',
@@ -184,6 +185,13 @@ function isUnderRoot(p, root) {
184
185
  // corrected down to match reality when it's proven to have leaked.
185
186
  const activeWorktreeCount = { job: 0, epic: 0 };
186
187
 
188
+ // Registered checkouts this process created and has not yet cleaned up:
189
+ // dir -> { kind, branch }. Lets `expireDeadWorktreeRegistrations` prove a
190
+ // counted checkout is dead (no cwd holder + terminal row) and release its
191
+ // count — accounting only, never a kill or an rm.
192
+ const registeredCheckouts = new Map();
193
+ const expiredCheckoutDirs = new Set(); // released by expiry, awaiting their (late) cleanupWorktree
194
+
187
195
  // Per-kind promise chain used as a simple async mutex (see `withKindLock`)
188
196
  // so the cap-check-and-reserve decision in `reserveWorktreeSlot` — which now
189
197
  // sometimes needs to `await` a fresh on-disk observation — stays a single
@@ -216,6 +224,46 @@ function execGit(args, { cwd, timeout = 20_000 } = {}) {
216
224
  });
217
225
  }
218
226
 
227
+ // Unquote a single git porcelain v1 path token. Git wraps a path in double
228
+ // quotes and C-style-escapes it (\", \\, \t, \n, and \NNN octal per raw UTF-8
229
+ // byte) whenever it contains a double quote, backslash, control character, or
230
+ // any byte >= 0x80 (core.quotepath's default "ASCII-safe" behavior) — a plain
231
+ // path with none of those passes through untouched. Pure. Lives here (not
232
+ // scheduler.cjs, which also parses porcelain output) because scheduler.cjs
233
+ // requires this module — the reverse would be circular — and every porcelain
234
+ // consumer in either file must unquote a path before treating it as a real
235
+ // on-disk path (fs.existsSync, a pathspec, a Set membership check).
236
+ function unquotePorcelainPath(raw) {
237
+ if (raw.length < 2 || raw[0] !== '"' || raw[raw.length - 1] !== '"') return raw;
238
+ const inner = raw.slice(1, -1);
239
+ const bytes = [];
240
+ const simpleEscapes = { '"': 0x22, '\\': 0x5c, n: 0x0a, t: 0x09, r: 0x0d, a: 0x07, b: 0x08, f: 0x0c, v: 0x0b };
241
+ for (let i = 0; i < inner.length; i += 1) {
242
+ const c = inner[i];
243
+ if (c === '\\' && i + 1 < inner.length) {
244
+ const next = inner[i + 1];
245
+ if (Object.prototype.hasOwnProperty.call(simpleEscapes, next)) {
246
+ bytes.push(simpleEscapes[next]);
247
+ i += 1;
248
+ } else if (next >= '0' && next <= '7') {
249
+ let octal = '';
250
+ let j = i + 1;
251
+ while (j < inner.length && octal.length < 3 && inner[j] >= '0' && inner[j] <= '7') {
252
+ octal += inner[j];
253
+ j += 1;
254
+ }
255
+ bytes.push(parseInt(octal, 8) & 0xff);
256
+ i = j - 1;
257
+ } else {
258
+ bytes.push(c.charCodeAt(0));
259
+ }
260
+ } else {
261
+ for (const b of Buffer.from(c, 'utf8')) bytes.push(b);
262
+ }
263
+ }
264
+ return Buffer.from(bytes).toString('utf8');
265
+ }
266
+
219
267
  async function isGitRepo(cwd) {
220
268
  if (!cwd) return false;
221
269
  try {
@@ -282,11 +330,11 @@ async function removeWorktreeDir(cwd, dir) {
282
330
  /* best-effort */
283
331
  }
284
332
  const parentDir = path.dirname(dir);
285
- // Guard against ever rmdir-ing os.tmpdir() itself — every real caller's
333
+ // Guard against ever rmdir-ing the worktree base (os.tmpdir() or SM_WORKTREE_ROOT) itself — every real caller's
286
334
  // `dir` is `<kind root>/<hash>/<key>`, so `parentDir` is always the hash
287
335
  // dir, never the tmpdir root, but this keeps the guarantee explicit rather
288
336
  // than relying solely on call-site discipline.
289
- if (parentDir === os.tmpdir()) return;
337
+ if (parentDir === schedulerPaths.worktreeBase()) return;
290
338
  try {
291
339
  const remaining = await fsp.readdir(parentDir);
292
340
  if (remaining.length === 0) await fsp.rmdir(parentDir);
@@ -526,7 +574,11 @@ async function captureAndCarryBaseDiff({ cwd, dir }) {
526
574
  if (stat.size === 0) return { ok: true, paths: [] };
527
575
 
528
576
  try {
529
- await execGit(['apply', '--binary', patchFile], { cwd: dir, timeout: 30_000 });
577
+ // Same 120s ceiling as the capture step above: applying a patch large
578
+ // enough to need that long to generate can just as easily need that
579
+ // long to apply — leaving this at the old 30s reintroduces the same
580
+ // large-dirty-tree degrade-to-shared-tree failure one call later.
581
+ await execGit(['apply', '--binary', patchFile], { cwd: dir, timeout: 120_000 });
530
582
  } catch (e) {
531
583
  return { ok: false, reason: `git apply failed: ${(e && (e.stderrText || e.message)) || e}` };
532
584
  }
@@ -800,9 +852,31 @@ async function createWorktree({ kind, cwd, key }) {
800
852
  carriedPaths = carry.paths;
801
853
  }
802
854
 
855
+ registeredCheckouts.set(dir, { kind, branch });
803
856
  return { ok: true, dir, branch, baseCwd: cwd, carriedPaths };
804
857
  }
805
858
 
859
+ /**
860
+ * Release the count of any registered checkout whose branch has no live
861
+ * holder (no process cwd under its dir) AND whose row is terminal
862
+ * (`isTerminalBranch(branch)`). Fail-closed: an unknown/live row or a live
863
+ * holder keeps the count. Returns the expired dirs. O(registered) plus one
864
+ * /proc scan.
865
+ */
866
+ function expireDeadWorktreeRegistrations({ isTerminalBranch, holders = listCwdHolders() } = {}) {
867
+ const expired = [];
868
+ if (typeof isTerminalBranch !== 'function') return expired;
869
+ for (const [dir, reg] of [...registeredCheckouts]) {
870
+ if (hasLiveHolder(dir, holders)) continue;
871
+ if (!isTerminalBranch(reg.branch)) continue;
872
+ registeredCheckouts.delete(dir);
873
+ expiredCheckoutDirs.add(dir);
874
+ activeWorktreeCount[reg.kind] = Math.max(0, activeWorktreeCount[reg.kind] - 1);
875
+ expired.push(dir);
876
+ }
877
+ return expired;
878
+ }
879
+
806
880
  /**
807
881
  * True iff every path in `paths` has working-tree content in `cwd`
808
882
  * byte-identical to its committed blob on `branch`. Used both by the
@@ -1064,7 +1138,10 @@ async function cleanupWorktree({ kind, cwd, dir, branch, keepBranch }) {
1064
1138
  try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
1065
1139
  }
1066
1140
  try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
1067
- activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
1141
+ // A checkout the expiry pass already released must not be counted down twice.
1142
+ const alreadyExpired = dir ? expiredCheckoutDirs.delete(dir) : false;
1143
+ if (dir) registeredCheckouts.delete(dir);
1144
+ if (!alreadyExpired) activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
1068
1145
  }
1069
1146
 
1070
1147
  /**
@@ -1143,7 +1220,12 @@ async function salvageDirtyDelta({ cwd, paths, outFile }) {
1143
1220
  for (const line of String(statusOut || '').split('\n')) {
1144
1221
  if (!line) continue;
1145
1222
  const code = line.slice(0, 2);
1146
- const p = line.slice(3);
1223
+ // Unquote before use: a raw quoted+C-escaped token (any path with a
1224
+ // quote, backslash, control char, or non-ASCII byte) is not a real
1225
+ // on-disk path — passing it straight through as a pathspec to the
1226
+ // `git diff` calls below silently fails to match the real file,
1227
+ // dropping that path's content out of the salvaged patch entirely.
1228
+ const p = unquotePorcelainPath(line.slice(3));
1147
1229
  if (!p) continue;
1148
1230
  if (code === '??') untrackedPaths.push(p);
1149
1231
  else trackedPaths.push(p);
@@ -1301,7 +1383,10 @@ async function reclaimTerminalJobOrphans({ cwd, terminalSlugs, isLive }) {
1301
1383
  } catch {
1302
1384
  continue; // can't prove the checkout is clean-of-real-work — never assume
1303
1385
  }
1304
- const dirtyPaths = statusOut.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
1386
+ const dirtyPaths = statusOut.split('\n')
1387
+ .filter((l) => l.length > 0)
1388
+ .map((l) => unquotePorcelainPath(l.slice(3).trim()))
1389
+ .filter(Boolean);
1305
1390
  const opsOnly = dirtyPaths.every(
1306
1391
  (p) => p === OPS_ROOT_DIR || p.startsWith(`${OPS_ROOT_DIR}/`)
1307
1392
  );
@@ -1432,6 +1517,7 @@ module.exports = {
1432
1517
  getMaxConcurrentWorktrees,
1433
1518
  getStaleSweepAgeMs,
1434
1519
  isGitRepo,
1520
+ unquotePorcelainPath,
1435
1521
  isBaseTreeClean,
1436
1522
  worktreeDirFor,
1437
1523
  branchNameFor,
@@ -1454,6 +1540,7 @@ module.exports = {
1454
1540
  mainTreeFromWorktreeGitFile,
1455
1541
  getObservedWorktreeCount,
1456
1542
  reserveWorktreeSlot,
1543
+ expireDeadWorktreeRegistrations,
1457
1544
  reclaimTerminalJobOrphans,
1458
1545
  // Job-kind convenience wrappers — same call shape jobWorktree.cjs has
1459
1546
  // always exposed.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * guardShims.cjs — stable, app-upgrade-proof indirection for the three
3
- * PreToolUse guard hooks that delegationReadiness.cjs installs into an
2
+ * guardShims.cjs — stable, app-upgrade-proof indirection for every
3
+ * PreToolUse guard hook (each name in GUARD_NAMES) that delegationReadiness.cjs installs into an
4
4
  * adopting project's `.claude/settings.json`.
5
5
  *
6
6
  * Why this exists: those installers used to bake
@@ -66,7 +66,7 @@ require(path.join(ptr.appRoot, 'scripts', 'hooks', '${guardName}'));
66
66
  }
67
67
 
68
68
  /**
69
- * Write the pointer file + all three shims under `homeDir`. Idempotent: a
69
+ * Write the pointer file + every shim in GUARD_NAMES under `homeDir`. Idempotent: a
70
70
  * re-run with the same `appRoot` produces byte-identical files; a re-run with
71
71
  * a DIFFERENT `appRoot` (an app upgrade) rewrites only the pointer, which is
72
72
  * exactly what lets already-installed project hooks follow the upgrade with
@@ -26,10 +26,9 @@
26
26
  'use strict';
27
27
 
28
28
  const path = require('node:path');
29
- const os = require('node:os');
30
29
  const config = require('../config.cjs');
31
30
 
32
- const ROLLUP_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'history-rollup.jsonl');
31
+ const { historyRollupPath } = require('./schedulerPaths.cjs');
33
32
  const COMPACT_THRESHOLD_BYTES = 5 * 1024 * 1024;
34
33
 
35
34
  const TOTALS_MODEL_ID = '';
@@ -75,7 +74,7 @@ function mergeRollupLines(lines) {
75
74
  * only want data lines should filter on modelId/projectDir.
76
75
  */
77
76
  async function readRollup(fromDate, toDate) {
78
- const { exists, text } = await config.readText(ROLLUP_PATH);
77
+ const { exists, text } = await config.readText(historyRollupPath());
79
78
  if (!exists || !text) return new Map();
80
79
  const merged = mergeRollupLines(text.split('\n'));
81
80
  const filtered = new Map();
@@ -108,7 +107,7 @@ function isDateFinalized(mergedMap, date) {
108
107
  async function appendRollupDays(entries) {
109
108
  if (!entries || entries.length === 0) return;
110
109
 
111
- const real = config.validatePath(ROLLUP_PATH);
110
+ const real = config.validatePath(historyRollupPath());
112
111
  config.validateWrite(real);
113
112
  const fsp = require('node:fs/promises');
114
113
  await fsp.mkdir(path.dirname(real), { recursive: true });
@@ -125,15 +124,15 @@ async function appendRollupDays(entries) {
125
124
 
126
125
  /** Dedupe the rollup file down to its last-write-wins merged lines. */
127
126
  async function compact() {
128
- const { exists, text } = await config.readText(ROLLUP_PATH);
127
+ const { exists, text } = await config.readText(historyRollupPath());
129
128
  if (!exists || !text) return;
130
129
  const merged = mergeRollupLines(text.split('\n'));
131
130
  const rewritten = Array.from(merged.values()).map((b) => JSON.stringify(b)).join('\n') + '\n';
132
- await config.writeTextAtomic(ROLLUP_PATH, rewritten);
131
+ await config.writeTextAtomic(historyRollupPath(), rewritten);
133
132
  }
134
133
 
135
134
  module.exports = {
136
- ROLLUP_PATH,
135
+ historyRollupPath,
137
136
  COMPACT_THRESHOLD_BYTES,
138
137
  TOTALS_MODEL_ID,
139
138
  FINALIZED_MODEL_ID,
@@ -26,14 +26,16 @@
26
26
  'use strict';
27
27
 
28
28
  const fs = require('node:fs');
29
- const os = require('node:os');
30
29
  const path = require('node:path');
30
+ const { identity, isDifferentProcess } = require('./procIdentity.cjs');
31
+ const { instanceLockPath } = require('./schedulerPaths.cjs');
32
+ const { appendAuditEvent } = require('./auditLog.cjs');
31
33
 
32
34
  // Env override is for unit tests only (isolates the lock from the real
33
35
  // ~/.claude of the machine running the suite).
34
36
  function lockPath() {
35
37
  return process.env.SM_SCHEDULER_LOCK_PATH
36
- || path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-owner.lock');
38
+ || instanceLockPath();
37
39
  }
38
40
 
39
41
  function pidAlive(pid) {
@@ -58,7 +60,11 @@ function readLock() {
58
60
  }
59
61
 
60
62
  function writeLockExclusive() {
61
- const body = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
63
+ const body = JSON.stringify({
64
+ pid: process.pid,
65
+ startedAt: new Date().toISOString(),
66
+ identity: identity(process.pid),
67
+ });
62
68
  fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
63
69
  // 'wx' = atomic create-or-fail — two racing instances cannot both win.
64
70
  fs.writeFileSync(lockPath(), body, { flag: 'wx', mode: 0o600 });
@@ -67,14 +73,35 @@ function writeLockExclusive() {
67
73
  /**
68
74
  * Try to become the machine's scheduler owner.
69
75
  * Returns { owner: true } or { owner: false, holderPid } — never throws.
76
+ *
77
+ * A holder pid that is alive is normally respected — but if the lock body
78
+ * carries a COMPLETE recorded identity that provably differs from the live
79
+ * process now holding that pid (procIdentity.isDifferentProcess), the pid
80
+ * was recycled after a hard crash: today's bare pidAlive() check would pin
81
+ * ownership on that unrelated live process forever. That case is treated as
82
+ * stale and the lock is broken (audited as `stale_lock_broken`). A missing
83
+ * identity (legacy lock body, or an unreadable /proc on either side) is
84
+ * fail-closed to "not different" — i.e. today's pid-only behaviour.
70
85
  */
71
86
  function acquireSchedulerOwnership() {
72
87
  for (let attempt = 0; attempt < 2; attempt++) {
73
88
  const existing = readLock();
74
89
  if (existing && existing.pid !== process.pid && pidAlive(existing.pid)) {
75
- return { owner: false, holderPid: existing.pid };
90
+ const liveIdentity = identity(existing.pid);
91
+ if (!isDifferentProcess(existing.identity, liveIdentity)) {
92
+ return { owner: false, holderPid: existing.pid };
93
+ }
94
+ appendAuditEvent('stale_lock_broken', {
95
+ path: lockPath(),
96
+ recordedPid: existing.pid,
97
+ recordedStartedAt: existing.startedAt ?? null,
98
+ recordedIdentity: existing.identity ?? null,
99
+ liveIdentity,
100
+ });
76
101
  }
77
- // Missing, unreadable, our own, or stale (holder pid dead) — take it.
102
+ // Missing, unreadable, our own, stale (holder pid dead), or a recorded
103
+ // identity proven different from the live process at that recycled pid
104
+ // — take it.
78
105
  try {
79
106
  if (existing || fs.existsSync(lockPath())) fs.unlinkSync(lockPath());
80
107
  } catch { /* raced with another breaker — retry loop below settles it */ }
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * jobSupervisorRecord.cjs — the one authoritative, SYNCHRONOUS record of a
5
+ * dispatch, written at the same point scheduler.cjs logs `[scheduler] spawned
6
+ * pid=`.
7
+ *
8
+ * Before this, the only durable trace of a dispatch was runtime={pid,...},
9
+ * written asynchronously by a mutate() inside the onPid callback, and the run
10
+ * dir held only the log until an exit path wrote meta.json. A booting process
11
+ * therefore had nothing to tell "my run, still alive" from "dead" and fell
12
+ * back to a ladder of inferences. `<runDir>/<slug>.supervisor.json` is that
13
+ * missing fact.
14
+ *
15
+ * Plain Node, no electron. The run dir is machine-wide scheduler state (not
16
+ * an ops namespace), so queueStore's writeJsonAtomicSync is the right writer.
17
+ * This module never signals a process and adds no cap accounting.
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const { writeJsonAtomicSync } = require('./queueStore.cjs');
23
+ const { isDifferentProcess } = require('./procIdentity.cjs');
24
+
25
+ const SUFFIX = '.supervisor.json';
26
+ const META_SUFFIX = '.meta.json';
27
+ const INVESTIGATION_EXIT_RE = /\[scheduler\] investigation exit code=/;
28
+ const EXIT_TAIL_BYTES = 8 * 1024;
29
+
30
+ function recordPath(runDir, slug) {
31
+ return path.join(runDir, `${slug}${SUFFIX}`);
32
+ }
33
+
34
+ /**
35
+ * Writes `<runDir>/<slug>.supervisor.json` atomically. Throws on failure —
36
+ * the caller owns the "never fail the dispatch" try/catch. `kind` defaults to
37
+ * "job"; an investigation probe passes kind: "investigation" (and a slug of
38
+ * `<failedSlug>.investigation`, so it never overwrites the failed job's own
39
+ * record in the shared run dir).
40
+ */
41
+ function writeSupervisorRecord({
42
+ runDir, slug, cwd, runId, pid, pgid, identity, execCwd, worktreeDir, worktreeBranch,
43
+ sessionId, startedAt, budgetMs, maxDurationMs, idleKillMs, schedulerPid, codeSha, kind,
44
+ }) {
45
+ if (!runDir || !slug) throw new Error('writeSupervisorRecord: runDir and slug are required');
46
+ const record = {
47
+ kind: kind || 'job',
48
+ slug, cwd: cwd ?? null, runId: runId ?? null,
49
+ pid: pid ?? null, pgid: pgid ?? null, identity: identity ?? null,
50
+ execCwd: execCwd ?? null, worktreeDir: worktreeDir ?? null, worktreeBranch: worktreeBranch ?? null,
51
+ sessionId: sessionId ?? null, startedAt: startedAt ?? null,
52
+ budgetMs: budgetMs ?? null, maxDurationMs: maxDurationMs ?? null, idleKillMs: idleKillMs ?? null,
53
+ schedulerPid: schedulerPid ?? null, codeSha: codeSha ?? null,
54
+ };
55
+ writeJsonAtomicSync(recordPath(runDir, slug), record);
56
+ return record;
57
+ }
58
+
59
+ /** Returns the parsed record, or null when absent/unparseable. Never throws. */
60
+ function readSupervisorRecord(runDir, slug) {
61
+ try {
62
+ const rec = JSON.parse(fs.readFileSync(recordPath(runDir, slug), 'utf8'));
63
+ return rec && typeof rec === 'object' ? rec : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * True when the run has a durable exit marker: `<slug>.meta.json` for a job,
71
+ * the `investigation exit code=` log line for a probe. This is what separates
72
+ * "exited" from "dead" — a dead pid with no marker never got to write one.
73
+ */
74
+ function hasExitMarker(runDir, record) {
75
+ try {
76
+ if (record.kind === 'investigation') {
77
+ const failedSlug = String(record.slug).replace(/\.investigation$/, '');
78
+ const logPath = path.join(runDir, `${failedSlug}.investigation.log`);
79
+ const { size } = fs.statSync(logPath);
80
+ const fd = fs.openSync(logPath, 'r');
81
+ try {
82
+ const len = Math.min(size, EXIT_TAIL_BYTES);
83
+ const buf = Buffer.alloc(len);
84
+ fs.readSync(fd, buf, 0, len, size - len);
85
+ return INVESTIGATION_EXIT_RE.test(buf.toString('utf8'));
86
+ } finally {
87
+ fs.closeSync(fd);
88
+ }
89
+ }
90
+ return fs.existsSync(path.join(runDir, `${record.slug}${META_SUFFIX}`));
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Every record under `runsDir/<runId>/` whose file was written within
98
+ * maxAgeMs and which has no exit marker yet. Each entry is the record plus
99
+ * `runDir`. Never throws; an unreadable dir yields [].
100
+ * Complexity: O(run dirs + files in recent dirs).
101
+ */
102
+ function listLiveSupervisorRecords(runsDir, { maxAgeMs, now = Date.now() } = {}) {
103
+ const out = [];
104
+ let dirs;
105
+ try { dirs = fs.readdirSync(runsDir, { withFileTypes: true }); } catch { return out; }
106
+ for (const d of dirs) {
107
+ if (!d.isDirectory()) continue;
108
+ const runDir = path.join(runsDir, d.name);
109
+ let files;
110
+ try { files = fs.readdirSync(runDir); } catch { continue; }
111
+ for (const f of files) {
112
+ if (!f.endsWith(SUFFIX)) continue;
113
+ try {
114
+ if (Number.isFinite(maxAgeMs) && now - fs.statSync(path.join(runDir, f)).mtimeMs > maxAgeMs) continue;
115
+ const record = readSupervisorRecord(runDir, f.slice(0, -SUFFIX.length));
116
+ if (!record || hasExitMarker(runDir, record)) continue;
117
+ out.push({ ...record, runDir });
118
+ } catch { /* raced with a delete — skip */ }
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Pure. What should a booting process do with this record?
126
+ * - not alive + exit marker → 'exited' (finished; outcome is on disk)
127
+ * - not alive, no marker → 'dead' (died without writing an outcome)
128
+ * - alive, different process → 'foreign-pid' (pid recycled; never ours)
129
+ * - alive, ours, past budget or log stalled past idleKillMs → 'over-budget'
130
+ * - alive, ours → 'adopt'
131
+ * `identity` is the live probe of record.pid; isDifferentProcess is
132
+ * fail-closed, so an incomplete identity on either side reads as "same".
133
+ */
134
+ function classifyAdoption(record, { identity, pidAlive, logMtimeMs, exitMarker, now = Date.now() } = {}) {
135
+ if (!pidAlive) return exitMarker ? 'exited' : 'dead';
136
+ if (exitMarker) return 'exited';
137
+ if (isDifferentProcess(record.identity, identity)) return 'foreign-pid';
138
+ const ceiling = record.budgetMs > 0 ? record.budgetMs : (record.maxDurationMs > 0 ? record.maxDurationMs : 0);
139
+ if (ceiling > 0 && Number.isFinite(record.startedAt) && now - record.startedAt > ceiling) return 'over-budget';
140
+ if (record.idleKillMs > 0 && Number.isFinite(logMtimeMs) && now - logMtimeMs > record.idleKillMs) return 'over-budget';
141
+ return 'adopt';
142
+ }
143
+
144
+ module.exports = {
145
+ writeSupervisorRecord, readSupervisorRecord, listLiveSupervisorRecords, classifyAdoption, hasExitMarker,
146
+ SUFFIX,
147
+ };
@@ -22,15 +22,19 @@
22
22
  */
23
23
 
24
24
  /**
25
- * @param {{ bootJobs: Array<object>, claudePidAlive: (pid: number) => boolean, hasLiveHolder: (dir: string, holders?: Set<string>) => boolean, cwdHolders?: Set<string> }} deps
25
+ * `rowPid(job)` resolves a running row's pid; scheduler.cjs passes the same
26
+ * supervisor-record → runtime.pid → run-log ladder partitionBootOrphans uses,
27
+ * so the two boot passes agree. Default: runtime.pid alone.
28
+ *
29
+ * @param {{ bootJobs: Array<object>, claudePidAlive: (pid: number) => boolean, hasLiveHolder: (dir: string, holders?: Set<string>) => boolean, cwdHolders?: Set<string>, rowPid?: (job: object) => number|null }} deps
26
30
  * @returns {(slug: string, entry: { worktree: string, branch: string|null }) => boolean}
27
31
  */
28
- function buildJobWorktreeIsLive({ bootJobs, claudePidAlive, hasLiveHolder, cwdHolders }) {
32
+ function buildJobWorktreeIsLive({ bootJobs, claudePidAlive, hasLiveHolder, cwdHolders, rowPid = (j) => j.runtime && j.runtime.pid }) {
29
33
  const runningPidBySlug = new Map();
30
34
  for (const j of Array.isArray(bootJobs) ? bootJobs : []) {
31
- if (j && j.status === 'running' && j.runtime && j.runtime.pid) {
32
- runningPidBySlug.set(j.slug, j.runtime.pid);
33
- }
35
+ if (!j || j.status !== 'running') continue;
36
+ const pid = rowPid(j);
37
+ if (pid) runningPidBySlug.set(j.slug, pid);
34
38
  }
35
39
 
36
40
  return function isLive(slug, entry) {
@@ -32,40 +32,20 @@
32
32
  'use strict';
33
33
 
34
34
  const http = require('node:http');
35
- const os = require('node:os');
36
35
  const path = require('node:path');
37
36
  const crypto = require('node:crypto');
38
37
  const fsp = require('node:fs/promises');
39
38
  const config = require('../config.cjs');
40
-
41
- const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
39
+ const schedulerPaths = require('./schedulerPaths.cjs');
42
40
 
43
41
  /**
44
- * Mode-aware token file path, resolved fresh on every call (not cached at
42
+ * Mode-aware token file path, resolved fresh on every call (never cached at
45
43
  * module load) so a dev/e2e launch never collides with a production
46
- * instance's admin-api.json. index.cjs treats SM_DEV and SM_E2E as one OR'd
47
- * `isDev` boolean with no ordering between them; if both happen to be set,
48
- * this resolves to the SM_DEV path (checked first below) — an arbitrary but
49
- * harmless tie-break, since real launches only ever set one or the other.
44
+ * instance's admin-api.json. The SM_ADMIN_TOKEN_PATH / SM_DEV / SM_E2E ladder
45
+ * lives in schedulerPaths.adminTokenPath() (the SM_ADMIN_TOKEN_PATH override
46
+ * exists because unit tests once overwrote the live app's port+token).
50
47
  */
51
- function resolveTokenPath() {
52
- // Explicit override, highest priority. Exists because the unit tests call
53
- // createAdminHttp().start() directly with neither SM_DEV nor SM_E2E set,
54
- // which resolved to the PRODUCTION admin-api.json and overwrote the
55
- // running app's port+token with a throwaway test server's — silently
56
- // orphaning scheduler-mcp-server.cjs's access to the live app until the
57
- // next app restart (confirmed live 2026-08-01: `npm run test:unit` made
58
- // every scheduler_* MCP tool report "app is not running"). Tests point
59
- // this at a tmp file; production never sets it.
60
- if (process.env.SM_ADMIN_TOKEN_PATH) return process.env.SM_ADMIN_TOKEN_PATH;
61
- if (process.env.SM_DEV === '1') {
62
- return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.dev.json');
63
- }
64
- if (process.env.SM_E2E === '1') {
65
- return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.e2e.json');
66
- }
67
- return TOKEN_PATH;
68
- }
48
+ const resolveTokenPath = schedulerPaths.adminTokenPath;
69
49
 
70
50
  // Reserved health-check path, handled directly (not via registerRoute) so
71
51
  // start()'s post-boot self-check never depends on route-registration order
@@ -238,9 +218,11 @@ function createAdminHttp() {
238
218
 
239
219
  module.exports = {
240
220
  createAdminHttp,
241
- TOKEN_PATH,
242
221
  resolveTokenPath,
243
222
  timingSafeEqualStrings,
244
223
  readBody,
245
224
  sendJson,
246
225
  };
226
+
227
+ // Lazy getter (the token path is mode-aware and env-driven; never frozen at require).
228
+ Object.defineProperty(module.exports, 'TOKEN_PATH', { get: () => schedulerPaths.adminTokenPath(), enumerable: true });