dsh-compound-engineering 0.0.0-stage → 0.1.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 (461) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/README.zh.md +161 -0
  4. package/cordis.patch.yml +6 -0
  5. package/lib/frontmatter.js +167 -0
  6. package/lib/index.js +46 -0
  7. package/lib/provider.js +201 -0
  8. package/package.json +62 -4
  9. package/skills/CODING_STANDARDS.md +68 -0
  10. package/skills/ce-babysit-pr/SKILL.md +59 -0
  11. package/skills/ce-babysit-pr/references/branch-currency.md +22 -0
  12. package/skills/ce-babysit-pr/references/envelope.md +44 -0
  13. package/skills/ce-babysit-pr/references/pipeline.md +15 -0
  14. package/skills/ce-babysit-pr/references/report.md +20 -0
  15. package/skills/ce-babysit-pr/references/settle.md +37 -0
  16. package/skills/ce-babysit-pr/references/setup.md +33 -0
  17. package/skills/ce-babysit-pr/references/stack-commands.md +39 -0
  18. package/skills/ce-babysit-pr/references/stack.md +43 -0
  19. package/skills/ce-babysit-pr/references/tick.md +102 -0
  20. package/skills/ce-babysit-pr/references/watch-loop.md +222 -0
  21. package/skills/ce-babysit-pr/scripts/pr-snapshot +3656 -0
  22. package/skills/ce-bakeoff/SKILL.md +48 -0
  23. package/skills/ce-bakeoff/references/candidates.md +37 -0
  24. package/skills/ce-bakeoff/references/judging.md +19 -0
  25. package/skills/ce-bakeoff/references/output.md +11 -0
  26. package/skills/ce-bakeoff/references/verification.md +15 -0
  27. package/skills/ce-brainstorm/SKILL.md +69 -0
  28. package/skills/ce-brainstorm/references/agents/slack-researcher.md +127 -0
  29. package/skills/ce-brainstorm/references/approaches.md +57 -0
  30. package/skills/ce-brainstorm/references/bakeoff.md +9 -0
  31. package/skills/ce-brainstorm/references/blindspot-pass.md +70 -0
  32. package/skills/ce-brainstorm/references/brainstorm-sections.md +458 -0
  33. package/skills/ce-brainstorm/references/dialogue.md +89 -0
  34. package/skills/ce-brainstorm/references/handoff.md +187 -0
  35. package/skills/ce-brainstorm/references/html-rendering.md +638 -0
  36. package/skills/ce-brainstorm/references/interaction-rules.md +33 -0
  37. package/skills/ce-brainstorm/references/markdown-rendering.md +242 -0
  38. package/skills/ce-brainstorm/references/model-tiers.md +13 -0
  39. package/skills/ce-brainstorm/references/output-mode.md +26 -0
  40. package/skills/ce-brainstorm/references/phase-0.md +104 -0
  41. package/skills/ce-brainstorm/references/plan-write.md +32 -0
  42. package/skills/ce-brainstorm/references/product-pressure-test.md +42 -0
  43. package/skills/ce-brainstorm/references/reasoning-elevation.md +124 -0
  44. package/skills/ce-brainstorm/references/settled-decisions.md +45 -0
  45. package/skills/ce-brainstorm/references/synthesis-summary.md +149 -0
  46. package/skills/ce-brainstorm/references/universal-brainstorming.md +72 -0
  47. package/skills/ce-brainstorm/references/verdict-routing.md +26 -0
  48. package/skills/ce-brainstorm/references/visual-probes.md +164 -0
  49. package/skills/ce-brainstorm/scripts/elevation-dispatch.sh +288 -0
  50. package/skills/ce-brainstorm/scripts/light-webserver.js +1275 -0
  51. package/skills/ce-brainstorm/scripts/packs-resolve.py +737 -0
  52. package/skills/ce-brainstorm/scripts/peer-job-runner.py +2594 -0
  53. package/skills/ce-code-review/SKILL.md +40 -0
  54. package/skills/ce-code-review/references/action-class-rubric.md +67 -0
  55. package/skills/ce-code-review/references/cross-model-recovery.md +38 -0
  56. package/skills/ce-code-review/references/cross-model-review.md +191 -0
  57. package/skills/ce-code-review/references/depth-paths.md +32 -0
  58. package/skills/ce-code-review/references/diff-scope.md +59 -0
  59. package/skills/ce-code-review/references/dispatch-reviewers.md +135 -0
  60. package/skills/ce-code-review/references/findings-schema.json +125 -0
  61. package/skills/ce-code-review/references/finish-input.md +118 -0
  62. package/skills/ce-code-review/references/finish-review.md +202 -0
  63. package/skills/ce-code-review/references/intent-and-plan.md +73 -0
  64. package/skills/ce-code-review/references/modes-and-output.md +156 -0
  65. package/skills/ce-code-review/references/persona-catalog.md +66 -0
  66. package/skills/ce-code-review/references/personas/adversarial-reviewer.md +111 -0
  67. package/skills/ce-code-review/references/personas/agent-native-reviewer.md +173 -0
  68. package/skills/ce-code-review/references/personas/api-contract-reviewer.md +44 -0
  69. package/skills/ce-code-review/references/personas/correctness-reviewer.md +48 -0
  70. package/skills/ce-code-review/references/personas/data-migration-reviewer.md +111 -0
  71. package/skills/ce-code-review/references/personas/deployment-verification-agent.md +157 -0
  72. package/skills/ce-code-review/references/personas/julik-frontend-races-reviewer.md +45 -0
  73. package/skills/ce-code-review/references/personas/learnings-researcher.md +260 -0
  74. package/skills/ce-code-review/references/personas/maintainability-reviewer.md +85 -0
  75. package/skills/ce-code-review/references/personas/performance-reviewer.md +45 -0
  76. package/skills/ce-code-review/references/personas/previous-comments-reviewer.md +59 -0
  77. package/skills/ce-code-review/references/personas/project-standards-reviewer.md +77 -0
  78. package/skills/ce-code-review/references/personas/reliability-reviewer.md +49 -0
  79. package/skills/ce-code-review/references/personas/security-reviewer.md +50 -0
  80. package/skills/ce-code-review/references/personas/swift-ios-reviewer.md +99 -0
  81. package/skills/ce-code-review/references/personas/testing-reviewer.md +51 -0
  82. package/skills/ce-code-review/references/review-output-template.md +172 -0
  83. package/skills/ce-code-review/references/scope.md +176 -0
  84. package/skills/ce-code-review/references/select-and-route.md +93 -0
  85. package/skills/ce-code-review/references/subagent-template.md +206 -0
  86. package/skills/ce-code-review/references/validator-batch-template.md +67 -0
  87. package/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +1275 -0
  88. package/skills/ce-code-review/scripts/findings-mechanics.py +274 -0
  89. package/skills/ce-code-review/scripts/packs-resolve.py +737 -0
  90. package/skills/ce-code-review/scripts/peer-job-runner.py +2594 -0
  91. package/skills/ce-code-review/scripts/review-scope.py +419 -0
  92. package/skills/ce-code-review/scripts/run-log.py +280 -0
  93. package/skills/ce-commit/SKILL.md +60 -0
  94. package/skills/ce-commit-push-pr/SKILL.md +69 -0
  95. package/skills/ce-commit-push-pr/references/apply-and-handoff.md +79 -0
  96. package/skills/ce-commit-push-pr/references/branch-creation.md +47 -0
  97. package/skills/ce-commit-push-pr/references/commit-and-push.md +26 -0
  98. package/skills/ce-commit-push-pr/references/compose.md +29 -0
  99. package/skills/ce-commit-push-pr/references/context.md +42 -0
  100. package/skills/ce-commit-push-pr/references/gh-stack-cli.md +83 -0
  101. package/skills/ce-commit-push-pr/references/pr-description-writing.md +208 -0
  102. package/skills/ce-commit-push-pr/references/stack-submit.md +81 -0
  103. package/skills/ce-compound/SKILL.md +79 -0
  104. package/skills/ce-compound/assets/resolution-template.md +96 -0
  105. package/skills/ce-compound/references/agents/best-practices-researcher.md +115 -0
  106. package/skills/ce-compound/references/agents/data-integrity-guardian.md +68 -0
  107. package/skills/ce-compound/references/agents/framework-docs-researcher.md +93 -0
  108. package/skills/ce-compound/references/agents/pattern-recognition-specialist.md +55 -0
  109. package/skills/ce-compound/references/agents/performance-oracle.md +108 -0
  110. package/skills/ce-compound/references/agents/security-sentinel.md +91 -0
  111. package/skills/ce-compound/references/agents/session-historian.md +82 -0
  112. package/skills/ce-compound/references/assembly.md +127 -0
  113. package/skills/ce-compound/references/concepts-vocabulary.md +98 -0
  114. package/skills/ce-compound/references/enhancement.md +42 -0
  115. package/skills/ce-compound/references/grounding-validation.md +89 -0
  116. package/skills/ce-compound/references/lightweight.md +56 -0
  117. package/skills/ce-compound/references/modes.md +13 -0
  118. package/skills/ce-compound/references/refresh-and-discoverability.md +96 -0
  119. package/skills/ce-compound/references/report.md +115 -0
  120. package/skills/ce-compound/references/research.md +144 -0
  121. package/skills/ce-compound/references/schema.yaml +236 -0
  122. package/skills/ce-compound/references/session-history.md +73 -0
  123. package/skills/ce-compound/references/yaml-schema.md +130 -0
  124. package/skills/ce-compound/scripts/packs-resolve.py +737 -0
  125. package/skills/ce-compound/scripts/session-history/__pycache__/dsh_session.cpython-314.pyc +0 -0
  126. package/skills/ce-compound/scripts/session-history/discover-sessions.sh +290 -0
  127. package/skills/ce-compound/scripts/session-history/discover_dsh.py +123 -0
  128. package/skills/ce-compound/scripts/session-history/dsh_session.py +225 -0
  129. package/skills/ce-compound/scripts/session-history/extract-errors.py +178 -0
  130. package/skills/ce-compound/scripts/session-history/extract-metadata.py +301 -0
  131. package/skills/ce-compound/scripts/session-history/extract-skeleton.py +346 -0
  132. package/skills/ce-compound/scripts/validate-doc-claims.py +497 -0
  133. package/skills/ce-compound/scripts/validate-frontmatter.py +137 -0
  134. package/skills/ce-compound-refresh/SKILL.md +88 -0
  135. package/skills/ce-compound-refresh/assets/resolution-template.md +96 -0
  136. package/skills/ce-compound-refresh/references/classify.md +42 -0
  137. package/skills/ce-compound-refresh/references/commit.md +7 -0
  138. package/skills/ce-compound-refresh/references/concepts-vocabulary.md +116 -0
  139. package/skills/ce-compound-refresh/references/discoverability.md +15 -0
  140. package/skills/ce-compound-refresh/references/investigate.md +23 -0
  141. package/skills/ce-compound-refresh/references/modes.md +27 -0
  142. package/skills/ce-compound-refresh/references/per-action-flows.md +142 -0
  143. package/skills/ce-compound-refresh/references/report.md +23 -0
  144. package/skills/ce-compound-refresh/references/schema.yaml +236 -0
  145. package/skills/ce-compound-refresh/references/scope.md +18 -0
  146. package/skills/ce-compound-refresh/references/worth-audit.md +35 -0
  147. package/skills/ce-compound-refresh/references/yaml-schema.md +130 -0
  148. package/skills/ce-compound-refresh/scripts/validate-doc-claims.py +497 -0
  149. package/skills/ce-compound-refresh/scripts/validate-frontmatter.py +137 -0
  150. package/skills/ce-debug/SKILL.md +122 -0
  151. package/skills/ce-debug/references/anti-patterns.md +91 -0
  152. package/skills/ce-debug/references/defense-in-depth.md +39 -0
  153. package/skills/ce-debug/references/fix.md +21 -0
  154. package/skills/ce-debug/references/investigate.md +114 -0
  155. package/skills/ce-debug/references/investigation-techniques.md +384 -0
  156. package/skills/ce-debug/references/pipeline-mode.md +78 -0
  157. package/skills/ce-debug/references/post-fix-handoff.md +60 -0
  158. package/skills/ce-debug/references/return-to-caller.md +57 -0
  159. package/skills/ce-doc-review/SKILL.md +75 -0
  160. package/skills/ce-doc-review/references/bulk-preview.md +146 -0
  161. package/skills/ce-doc-review/references/cross-model-review.md +207 -0
  162. package/skills/ce-doc-review/references/decision-primer.md +47 -0
  163. package/skills/ce-doc-review/references/dispatch.md +56 -0
  164. package/skills/ce-doc-review/references/document-intake.md +44 -0
  165. package/skills/ce-doc-review/references/findings-schema.json +85 -0
  166. package/skills/ce-doc-review/references/modes.md +20 -0
  167. package/skills/ce-doc-review/references/open-questions-defer.md +158 -0
  168. package/skills/ce-doc-review/references/persona-selection.md +41 -0
  169. package/skills/ce-doc-review/references/personas/adversarial-document-reviewer.md +109 -0
  170. package/skills/ce-doc-review/references/personas/coherence-reviewer.md +69 -0
  171. package/skills/ce-doc-review/references/personas/design-lens-reviewer.md +48 -0
  172. package/skills/ce-doc-review/references/personas/feasibility-reviewer.md +50 -0
  173. package/skills/ce-doc-review/references/personas/product-lens-reviewer.md +85 -0
  174. package/skills/ce-doc-review/references/personas/scope-guardian-reviewer.md +83 -0
  175. package/skills/ce-doc-review/references/personas/security-lens-reviewer.md +41 -0
  176. package/skills/ce-doc-review/references/personas/whole-doc-reviewer.md +25 -0
  177. package/skills/ce-doc-review/references/rendering-floor.md +175 -0
  178. package/skills/ce-doc-review/references/review-output-template.md +123 -0
  179. package/skills/ce-doc-review/references/subagent-template.md +188 -0
  180. package/skills/ce-doc-review/references/synthesis-and-presentation.md +350 -0
  181. package/skills/ce-doc-review/references/walkthrough.md +347 -0
  182. package/skills/ce-doc-review/scripts/cross-model-doc-review.sh +1270 -0
  183. package/skills/ce-doc-review/scripts/packs-resolve.py +737 -0
  184. package/skills/ce-doc-review/scripts/peer-job-runner.py +2594 -0
  185. package/skills/ce-dogfood/SKILL.md +66 -0
  186. package/skills/ce-dogfood/agents/openai.yaml +2 -0
  187. package/skills/ce-dogfood/references/dogfood-report-template.md +97 -0
  188. package/skills/ce-dogfood/references/phases.md +167 -0
  189. package/skills/ce-dogfood/references/test-matrix-taxonomy.md +62 -0
  190. package/skills/ce-dogfood/scripts/packs-resolve.py +737 -0
  191. package/skills/ce-explain/SKILL.md +81 -0
  192. package/skills/ce-explain/references/agents/behavior-trace-scout.md +19 -0
  193. package/skills/ce-explain/references/agents/work-recap-scout.md +23 -0
  194. package/skills/ce-explain/references/check-in.md +18 -0
  195. package/skills/ce-explain/references/destinations.md +35 -0
  196. package/skills/ce-explain/references/explainer-html.md +29 -0
  197. package/skills/ce-explain/references/explainer-markdown.md +24 -0
  198. package/skills/ce-explain/references/intake.md +50 -0
  199. package/skills/ce-explain/references/orchestration.md +46 -0
  200. package/skills/ce-handoff/SKILL.md +54 -0
  201. package/skills/ce-handoff/references/create.md +88 -0
  202. package/skills/ce-handoff/references/resume.md +46 -0
  203. package/skills/ce-ideate/SKILL.md +75 -0
  204. package/skills/ce-ideate/references/agents/issue-intelligence-analyst.md +166 -0
  205. package/skills/ce-ideate/references/agents/learnings-researcher.md +247 -0
  206. package/skills/ce-ideate/references/agents/slack-researcher.md +127 -0
  207. package/skills/ce-ideate/references/agents/web-researcher.md +121 -0
  208. package/skills/ce-ideate/references/decomposition.md +39 -0
  209. package/skills/ce-ideate/references/divergent-ideation.md +112 -0
  210. package/skills/ce-ideate/references/grounding.md +138 -0
  211. package/skills/ce-ideate/references/html-rendering.md +638 -0
  212. package/skills/ce-ideate/references/ideation-sections.md +191 -0
  213. package/skills/ce-ideate/references/issue-intelligence.md +44 -0
  214. package/skills/ce-ideate/references/markdown-rendering.md +242 -0
  215. package/skills/ce-ideate/references/output-mode.md +34 -0
  216. package/skills/ce-ideate/references/post-ideation-workflow.md +169 -0
  217. package/skills/ce-ideate/references/scope-gates.md +148 -0
  218. package/skills/ce-ideate/references/universal-ideation.md +119 -0
  219. package/skills/ce-ideate/references/user-research-artifacts.md +28 -0
  220. package/skills/ce-ideate/references/web-research-cache.md +56 -0
  221. package/skills/ce-noslop/SKILL.md +45 -0
  222. package/skills/ce-noslop/references/patterns.md +61 -0
  223. package/skills/ce-noslop/references/terminology.md +3 -0
  224. package/skills/ce-optimize/SKILL.md +64 -0
  225. package/skills/ce-optimize/references/agents/learnings-researcher.md +247 -0
  226. package/skills/ce-optimize/references/agents/repo-research-analyst.md +258 -0
  227. package/skills/ce-optimize/references/example-expensive-benchmark-spec.yaml +88 -0
  228. package/skills/ce-optimize/references/example-hard-spec.yaml +64 -0
  229. package/skills/ce-optimize/references/example-judge-spec.yaml +83 -0
  230. package/skills/ce-optimize/references/example-text-target-spec.yaml +80 -0
  231. package/skills/ce-optimize/references/experiment-log-schema.yaml +563 -0
  232. package/skills/ce-optimize/references/experiment-prompt-template.md +143 -0
  233. package/skills/ce-optimize/references/judge-prompt-template.md +117 -0
  234. package/skills/ce-optimize/references/loop.md +286 -0
  235. package/skills/ce-optimize/references/measurement.md +168 -0
  236. package/skills/ce-optimize/references/optimize-spec-schema.yaml +570 -0
  237. package/skills/ce-optimize/references/persistence.md +93 -0
  238. package/skills/ce-optimize/references/spec.md +107 -0
  239. package/skills/ce-optimize/references/text-targets.md +51 -0
  240. package/skills/ce-optimize/references/usage-guide.md +147 -0
  241. package/skills/ce-optimize/references/wrap-up.md +54 -0
  242. package/skills/ce-optimize/scripts/decide.mjs +676 -0
  243. package/skills/ce-optimize/scripts/experiment-worktree.sh +330 -0
  244. package/skills/ce-optimize/scripts/measure.sh +140 -0
  245. package/skills/ce-optimize/scripts/parallel-probe.sh +134 -0
  246. package/skills/ce-plan/SKILL.md +60 -0
  247. package/skills/ce-plan/references/agents/agent-native-planning-strategist.md +66 -0
  248. package/skills/ce-plan/references/agents/architecture-strategist.md +46 -0
  249. package/skills/ce-plan/references/agents/best-practices-researcher.md +118 -0
  250. package/skills/ce-plan/references/agents/data-integrity-guardian.md +68 -0
  251. package/skills/ce-plan/references/agents/framework-docs-researcher.md +97 -0
  252. package/skills/ce-plan/references/agents/git-history-analyzer.md +40 -0
  253. package/skills/ce-plan/references/agents/learnings-researcher.md +268 -0
  254. package/skills/ce-plan/references/agents/pattern-recognition-specialist.md +55 -0
  255. package/skills/ce-plan/references/agents/performance-oracle.md +108 -0
  256. package/skills/ce-plan/references/agents/repo-research-analyst.md +262 -0
  257. package/skills/ce-plan/references/agents/security-sentinel.md +91 -0
  258. package/skills/ce-plan/references/agents/slack-researcher.md +131 -0
  259. package/skills/ce-plan/references/agents/spec-flow-analyzer.md +84 -0
  260. package/skills/ce-plan/references/agents/web-researcher.md +125 -0
  261. package/skills/ce-plan/references/approach-altitude.md +55 -0
  262. package/skills/ce-plan/references/bakeoff.md +9 -0
  263. package/skills/ce-plan/references/deepening-workflow.md +263 -0
  264. package/skills/ce-plan/references/final-review.md +134 -0
  265. package/skills/ce-plan/references/html-rendering.md +638 -0
  266. package/skills/ce-plan/references/intake.md +158 -0
  267. package/skills/ce-plan/references/markdown-rendering.md +242 -0
  268. package/skills/ce-plan/references/output-contracts.md +29 -0
  269. package/skills/ce-plan/references/output-mode.md +45 -0
  270. package/skills/ce-plan/references/plan-handoff.md +116 -0
  271. package/skills/ce-plan/references/plan-sections.md +489 -0
  272. package/skills/ce-plan/references/reasoning-elevation.md +124 -0
  273. package/skills/ce-plan/references/research.md +211 -0
  274. package/skills/ce-plan/references/resume.md +61 -0
  275. package/skills/ce-plan/references/settled-decisions.md +45 -0
  276. package/skills/ce-plan/references/structure.md +207 -0
  277. package/skills/ce-plan/references/synthesis-summary.md +260 -0
  278. package/skills/ce-plan/references/universal-planning.md +170 -0
  279. package/skills/ce-plan/scripts/elevation-dispatch.sh +288 -0
  280. package/skills/ce-plan/scripts/packs-resolve.py +737 -0
  281. package/skills/ce-plan/scripts/peer-job-runner.py +2594 -0
  282. package/skills/ce-polish/SKILL.md +22 -0
  283. package/skills/ce-polish/agents/openai.yaml +2 -0
  284. package/skills/ce-polish/references/dev-server-astro.md +58 -0
  285. package/skills/ce-polish/references/dev-server-detection.md +40 -0
  286. package/skills/ce-polish/references/dev-server-next.md +61 -0
  287. package/skills/ce-polish/references/dev-server-nuxt.md +58 -0
  288. package/skills/ce-polish/references/dev-server-procfile.md +59 -0
  289. package/skills/ce-polish/references/dev-server-rails.md +50 -0
  290. package/skills/ce-polish/references/dev-server-remix.md +58 -0
  291. package/skills/ce-polish/references/dev-server-sveltekit.md +58 -0
  292. package/skills/ce-polish/references/dev-server-vite.md +48 -0
  293. package/skills/ce-polish/references/install-riffrec.md +35 -0
  294. package/skills/ce-polish/references/launch-json-schema.md +177 -0
  295. package/skills/ce-polish/references/live-loop.md +98 -0
  296. package/skills/ce-polish/references/live-remote.md +35 -0
  297. package/skills/ce-polish/references/live-start.md +93 -0
  298. package/skills/ce-polish/references/live-stream-contract.md +253 -0
  299. package/skills/ce-polish/references/run.md +67 -0
  300. package/skills/ce-polish/scripts/detect-project-type.sh +277 -0
  301. package/skills/ce-polish/scripts/detect-riffrec.sh +225 -0
  302. package/skills/ce-polish/scripts/live-endpoint.js +2841 -0
  303. package/skills/ce-polish/scripts/read-launch-json.sh +117 -0
  304. package/skills/ce-polish/scripts/resolve-package-manager.sh +95 -0
  305. package/skills/ce-polish/scripts/resolve-port.sh +307 -0
  306. package/skills/ce-pov/SKILL.md +64 -0
  307. package/skills/ce-pov/references/agents/external-evidence-researcher.md +26 -0
  308. package/skills/ce-pov/references/agents/pov-peer.md +43 -0
  309. package/skills/ce-pov/references/agents/precedent-activity-scout.md +21 -0
  310. package/skills/ce-pov/references/agents/project-grounding-scout.md +32 -0
  311. package/skills/ce-pov/references/boundaries.md +36 -0
  312. package/skills/ce-pov/references/cross-model-panel.md +596 -0
  313. package/skills/ce-pov/references/followup.md +9 -0
  314. package/skills/ce-pov/references/grounding.md +47 -0
  315. package/skills/ce-pov/references/intake.md +61 -0
  316. package/skills/ce-pov/references/invocation.md +47 -0
  317. package/skills/ce-pov/references/method.md +68 -0
  318. package/skills/ce-pov/references/pov-schema.json +56 -0
  319. package/skills/ce-pov/references/report.md +7 -0
  320. package/skills/ce-pov/scripts/cross-model-pov.sh +1110 -0
  321. package/skills/ce-pov/scripts/peer-job-runner.py +2594 -0
  322. package/skills/ce-product-pulse/SKILL.md +81 -0
  323. package/skills/ce-product-pulse/agents/openai.yaml +2 -0
  324. package/skills/ce-product-pulse/references/config.md +20 -0
  325. package/skills/ce-product-pulse/references/interview.md +260 -0
  326. package/skills/ce-product-pulse/references/report-template.md +89 -0
  327. package/skills/ce-product-pulse/references/run.md +46 -0
  328. package/skills/ce-product-pulse/references/setup.md +43 -0
  329. package/skills/ce-promote/SKILL.md +90 -0
  330. package/skills/ce-promote/agents/openai.yaml +2 -0
  331. package/skills/ce-promote/references/spiral-cli.md +149 -0
  332. package/skills/ce-proof/SKILL.md +65 -0
  333. package/skills/ce-proof/references/api.md +176 -0
  334. package/skills/ce-proof/references/workflows.md +124 -0
  335. package/skills/ce-prototype/SKILL.md +59 -0
  336. package/skills/ce-prototype/assets/annotate.css +206 -0
  337. package/skills/ce-prototype/assets/annotate.js +837 -0
  338. package/skills/ce-prototype/references/annotation-loop.md +32 -0
  339. package/skills/ce-prototype/references/build.md +39 -0
  340. package/skills/ce-prototype/references/craft-floor.md +43 -0
  341. package/skills/ce-prototype/references/preview.md +118 -0
  342. package/skills/ce-prototype/references/scoping.md +38 -0
  343. package/skills/ce-prototype/references/write-back.md +37 -0
  344. package/skills/ce-prototype/scripts/light-webserver.js +1275 -0
  345. package/skills/ce-resolve-pr-feedback/SKILL.md +60 -0
  346. package/skills/ce-resolve-pr-feedback/references/agents/pr-comment-resolver.md +59 -0
  347. package/skills/ce-resolve-pr-feedback/references/evaluation-rubric.md +141 -0
  348. package/skills/ce-resolve-pr-feedback/references/full-mode.md +336 -0
  349. package/skills/ce-resolve-pr-feedback/references/pipeline-mode.md +23 -0
  350. package/skills/ce-resolve-pr-feedback/references/resume.md +60 -0
  351. package/skills/ce-resolve-pr-feedback/references/return-to-caller.md +111 -0
  352. package/skills/ce-resolve-pr-feedback/references/targeted-mode.md +48 -0
  353. package/skills/ce-resolve-pr-feedback/scripts/get-pr-comments +176 -0
  354. package/skills/ce-resolve-pr-feedback/scripts/get-thread-for-comment +84 -0
  355. package/skills/ce-resolve-pr-feedback/scripts/pending-feedback.py +287 -0
  356. package/skills/ce-resolve-pr-feedback/scripts/reply-to-pr-thread +57 -0
  357. package/skills/ce-resolve-pr-feedback/scripts/resolve-pr-thread +23 -0
  358. package/skills/ce-retune/SKILL.md +42 -0
  359. package/skills/ce-retune/agents/openai.yaml +2 -0
  360. package/skills/ce-retune/references/baseline-mining.md +122 -0
  361. package/skills/ce-retune/references/corpus-audit.md +128 -0
  362. package/skills/ce-retune/references/cut-passes.md +129 -0
  363. package/skills/ce-retune/references/halt-taxonomy.md +123 -0
  364. package/skills/ce-retune/references/noise-floor.md +131 -0
  365. package/skills/ce-retune/references/workflow-shapes.md +73 -0
  366. package/skills/ce-riffrec-feedback-analysis/SKILL.md +33 -0
  367. package/skills/ce-riffrec-feedback-analysis/references/analyzer.md +18 -0
  368. package/skills/ce-riffrec-feedback-analysis/references/compound-engineering-feedback-format.md +116 -0
  369. package/skills/ce-riffrec-feedback-analysis/references/extensive-analysis.md +98 -0
  370. package/skills/ce-riffrec-feedback-analysis/references/install-riffrec.md +27 -0
  371. package/skills/ce-riffrec-feedback-analysis/references/quick-bug-report.md +38 -0
  372. package/skills/ce-riffrec-feedback-analysis/scripts/analyze_riffrec_zip.py +1306 -0
  373. package/skills/ce-setup/SKILL.md +103 -0
  374. package/skills/ce-setup/agents/openai.yaml +2 -0
  375. package/skills/ce-setup/assets/compounding-directive.md +11 -0
  376. package/skills/ce-setup/assets/noslop-directive.md +7 -0
  377. package/skills/ce-setup/assets/pack-rule-template.md +11 -0
  378. package/skills/ce-setup/references/acpx-cache-warm.md +14 -0
  379. package/skills/ce-setup/references/config-template.yaml +210 -0
  380. package/skills/ce-setup/references/legacy-codex-tool-map.md +15 -0
  381. package/skills/ce-setup/references/pack-scaffold.md +45 -0
  382. package/skills/ce-setup/references/repo-fixes.md +121 -0
  383. package/skills/ce-setup/scripts/check-health +929 -0
  384. package/skills/ce-setup/scripts/packs-resolve.py +737 -0
  385. package/skills/ce-simplify-code/SKILL.md +76 -0
  386. package/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
  387. package/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
  388. package/skills/ce-simplify-code/references/personas/comment-reviewer.md +24 -0
  389. package/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
  390. package/skills/ce-simplify-code/references/personas/test-value-reviewer.md +23 -0
  391. package/skills/ce-simplify-code/references/reviewer-dispatch.md +11 -0
  392. package/skills/ce-simplify-code/references/standalone-report.md +25 -0
  393. package/skills/ce-strategy/SKILL.md +61 -0
  394. package/skills/ce-strategy/references/grounding.md +22 -0
  395. package/skills/ce-strategy/references/interview.md +179 -0
  396. package/skills/ce-strategy/references/strategy-template.md +94 -0
  397. package/skills/ce-strategy/references/update-run.md +33 -0
  398. package/skills/ce-sweep/SKILL.md +93 -0
  399. package/skills/ce-sweep/agents/openai.yaml +2 -0
  400. package/skills/ce-sweep/references/agents/media-analyzer.md +54 -0
  401. package/skills/ce-sweep/references/interview.md +208 -0
  402. package/skills/ce-sweep/references/model-tiers.md +13 -0
  403. package/skills/ce-sweep/references/plan-template.md +62 -0
  404. package/skills/ce-sweep/references/run.md +126 -0
  405. package/skills/ce-sweep/references/sources/email.md +57 -0
  406. package/skills/ce-sweep/references/sources/github-issues.md +59 -0
  407. package/skills/ce-sweep/references/sources/slack.md +61 -0
  408. package/skills/ce-sweep/references/state-schema.md +157 -0
  409. package/skills/ce-sweep/references/subagent-template.md +60 -0
  410. package/skills/ce-sweep/scripts/analyze_riffrec_zip.py +1306 -0
  411. package/skills/ce-sweep/scripts/sweep-state.py +793 -0
  412. package/skills/ce-test-browser/SKILL.md +50 -0
  413. package/skills/ce-test-browser/references/agent-browser-driver.md +47 -0
  414. package/skills/ce-test-browser/references/pipeline-orchestration.md +45 -0
  415. package/skills/ce-test-browser/references/route-and-report.md +129 -0
  416. package/skills/ce-test-browser/scripts/resolve-port.sh +41 -0
  417. package/skills/ce-test-xcode/SKILL.md +26 -0
  418. package/skills/ce-test-xcode/agents/openai.yaml +2 -0
  419. package/skills/ce-test-xcode/references/setup-and-build.md +32 -0
  420. package/skills/ce-test-xcode/references/test-and-report.md +64 -0
  421. package/skills/ce-work/SKILL.md +62 -0
  422. package/skills/ce-work/references/agents/figma-design-sync.md +27 -0
  423. package/skills/ce-work/references/agents/implementation-worker.md +17 -0
  424. package/skills/ce-work/references/cross-model-execution.md +191 -0
  425. package/skills/ce-work/references/execution-engines.md +64 -0
  426. package/skills/ce-work/references/execution-strategy.md +73 -0
  427. package/skills/ce-work/references/implementation-loop.md +107 -0
  428. package/skills/ce-work/references/implementation-result-schema.json +52 -0
  429. package/skills/ce-work/references/input-triage.md +50 -0
  430. package/skills/ce-work/references/non-code-execution.md +23 -0
  431. package/skills/ce-work/references/return-to-caller.md +33 -0
  432. package/skills/ce-work/references/review-findings-followup.md +82 -0
  433. package/skills/ce-work/references/shipping-workflow.md +89 -0
  434. package/skills/ce-work/references/tracker-defer.md +150 -0
  435. package/skills/ce-work/references/work-intake.md +51 -0
  436. package/skills/ce-work/references/workspace-setup.md +27 -0
  437. package/skills/ce-work/scripts/__pycache__/unit_workspace_jobs.cpython-314.pyc +0 -0
  438. package/skills/ce-work/scripts/__pycache__/unit_workspace_state.cpython-314.pyc +0 -0
  439. package/skills/ce-work/scripts/cross-model-work.sh +1385 -0
  440. package/skills/ce-work/scripts/peer-job-runner.py +2594 -0
  441. package/skills/ce-work/scripts/unit-workspace.py +215 -0
  442. package/skills/ce-work/scripts/unit_workspace_ignored.py +93 -0
  443. package/skills/ce-work/scripts/unit_workspace_integration.py +677 -0
  444. package/skills/ce-work/scripts/unit_workspace_jobs.py +1216 -0
  445. package/skills/ce-work/scripts/unit_workspace_lifecycle.py +981 -0
  446. package/skills/ce-work/scripts/unit_workspace_state.py +1379 -0
  447. package/skills/ce-work/scripts/unit_workspace_transaction.py +612 -0
  448. package/skills/ce-worktree/SKILL.md +54 -0
  449. package/skills/lfg/SKILL.md +59 -0
  450. package/skills/lfg/references/debug-return.md +25 -0
  451. package/skills/lfg/references/intake.md +30 -0
  452. package/skills/lfg/references/next-work-handoff.md +96 -0
  453. package/skills/lfg/references/plan-brief.md +36 -0
  454. package/skills/lfg/references/review-followup.md +78 -0
  455. package/skills/lfg/references/shipping.md +65 -0
  456. package/skills/lfg/references/stage-routing.md +49 -0
  457. package/skills/lfg/references/task-visibility.md +11 -0
  458. package/skills/lfg/references/tracker-defer.md +150 -0
  459. package/skills/lfg/references/work-return.md +27 -0
  460. package/skills/wtf/SKILL.md +33 -0
  461. package/skills/wtf/agents/openai.yaml +2 -0
@@ -0,0 +1,2841 @@
1
+ #!/usr/bin/env node
2
+ // Live endpoint for ce-polish live mode: page -> endpoint event intake over
3
+ // HTTP POST, endpoint -> page SSE, endpoint -> agent blocking wake.
4
+ // Adapted from the ce-prototype helper (light-webserver.js): the run-directory
5
+ // lifecycle (pidfile, state/, idle timeout, --owner-pid, start/status/stop/wait)
6
+ // is kept; file serving, the overlay, /version, SSE grace shutdown, and the
7
+ // pending-document handshake are gone. Owner death and idle timeout stop the
8
+ // process but never end the session: a later `start --root` resumes it.
9
+ // Only /session/end or an explicit `stop` ends a session.
10
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
11
+ import { execFileSync, spawn } from "node:child_process"
12
+ import fs from "node:fs"
13
+ import http from "node:http"
14
+ import net from "node:net"
15
+ import path from "node:path"
16
+ import readline from "node:readline"
17
+ import { fileURLToPath } from "node:url"
18
+
19
+ const scriptPath = fileURLToPath(import.meta.url)
20
+ const DEFAULT_HOST = "127.0.0.1"
21
+ const DEFAULT_URL_HOST = "localhost"
22
+ const SCHEMA_VERSION = "live/1"
23
+ const IDLE_TIMEOUT_MS = Number(process.env.CE_LIVE_IDLE_TIMEOUT_MS) || 30 * 60 * 1000
24
+ const LIFECYCLE_CHECK_MS = Number(process.env.CE_LIVE_LIFECYCLE_CHECK_MS) || 60 * 1000
25
+ const WAIT_TIMEOUT_MS = Number(process.env.CE_LIVE_WAIT_TIMEOUT_MS) || 30 * 1000
26
+ const PAGE_LOST_GRACE_MS = Number(process.env.CE_LIVE_PAGE_LOST_GRACE_MS) || 15 * 1000
27
+ const MINT_TIMEOUT_MS = Number(process.env.CE_LIVE_MINT_TIMEOUT_MS) || 10 * 1000
28
+ const BODY_LIMIT = 64 * 1024
29
+ // How long after a stream delivery the response is ended so buffering
30
+ // intermediaries release it; a burst inside this window shares one response.
31
+ const STREAM_FLUSH_MS = Number(process.env.CE_LIVE_STREAM_FLUSH_MS) || 300
32
+ const FRAME_BODY_LIMIT = 2 * 1024 * 1024
33
+ const ARCHIVE_BODY_LIMIT = Number(process.env.CE_LIVE_ARCHIVE_LIMIT_BYTES) || 400 * 1024 * 1024
34
+ const DISK_CAP_BYTES = Number(process.env.CE_LIVE_DISK_CAP_BYTES) || 500 * 1024 * 1024
35
+ // Above the image cap, a ceiling on the whole session log: past it every
36
+ // envelope is refused, not only frames, so a session that keeps sending
37
+ // small events (or a page that keeps posting) cannot grow the log without
38
+ // bound. Normal sessions never reach it; the page treats the refusal as
39
+ // buffering and replays once accepted again.
40
+ const DISK_HARD_CAP_BYTES = Math.max(DISK_CAP_BYTES, Number(process.env.CE_LIVE_DISK_HARD_CAP_BYTES) || DISK_CAP_BYTES + 64 * 1024 * 1024)
41
+ // An archive upload streams into `archive.<ext>.<uuid>.part` and is renamed
42
+ // once complete; a `.part` found at startup is an upload the previous process
43
+ // did not finish.
44
+ const ARCHIVE_PART_SUFFIX = ".part"
45
+ const BRIEF_MAX_CHARS = 3000
46
+ // Envelopes held ahead of a sequence gap before early arrivals are dropped for replay.
47
+ const OUT_OF_ORDER_CAP = 512
48
+ const MINTS_PER_MINUTE = 5
49
+ // Refused requests logged to agent.ndjson per window; the last slot in a
50
+ // window is the one line saying the rest of that window is suppressed.
51
+ const REJECTION_LOG_PER_WINDOW = Number(process.env.CE_LIVE_REJECTION_LOG_PER_MINUTE) || 60
52
+ const REJECTION_LOG_WINDOW_MS = 60 * 1000
53
+ const OPENAI_BASE_URL = (process.env.OPENAI_BASE_URL || "https://api.openai.com").replace(/\/+$/, "")
54
+ const REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || "gpt-realtime"
55
+ const REALTIME_VOICE = process.env.OPENAI_REALTIME_VOICE || "marin"
56
+ const CLIENT_SECRET_TTL_S = 600
57
+
58
+ const PAGE_EVENT_TYPES = new Set([
59
+ "click", "navigation", "network_request", "console_error",
60
+ "transcript", "unit", "unit_update", "unit_withdraw", "annotation",
61
+ "checkpoint", "answer", "frame", "mic", "mode", "stream_state",
62
+ ])
63
+ // Page-emitted checkpoint triggers (KTD9). `silence`/`page_change`/`send`
64
+ // wake the agent only when they release something; `final` always wakes.
65
+ const PAGE_CHECKPOINT_KINDS = new Set(["silence", "page_change", "send", "final"])
66
+ const ALWAYS_WAKE_KINDS = new Set(["answer", "mode_change", "final"])
67
+ const EXECUTION_MODES = new Set(["instant", "smart", "collect"])
68
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
69
+ // Agent-postable unit statuses: riffrec's UnitStatus set minus `initial`.
70
+ const AGENT_UNIT_STATUSES = new Set(["triaging", "accepted", "needs_info", "working", "applied", "blocked", "withdrawn"])
71
+ const UNIT_STATUSES = new Set(["initial", ...AGENT_UNIT_STATUSES])
72
+ const AGENT_AWAY_GRACE_MS = 15_000
73
+
74
+ // Payload shapes, ported from riffrec's `isPayloadFor` (src/live/contract.ts)
75
+ // so a malformed event is refused before it is acknowledged.
76
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v)
77
+ const isString = (v) => typeof v === "string"
78
+ const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v)
79
+ const isBoolean = (v) => typeof v === "boolean"
80
+ const optionalString = (v) => v === undefined || isString(v)
81
+ const isStringArray = (v) => Array.isArray(v) && v.every(isString)
82
+ const isRect = (v) => isRecord(v) && isFiniteNumber(v.x) && isFiniteNumber(v.y) && isFiniteNumber(v.width) && isFiniteNumber(v.height)
83
+ const isAnchor = (v) => isRecord(v) && isString(v.route) && isString(v.selector) && (v.component === undefined || v.component === null || isString(v.component)) && isRect(v.rect) && isFiniteNumber(v.t)
84
+ const isAnchorArray = (v) => Array.isArray(v) && v.every(isAnchor)
85
+ const isConfirmation = (v) => isRecord(v) && isBoolean(v.element) && isBoolean(v.change)
86
+ const isSpan = (v) => isRecord(v) && isFiniteNumber(v.t_start) && isFiniteNumber(v.t_end)
87
+ const isEvidence = (v) => isRecord(v) && isStringArray(v.frame_ids) && isStringArray(v.annotation_ids) && isSpan(v.transcript_span)
88
+ && optionalString(v.audio_clip_id) && (v.telemetry_window === undefined || (isSpan(v.telemetry_window) && Array.isArray(v.telemetry_window.events)))
89
+ const isPointArray = (v) => Array.isArray(v) && v.every((p) => isRecord(p) && isFiniteNumber(p.x) && isFiniteNumber(p.y) && (p.pressure === undefined || isFiniteNumber(p.pressure)))
90
+
91
+ function validPayload(type, payload) {
92
+ if (!isRecord(payload)) return false
93
+ switch (type) {
94
+ case "click":
95
+ return payload.type === type && isFiniteNumber(payload.t) && isRecord(payload.element) && isString(payload.element.selector)
96
+ case "network_request":
97
+ return payload.type === type && isFiniteNumber(payload.t) && isString(payload.url) && isString(payload.method) && isFiniteNumber(payload.status)
98
+ case "console_error":
99
+ return payload.type === type && isFiniteNumber(payload.t) && isString(payload.message)
100
+ case "navigation":
101
+ return payload.type === type && isFiniteNumber(payload.t) && isString(payload.from) && isString(payload.to)
102
+ case "transcript":
103
+ return isString(payload.id) && (payload.role === "riffer" || payload.role === "interviewer") && isString(payload.text)
104
+ && isFiniteNumber(payload.t_start) && isFiniteNumber(payload.t_end) && isBoolean(payload.final)
105
+ case "unit":
106
+ return isString(payload.id) && isString(payload.statement) && isString(payload.transcript_excerpt) && isAnchorArray(payload.anchors)
107
+ && isEvidence(payload.evidence) && UNIT_STATUSES.has(payload.status) && (payload.confirmed === undefined || isConfirmation(payload.confirmed))
108
+ case "unit_update":
109
+ return isString(payload.unit_id) && optionalString(payload.statement) && (payload.anchors_add === undefined || isAnchorArray(payload.anchors_add))
110
+ && (payload.confirmed === undefined || isConfirmation(payload.confirmed))
111
+ case "unit_withdraw":
112
+ return isString(payload.unit_id) && optionalString(payload.reason)
113
+ case "annotation":
114
+ return isString(payload.id) && (payload.kind === "stroke" || payload.kind === "pin") && isPointArray(payload.points) && isRect(payload.bbox)
115
+ && isAnchor(payload.anchor) && optionalString(payload.text) && optionalString(payload.unit_id) && optionalString(payload.composite_frame_id)
116
+ case "checkpoint":
117
+ return isString(payload.id) && PAGE_CHECKPOINT_KINDS.has(payload.trigger) && EXECUTION_MODES.has(payload.mode)
118
+ case "answer":
119
+ return isString(payload.unit_id) && isString(payload.text)
120
+ case "frame":
121
+ return isString(payload.id) && isFiniteNumber(payload.t) && isString(payload.route)
122
+ && ["gesture", "periodic", "composite"].includes(payload.kind) && isString(payload.jpeg_base64)
123
+ && (payload.dropped === undefined || ["quota", "oversize"].includes(payload.dropped))
124
+ case "mic":
125
+ return ["granted", "denied", "muted", "unmuted"].includes(payload.state)
126
+ case "mode":
127
+ return EXECUTION_MODES.has(payload.mode)
128
+ case "stream_state":
129
+ return ["streaming", "buffering", "unloading"].includes(payload.state)
130
+ default:
131
+ return false
132
+ }
133
+ }
134
+ const EVIDENCE_PROFILES = {
135
+ anchors_transcript_only: { frames: "none", annotations: false, clips: false, telemetry: false },
136
+ strokes_composite: { frames: "composite", annotations: true, clips: false, telemetry: false },
137
+ everything: { frames: "all", annotations: true, clips: true, telemetry: true },
138
+ }
139
+
140
+ // The executable copy of the interviewer's tools the mint sends to OpenAI:
141
+ // verbatim riffrec `LIVE_TOOLS` (src/live/tools.ts). The human-readable copy
142
+ // is references/live-stream-contract.md and the pinned dump is
143
+ // tests/fixtures/ce-polish-live/live-tools.json; change all three together.
144
+ // After connecting, the page appends any tool it can answer that the mint
145
+ // lacked (the endpoint's copies win), so a stale copy degrades rather than
146
+ // breaks; a verbatim copy is never patched.
147
+ const INTERVIEWER_TOOLS = [
148
+ {
149
+ "type": "function",
150
+ "name": "record_unit",
151
+ "description": "Record one requested change as a unit on the board. Call when the riffer has asked for exactly one concrete change to the app and you can state it in one sentence; call once per change, so a sentence that asks for three things becomes three calls. Never call for questions, thinking aloud, praise, or utterances shorter than three words without a change verb; ask a clarifying question instead when the target element or the intended change is ambiguous.",
152
+ "parameters": {
153
+ "type": "object",
154
+ "properties": {
155
+ "statement": {
156
+ "type": "string",
157
+ "description": "Normalized imperative statement of the change, in the riffer's vocabulary, one sentence, no speculation."
158
+ },
159
+ "anchors": {
160
+ "type": "array",
161
+ "description": "Anchor references for the element(s) the change is about: an anchor id from a [PAGE] note announcing what the riffer clicked, drew on, or pinned (\"this\"/\"here\" means the most recent one), or the riffer's own words for the element (\"the sidebar toggle\", \"that red button\"). Empty only when the riffer named no element and no anchor was announced.",
162
+ "items": {
163
+ "type": "string"
164
+ }
165
+ },
166
+ "transcript_excerpt": {
167
+ "type": "string",
168
+ "description": "The riffer's own words that carry this change, verbatim, trimmed to the relevant span."
169
+ }
170
+ },
171
+ "required": [
172
+ "statement",
173
+ "anchors",
174
+ "transcript_excerpt"
175
+ ],
176
+ "additionalProperties": false
177
+ }
178
+ },
179
+ {
180
+ "type": "function",
181
+ "name": "update_unit",
182
+ "description": "Refine a unit you recorded earlier. Call when the riffer adds detail, corrects wording, or names another element for a change already on the board, or when they answer a clarifying question you asked about it. Never call to change a unit into a different change; withdraw it and record a new one. The call is rejected once the unit has left the initial status; the result tells you so, and you must then record the refinement as a new unit.",
183
+ "parameters": {
184
+ "type": "object",
185
+ "properties": {
186
+ "unit_id": {
187
+ "type": "string",
188
+ "description": "Id returned by record_unit."
189
+ },
190
+ "statement": {
191
+ "type": "string",
192
+ "description": "Replacement statement, when the wording changes."
193
+ },
194
+ "anchors_add": {
195
+ "type": "array",
196
+ "description": "Additional anchor references to attach; existing anchors are kept.",
197
+ "items": {
198
+ "type": "string"
199
+ }
200
+ }
201
+ },
202
+ "required": [
203
+ "unit_id"
204
+ ],
205
+ "additionalProperties": false
206
+ }
207
+ },
208
+ {
209
+ "type": "function",
210
+ "name": "withdraw_unit",
211
+ "description": "Retract a unit the riffer no longer wants. Call when the riffer says never mind, undo, scrap that, or otherwise takes back a change you recorded. Never call because you are unsure the unit was right; ask instead. Never call for units you did not record in this session.",
212
+ "parameters": {
213
+ "type": "object",
214
+ "properties": {
215
+ "unit_id": {
216
+ "type": "string",
217
+ "description": "Id returned by record_unit."
218
+ },
219
+ "reason": {
220
+ "type": "string",
221
+ "description": "The riffer's reason, in their words, when they gave one."
222
+ }
223
+ },
224
+ "required": [
225
+ "unit_id"
226
+ ],
227
+ "additionalProperties": false
228
+ }
229
+ },
230
+ {
231
+ "type": "function",
232
+ "name": "relay_answer",
233
+ "description": "Relay the riffer's answer to a question the coding agent asked about a unit. Call when you voiced a question that arrived from the endpoint for a specific unit and the riffer has answered it. Never call for answers to your own clarifying questions; use update_unit for those. Never invent or summarize an answer the riffer did not give.",
234
+ "parameters": {
235
+ "type": "object",
236
+ "properties": {
237
+ "unit_id": {
238
+ "type": "string",
239
+ "description": "Id of the unit the question was attached to."
240
+ },
241
+ "answer_text": {
242
+ "type": "string",
243
+ "description": "The riffer's answer, verbatim or lightly cleaned of filler."
244
+ }
245
+ },
246
+ "required": [
247
+ "unit_id",
248
+ "answer_text"
249
+ ],
250
+ "additionalProperties": false
251
+ }
252
+ },
253
+ {
254
+ "type": "function",
255
+ "name": "look_at_screen",
256
+ "description": "See the riffer's screen right now. The page attaches a screenshot of the current view as an image in the conversation, then returns this call's result with the route and how old the frame is. Call when the riffer refers to how something looks (\"this\", \"here\", \"that color\", \"it looks off\") and the clicked or drawn anchors the page announced do not settle what they mean, when they ask whether you can see their screen, or when they ask you to look. Never call more than once per riffer turn, and never call to browse: only to answer what the riffer just said. If the result says no frame is available, ask the riffer to describe what they see.",
257
+ "parameters": {
258
+ "type": "object",
259
+ "properties": {
260
+ "reason": {
261
+ "type": "string",
262
+ "description": "Why you need to see the screen, in a few words."
263
+ }
264
+ },
265
+ "required": [],
266
+ "additionalProperties": false
267
+ }
268
+ }
269
+ ]
270
+
271
+ // The interviewer's instructions: verbatim riffrec
272
+ // `DEFAULT_INTERVIEWER_INSTRUCTIONS` (src/live/realtime/persona.ts), pinned by
273
+ // tests/fixtures/ce-polish-live/interviewer-instructions.txt. The page checks
274
+ // the minted persona for the [SCREEN CONTEXT] marker after connect and appends
275
+ // the section when it is missing; keep it here, and keep it last.
276
+ const SCREEN_CONTEXT_SECTION = [
277
+ "[SCREEN CONTEXT]",
278
+ "The page keeps you informed about the screen, and this section is authoritative about it: it supersedes any earlier statement that you cannot see the page or must not claim to.",
279
+ "Every click the riffer makes arrives as a system note tagged [PAGE] that names the element (its component, visible text, selector, and route) and gives it an anchor id. Drawings and pins arrive the same way. The most recent note is what \"this\", \"here\", and \"that\" refer to: put its anchor id in record_unit's anchors, and never ask which element they mean when a note arrived within the last few seconds.",
280
+ "You can also see the screen. Call look_at_screen when the riffer refers to how something looks, asks whether you can see their screen, or asks you to look; the page attaches a screenshot of the current view and you may then describe or refer to what is in it. The riffrec panel docked at the top right is not part of the app. Never say you cannot see the screen: if no frame is available the tool result says so, and you ask the riffer to describe what they see instead.",
281
+ ].join("\n")
282
+
283
+ const INTERVIEWER_PERSONA = [
284
+ "You are the riffrec interviewer: a calm, terse product partner listening to a designer or developer (the riffer) talk through changes they want while they click and draw on their own running app. The page tells you what they click, draw on, and pin, and shows you the screen when you ask for it; the last section says how.",
285
+ "Your job is to turn what the riffer says into units of change on a shared board, one unit per requested change, using the record_unit tool. A sentence that asks for three things becomes three record_unit calls. Never call record_unit for questions, thinking aloud, praise, or utterances shorter than three words without a change verb.",
286
+ "Ask immediately, in one short sentence, when the target element or the intended value is ambiguous: which element, which side, what color, how much. Otherwise stay quiet and let the riffer keep talking. Do not narrate, summarize, or confirm each unit aloud; the board already shows it.",
287
+ "Never invent anchors. Use only the anchor ids the page announced or the element references the riffer named. When the riffer names no element and no anchor was announced, record the unit with an empty anchors list.",
288
+ "When the riffer takes back a change, call withdraw_unit and acknowledge it aloud in a few words. When they refine a change already on the board, call update_unit; if it is rejected because the unit was already picked up, record the refinement as a new unit.",
289
+ "When a note marked [ENDPOINT QUESTION] arrives, read the question to the riffer in your own words at the next pause and, once they answer, call relay_answer with their answer for that unit. Never answer such a question yourself.",
290
+ "When the riffer asks to compound, to capture what was learned, or to remember a decision for next time, record one unit whose statement starts with \"/ce-compound:\" followed by what to capture, with an empty anchors list, and say in a few words that it will be compounded.",
291
+ "Keep every spoken turn under two sentences. Speak the riffer's language.",
292
+ SCREEN_CONTEXT_SECTION,
293
+ ].join("\n\n")
294
+
295
+ function usage() {
296
+ return [
297
+ "Usage:",
298
+ " node live-endpoint.js start --root <dir> --app-origin <origin> [--host 127.0.0.1] [--port 0] [--owner-pid <pid>] [--trust-proxy <ip>[,<ip>]] [--foreground]",
299
+ " node live-endpoint.js status --root <dir>",
300
+ " node live-endpoint.js stop --root <dir>",
301
+ " node live-endpoint.js wait --root <dir>",
302
+ " node live-endpoint.js replay --root <dir> --profile <anchors_transcript_only|strokes_composite|everything> --to <endpoint> --token <page token> [--log <log-ended-<stamp>|dir>]",
303
+ ].join("\n")
304
+ }
305
+
306
+ function parseArgs(argv) {
307
+ const command = argv[2]
308
+ const options = { command, host: DEFAULT_HOST, port: undefined, foreground: false }
309
+
310
+ for (let i = 3; i < argv.length; i++) {
311
+ const arg = argv[i]
312
+ if (arg === "--root") options.root = argv[++i]
313
+ else if (arg === "--host") { options.host = argv[++i]; options.hostExplicit = true }
314
+ else if (arg === "--port") options.port = Number(argv[++i])
315
+ else if (arg === "--foreground") options.foreground = true
316
+ else if (arg === "--owner-pid") options.ownerPid = Number(argv[++i])
317
+ else if (arg === "--app-origin") options.appOrigin = argv[++i]
318
+ else if (arg === "--profile") options.profile = argv[++i]
319
+ else if (arg === "--to") options.to = argv[++i]
320
+ else if (arg === "--token") options.token = argv[++i]
321
+ else if (arg === "--log") options.log = argv[++i]
322
+ else if (arg === "--trust-proxy") options.trustProxy = [...(options.trustProxy ?? []), ...String(argv[++i] ?? "").split(",").map((ip) => ip.trim()).filter(Boolean)]
323
+ else throw new Error(`Unknown argument: ${arg}`)
324
+ }
325
+
326
+ if (!["start", "serve", "stop", "status", "wait", "replay"].includes(command)) {
327
+ throw new Error(usage())
328
+ }
329
+ if (!options.root) throw new Error("--root is required")
330
+ if (options.port !== undefined && (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535)) {
331
+ throw new Error("--port must be an integer from 0 to 65535")
332
+ }
333
+ if (options.ownerPid !== undefined && (!Number.isInteger(options.ownerPid) || options.ownerPid <= 1)) {
334
+ throw new Error("--owner-pid must be an integer greater than 1")
335
+ }
336
+ options.trustProxy = options.trustProxy ?? []
337
+ if (command === "start" || command === "serve") {
338
+ if (options.trustProxy.some((ip) => !net.isIP(ip))) throw new Error("--trust-proxy takes IP addresses (comma-separated or repeated)")
339
+ if (!options.appOrigin) {
340
+ // The documented recovery is a bare `start --root <dir>`: a session
341
+ // whose agent token is still retained lends its origin.
342
+ const resumable = readJsonOrNull(path.join(path.resolve(options.root), "state", "session.json"))
343
+ // Also lent by an ended session still draining its final batch.
344
+ if (resumable && resumable.agent_token && typeof resumable.app_origin === "string") options.appOrigin = resumable.app_origin
345
+ }
346
+ if (!options.appOrigin) throw new Error("--app-origin is required (the browser-facing origin of the app under polish)")
347
+ options.appOrigin = normalizeOrigin(options.appOrigin)
348
+ if (!options.appOrigin) throw new Error("--app-origin must be an origin such as http://localhost:3000")
349
+ }
350
+ if (command === "replay") {
351
+ if (!options.profile || !EVIDENCE_PROFILES[options.profile]) {
352
+ throw new Error(`--profile must be one of: ${Object.keys(EVIDENCE_PROFILES).join(", ")}`)
353
+ }
354
+ if (!options.to || !normalizeOrigin(options.to)) throw new Error("--to must be the endpoint origin to replay into")
355
+ options.to = normalizeOrigin(options.to)
356
+ if (!options.token) throw new Error("--token is required (the target endpoint's page token)")
357
+ }
358
+
359
+ options.root = path.resolve(options.root)
360
+ options.stateDir = path.join(options.root, "state")
361
+ options.pidFile = path.join(options.stateDir, "server.pid")
362
+ options.sessionFile = path.join(options.stateDir, "session.json")
363
+ options.boardFile = path.join(options.stateDir, "board.json")
364
+ options.briefFile = path.join(options.stateDir, "brief.md")
365
+ options.logFile = path.join(options.stateDir, "server.log")
366
+ options.batchesDir = path.join(options.stateDir, "batches")
367
+ options.logDir = path.join(options.stateDir, "log")
368
+ // `replay --log` addresses an earlier session on this root: a rotated
369
+ // `state/log-ended-<stamp>` by name, or any log directory by path.
370
+ if (options.log !== undefined) {
371
+ if (command !== "replay") throw new Error("--log applies to replay only")
372
+ const named = path.join(options.stateDir, path.basename(options.log))
373
+ const candidate = fs.existsSync(path.join(named, "events.ndjson")) ? named : path.resolve(options.log)
374
+ if (!fs.existsSync(path.join(candidate, "events.ndjson"))) throw new Error(`No session log at ${path.join(candidate, "events.ndjson")}`)
375
+ options.logDir = candidate
376
+ }
377
+ return options
378
+ }
379
+
380
+ function normalizeOrigin(value) {
381
+ try {
382
+ const url = new URL(value)
383
+ if (!/^https?:$/.test(url.protocol) || url.pathname !== "/" || url.search || url.hash) return null
384
+ return url.origin
385
+ } catch {
386
+ return null
387
+ }
388
+ }
389
+
390
+ function ensureDirs(options) {
391
+ fs.mkdirSync(options.stateDir, { recursive: true, mode: 0o700 })
392
+ fs.chmodSync(options.stateDir, 0o700)
393
+ fs.mkdirSync(options.batchesDir, { recursive: true, mode: 0o700 })
394
+ fs.mkdirSync(options.logDir, { recursive: true, mode: 0o700 })
395
+ fs.mkdirSync(path.join(options.logDir, "frames"), { recursive: true, mode: 0o700 })
396
+ }
397
+
398
+ function writePrivate(filePath, contents) {
399
+ const tmp = `${filePath}.${process.pid}.tmp`
400
+ fs.writeFileSync(tmp, contents, { mode: 0o600 })
401
+ fs.chmodSync(tmp, 0o600)
402
+ fs.renameSync(tmp, filePath)
403
+ }
404
+
405
+ function writePrivateJson(filePath, value) {
406
+ writePrivate(filePath, `${JSON.stringify(value, null, 2)}\n`)
407
+ }
408
+
409
+ function appendPrivate(filePath, line) {
410
+ fs.appendFileSync(filePath, line, { mode: 0o600 })
411
+ }
412
+
413
+ function jsonOut(value) {
414
+ process.stdout.write(`${JSON.stringify(value)}\n`)
415
+ }
416
+
417
+ function readJson(filePath) {
418
+ return JSON.parse(fs.readFileSync(filePath, "utf8"))
419
+ }
420
+
421
+ function readJsonOrNull(filePath) {
422
+ try {
423
+ return readJson(filePath)
424
+ } catch {
425
+ return null
426
+ }
427
+ }
428
+
429
+ function readableFile(filePath) {
430
+ try {
431
+ fs.accessSync(filePath, fs.constants.R_OK)
432
+ return fs.statSync(filePath).isFile()
433
+ } catch {
434
+ return false
435
+ }
436
+ }
437
+
438
+ // A frame file named by a session log, admitted only as a regular file that
439
+ // really sits under that log's frames/ directory: the log may be a copy from
440
+ // elsewhere, so a `frame_file` of `../x` or a symlink out of the directory
441
+ // must not make replay read and transmit an arbitrary local file.
442
+ function frameFileWithin(logDir, frameFile) {
443
+ if (typeof frameFile !== "string" || !frameFile) return null
444
+ let framesDir
445
+ try {
446
+ framesDir = fs.realpathSync(path.join(logDir, "frames"))
447
+ } catch {
448
+ return null
449
+ }
450
+ const candidate = path.resolve(logDir, frameFile)
451
+ try {
452
+ if (fs.lstatSync(candidate).isSymbolicLink()) return null
453
+ const real = fs.realpathSync(candidate)
454
+ if (path.dirname(real) !== framesDir || !fs.statSync(real).isFile()) return null
455
+ fs.accessSync(real, fs.constants.R_OK)
456
+ return real
457
+ } catch {
458
+ return null
459
+ }
460
+ }
461
+
462
+ function processAlive(pid) {
463
+ if (!pid || !Number.isInteger(pid)) return false
464
+ try {
465
+ process.kill(pid, 0)
466
+ return true
467
+ } catch (error) {
468
+ return error?.code === "EPERM"
469
+ }
470
+ }
471
+
472
+ function processArgs(pid) {
473
+ try {
474
+ return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
475
+ encoding: "utf8",
476
+ stdio: ["ignore", "pipe", "ignore"],
477
+ }).trim()
478
+ } catch {
479
+ return null
480
+ }
481
+ }
482
+
483
+ // Whether the process behind state/server.pid is this root's endpoint:
484
+ // `owned`, `foreign` (the PID was reused by something else), or `unknown`
485
+ // (the command line could not be inspected). The helper is matched by its
486
+ // file name, not the path it was launched from: a session resumed from a
487
+ // newer plugin checkout, or a foreground helper started as
488
+ // `node live-endpoint.js`, is still the same endpoint for this root.
489
+ function inspectServerProcess(options, pid) {
490
+ // Exact argv when the platform exposes it (Linux /proc): the `--root`
491
+ // argument is compared whole, so a root containing spaces or one that is
492
+ // a prefix of another cannot be confused with it.
493
+ const argv = processArgv(pid)
494
+ if (argv) {
495
+ const isHelper = argv.some((token) => path.basename(token) === path.basename(scriptPath))
496
+ const rootIndex = argv.indexOf("--root")
497
+ const forThisRoot = rootIndex !== -1 && argv[rootIndex + 1] === options.root
498
+ const serving = argv.includes("serve") || argv.includes("start")
499
+ return isHelper && forThisRoot && serving ? "owned" : "foreign"
500
+ }
501
+ const args = processArgs(pid)
502
+ if (args === null) return "unknown"
503
+ const tokens = args.split(/\s+/)
504
+ const isHelper = tokens.some((token) => path.basename(token) === path.basename(scriptPath))
505
+ // Flattened command line (`ps`): argv boundaries are gone, so the root must
506
+ // be followed by the next flag or the end of the line. A root that is this
507
+ // one plus more words (`--root /tmp/root other`) is ambiguous and reads as
508
+ // foreign rather than owned.
509
+ const rootArgument = new RegExp(`(^|\\s)--root\\s+${escapeRegExp(options.root)}(\\s+--|\\s*$)`)
510
+ const forThisRoot = rootArgument.test(args)
511
+ const serving = tokens.includes("serve") || tokens.includes("start")
512
+ return isHelper && forThisRoot && serving ? "owned" : "foreign"
513
+ }
514
+
515
+ // The process's argv with its boundaries intact, or null where the platform
516
+ // does not expose it (no /proc, or a PID this user may not read).
517
+ function processArgv(pid) {
518
+ try {
519
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`)
520
+ if (raw.length === 0) return null
521
+ const parts = raw.toString("utf8").split("\0")
522
+ if (parts[parts.length - 1] === "") parts.pop()
523
+ return parts
524
+ } catch {
525
+ return null
526
+ }
527
+ }
528
+
529
+ function escapeRegExp(text) {
530
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
531
+ }
532
+
533
+ function ownsServerProcess(options, pid) {
534
+ return inspectServerProcess(options, pid) === "owned"
535
+ }
536
+
537
+ // A live PID whose ownership cannot be verified is never signalled and never
538
+ // displaced: the caller stops with the PID named rather than risk killing an
539
+ // unrelated process or launching a second writer beside the real endpoint.
540
+ function requireInspectableProcess(options, pid) {
541
+ if (processAlive(pid) && inspectServerProcess(options, pid) === "unknown") {
542
+ throw new Error(`Cannot verify that process ${pid} (state/server.pid) is this root's endpoint: process inspection (ps) is unavailable. Stop it yourself if it is, then remove ${options.pidFile}`)
543
+ }
544
+ }
545
+
546
+ function resolveOwnerPid() {
547
+ const parentPid = process.ppid
548
+ if (!parentPid || parentPid <= 1) return null
549
+ try {
550
+ const grandparent = Number(execFileSync("ps", ["-o", "ppid=", "-p", String(parentPid)], {
551
+ encoding: "utf8",
552
+ stdio: ["ignore", "pipe", "ignore"],
553
+ }).trim())
554
+ if (Number.isInteger(grandparent) && grandparent > 1) return grandparent
555
+ } catch {
556
+ // Fall back to the direct parent when grandparent lookup is unavailable.
557
+ }
558
+ return parentPid
559
+ }
560
+
561
+ function readPid(options) {
562
+ if (!fs.existsSync(options.pidFile)) return null
563
+ const pid = Number(fs.readFileSync(options.pidFile, "utf8").trim())
564
+ return Number.isInteger(pid) ? pid : null
565
+ }
566
+
567
+ function readSession(options) {
568
+ return readJsonOrNull(options.sessionFile)
569
+ }
570
+
571
+ function serverRunning(options) {
572
+ const pid = readPid(options)
573
+ return processAlive(pid) && ownsServerProcess(options, pid)
574
+ }
575
+
576
+ function getRunningInfo(options) {
577
+ if (!serverRunning(options)) return null
578
+ const session = readSession(options)
579
+ if (!session || session.pid !== readPid(options)) return null
580
+ return session
581
+ }
582
+
583
+ function publicStartEnvelope(session) {
584
+ return { url: session.url, port: session.port, page_token: session.page_token }
585
+ }
586
+
587
+ // The address a local client uses to reach the bound interface: a wildcard
588
+ // bind is reached through its loopback, anything else through itself.
589
+ function localAddressFor(host) {
590
+ if (!host || host === "0.0.0.0") return DEFAULT_HOST
591
+ if (host === "::" || host === "[::]") return "[::1]"
592
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
593
+ }
594
+
595
+ function newToken() {
596
+ return randomBytes(32).toString("base64url")
597
+ }
598
+
599
+ function bestEffort(action) {
600
+ try {
601
+ action()
602
+ } catch {
603
+ // Cleanup on a failing disk is advisory.
604
+ }
605
+ }
606
+
607
+ function tokenMatches(candidate, expected) {
608
+ if (typeof candidate !== "string" || typeof expected !== "string") return false
609
+ const a = Buffer.from(candidate)
610
+ const b = Buffer.from(expected)
611
+ return a.length === b.length && timingSafeEqual(a, b)
612
+ }
613
+
614
+ // Credentials are accepted from `Authorization: Bearer` only.
615
+ function bearerToken(req) {
616
+ const auth = req.headers.authorization
617
+ if (typeof auth !== "string" || !auth.startsWith("Bearer ")) return null
618
+ const token = auth.slice(7).trim()
619
+ return token || null
620
+ }
621
+
622
+ function sendJson(res, status, value, headers = {}) {
623
+ // Kept for the server's finish hook, which logs refusals to agent.ndjson.
624
+ if (status >= 400) res.rejection = value
625
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...headers })
626
+ res.end(`${JSON.stringify(value)}\n`)
627
+ }
628
+
629
+ // The path of a request for the rejection log: no query string (a caller may
630
+ // put a token there), no fragment, bounded length. Never decoded, so a
631
+ // malformed escape cannot throw here.
632
+ function loggedRoute(req) {
633
+ const raw = typeof req.url === "string" ? req.url : ""
634
+ return raw.split("?")[0].split("#")[0].slice(0, 200) || null
635
+ }
636
+
637
+ // What a refusal says about itself, from the body this server wrote: the
638
+ // contract's `reason`, else its `error`, else its `status` word.
639
+ function rejectionReason(value) {
640
+ if (!isRecord(value)) return null
641
+ for (const key of ["reason", "error", "status"]) {
642
+ if (typeof value[key] === "string") return value[key]
643
+ }
644
+ return null
645
+ }
646
+
647
+ // Reads a body up to `limit` bytes. Past the limit the request is drained
648
+ // (so the 413 the caller writes is delivered) and the promise resolves with
649
+ // `{ tooLarge: true }`; a hard ceiling destroys the socket so an attacker
650
+ // cannot make the server read forever.
651
+ // Reads a request body up to `limit`. Past the limit the body is discarded
652
+ // but read to its end, so the caller's 413 travels on an orderly connection.
653
+ // Past the hard ceiling the caller gets its answer at once (the page needs
654
+ // the 413 to replace the frame with a `dropped` envelope, and a reset would
655
+ // read as an outage) while the rest is discarded as it arrives; only an
656
+ // upload that keeps going far beyond that is cut off.
657
+ function readBody(req, limit) {
658
+ return new Promise((resolve, reject) => {
659
+ const chunks = []
660
+ let size = 0
661
+ let tooLarge = false
662
+ let settled = false
663
+ const settle = (value) => {
664
+ if (settled) return
665
+ settled = true
666
+ resolve(value)
667
+ }
668
+ const hardCeiling = Math.max(limit * 2, limit + 1024 * 1024)
669
+ const abuseCeiling = hardCeiling * 16
670
+ req.on("data", (chunk) => {
671
+ size += chunk.length
672
+ if (tooLarge) {
673
+ if (size > hardCeiling) settle({ tooLarge: true, size })
674
+ if (size > abuseCeiling) req.destroy()
675
+ return
676
+ }
677
+ if (size > limit) {
678
+ tooLarge = true
679
+ chunks.length = 0
680
+ return
681
+ }
682
+ chunks.push(chunk)
683
+ })
684
+ req.on("end", () => settle(tooLarge ? { tooLarge: true, size } : { text: Buffer.concat(chunks).toString("utf8"), size }))
685
+ req.on("error", (error) => {
686
+ if (settled) return
687
+ settled = true
688
+ reject(error)
689
+ })
690
+ })
691
+ }
692
+
693
+ function parseJsonObject(text) {
694
+ try {
695
+ const value = JSON.parse(text)
696
+ return value && typeof value === "object" ? value : null
697
+ } catch {
698
+ return null
699
+ }
700
+ }
701
+
702
+ function isLoopback(address) {
703
+ if (!address) return false
704
+ const plain = address.replace(/^::ffff:/, "")
705
+ return plain === "127.0.0.1" || plain === "::1" || plain.startsWith("127.")
706
+ }
707
+
708
+ const SECRET_SHAPES = [
709
+ /\bsk-[A-Za-z0-9_-]{8,}/,
710
+ /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}/,
711
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/,
712
+ /\bAKIA[0-9A-Z]{16}\b/,
713
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/,
714
+ /\bAIza[0-9A-Za-z_-]{30,}/,
715
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
716
+ /\b[A-Za-z0-9_.-]*(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?)\s*[=:]\s*["']?[^\s"']{6,}/i,
717
+ /[?&](token|key|api_key|apikey|secret|password|access_token|auth|sig|signature)=[^&\s]+/i,
718
+ // Any URL scheme with userinfo credentials: postgres://u:p@h, https://u:p@h.
719
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/i,
720
+ ]
721
+
722
+ function briefContainsSecret(text) {
723
+ return SECRET_SHAPES.some((shape) => shape.test(text))
724
+ }
725
+
726
+ function readBrief(options) {
727
+ try {
728
+ return fs.readFileSync(options.briefFile, "utf8").slice(0, BRIEF_MAX_CHARS)
729
+ } catch {
730
+ return ""
731
+ }
732
+ }
733
+
734
+ function directorySize(dir) {
735
+ let total = 0
736
+ let entries
737
+ try {
738
+ entries = fs.readdirSync(dir, { withFileTypes: true })
739
+ } catch {
740
+ return 0
741
+ }
742
+ for (const entry of entries) {
743
+ const full = path.join(dir, entry.name)
744
+ if (entry.isDirectory()) total += directorySize(full)
745
+ else if (entry.isFile()) {
746
+ try {
747
+ total += fs.statSync(full).size
748
+ } catch {
749
+ // Removed between readdir and stat.
750
+ }
751
+ }
752
+ }
753
+ return total
754
+ }
755
+
756
+ // Statuses at which the agent is finished with a unit. A released unit at
757
+ // any other status (triaging, accepted, needs_info, working, or one a later
758
+ // build adds) is close-out work the agent still owes: only its own
759
+ // `applied`/`blocked` post retires it. Units never released to the agent
760
+ // (`initial`) are not its work.
761
+ const TERMINAL_UNIT_STATUSES = new Set(["applied", "blocked", "withdrawn"])
762
+
763
+ function unitsPendingCloseOut(board) {
764
+ if (!isRecord(board) || !Array.isArray(board.unit_order) || !isRecord(board.units)) return false
765
+ return board.unit_order.some((id) => {
766
+ const unit = Object.hasOwn(board.units, id) ? board.units[id] : undefined
767
+ return isRecord(unit) && unit.released === true && !TERMINAL_UNIT_STATUSES.has(unit.status)
768
+ })
769
+ }
770
+
771
+ // A `.part` in state/log/ is an archive upload the previous process did not
772
+ // finish: the page retries Done with the whole archive, so the partial holds
773
+ // no evidence and must not count against the disk cap that retry is checked
774
+ // against.
775
+ function removeOrphanedArchiveParts(logDir) {
776
+ let entries
777
+ try {
778
+ entries = fs.readdirSync(logDir, { withFileTypes: true })
779
+ } catch {
780
+ return
781
+ }
782
+ for (const entry of entries) {
783
+ if (entry.isFile() && entry.name.startsWith("archive.") && entry.name.endsWith(ARCHIVE_PART_SUFFIX)) {
784
+ bestEffort(() => fs.rmSync(path.join(logDir, entry.name), { force: true }))
785
+ }
786
+ }
787
+ }
788
+
789
+ // ---------------------------------------------------------------------------
790
+ // Board: the persisted session state `GET /status` and the `status` CLI read.
791
+ // ---------------------------------------------------------------------------
792
+
793
+ function emptyBoard() {
794
+ return {
795
+ schema_version: SCHEMA_VERSION,
796
+ session_id: null,
797
+ ended: false,
798
+ mode: "smart",
799
+ acked_seq: 0,
800
+ units: {},
801
+ unit_order: [],
802
+ annotations: {},
803
+ annotation_order: [],
804
+ answers: [],
805
+ transcript_count: 0,
806
+ frame_count: 0,
807
+ checkpoints: [],
808
+ acked_checkpoint_ids: [],
809
+ released_unit_ids: [],
810
+ released_annotation_ids: [],
811
+ pending_withdrawn: [],
812
+ page: { stream: "never", last_stream_state: null, mic: null, lost_episodes: 0 },
813
+ page_lost_pending: null,
814
+ watch_for_loss: false,
815
+ final_emitted: false,
816
+ }
817
+ }
818
+
819
+ function boardSummary(board, batches, logBytes) {
820
+ const byStatus = {}
821
+ for (const id of board.unit_order) {
822
+ const status = board.units[id]?.status ?? "unknown"
823
+ byStatus[status] = (byStatus[status] ?? 0) + 1
824
+ }
825
+ return {
826
+ schema_version: SCHEMA_VERSION,
827
+ session_id: board.session_id,
828
+ ended: board.ended,
829
+ mode: board.mode,
830
+ page: board.page,
831
+ acked_seq: board.acked_seq,
832
+ units: {
833
+ total: board.unit_order.length,
834
+ by_status: byStatus,
835
+ // Enough for a run resumed after an ack to pick a unit up from the
836
+ // board alone: what to change, where (anchors), and the evidence that
837
+ // grounds it, not just the status it was left at.
838
+ list: board.unit_order.map((id) => {
839
+ const unit = board.units[id]
840
+ return {
841
+ id,
842
+ statement: unit.statement,
843
+ status: unit.status,
844
+ confirmed: unit.confirmed ?? null,
845
+ anchors: Array.isArray(unit.anchors) ? unit.anchors : [],
846
+ evidence: isRecord(unit.evidence) ? unit.evidence : null,
847
+ ...(typeof unit.question === "string" ? { question: unit.question } : {}),
848
+ ...(typeof unit.note === "string" ? { note: unit.note } : {}),
849
+ ...(typeof unit.guess === "string" ? { guess: unit.guess } : {}),
850
+ }
851
+ }),
852
+ },
853
+ annotations: board.annotation_order.length,
854
+ answers: board.answers.length,
855
+ transcript_count: board.transcript_count,
856
+ frame_count: board.frame_count,
857
+ checkpoints: board.checkpoints.length,
858
+ batches: {
859
+ unserved: batches.filter((batch) => !batch.served).length,
860
+ unacked: batches.filter((batch) => batch.served).length,
861
+ },
862
+ page_lost_pending: Boolean(board.page_lost_pending),
863
+ log_bytes: logBytes,
864
+ }
865
+ }
866
+
867
+ function loadBatches(options) {
868
+ let files
869
+ try {
870
+ files = fs.readdirSync(options.batchesDir).filter((file) => file.endsWith(".json"))
871
+ } catch {
872
+ return []
873
+ }
874
+ return files
875
+ .map((file) => readJsonOrNull(path.join(options.batchesDir, file)))
876
+ .filter((batch) => batch && batch.envelope?.checkpoint_id)
877
+ .sort((a, b) => a.order - b.order)
878
+ }
879
+
880
+ // `status` CLI and `GET /status` read the board through this one function.
881
+ function readBoardSummary(options) {
882
+ const board = readJsonOrNull(options.boardFile) ?? emptyBoard()
883
+ return boardSummary(board, loadBatches(options), directorySize(options.logDir))
884
+ }
885
+
886
+ // ---------------------------------------------------------------------------
887
+ // CLI commands
888
+ // ---------------------------------------------------------------------------
889
+
890
+ async function start(options) {
891
+ ensureDirs(options)
892
+ options.ownerPid = options.ownerPid ?? resolveOwnerPid()
893
+ // One start per root at a time, foreground or detached: the running check
894
+ // and the launch happen under the same claim.
895
+ const lockPath = acquireStartLock(options)
896
+ try {
897
+ await startLocked(options)
898
+ } finally {
899
+ fs.rmSync(lockPath, { force: true })
900
+ }
901
+ }
902
+
903
+ function acquireStartLock(options) {
904
+ const lockPath = path.join(options.stateDir, "start.lock")
905
+ let lockFd
906
+ try {
907
+ lockFd = fs.openSync(lockPath, "wx", 0o600)
908
+ } catch (error) {
909
+ if (error.code !== "EEXIST") throw error
910
+ const holder = Number(fs.readFileSync(lockPath, "utf8")) || null
911
+ if (holder && processAlive(holder)) throw new Error("Another `start` for this root is in progress")
912
+ fs.rmSync(lockPath, { force: true })
913
+ lockFd = fs.openSync(lockPath, "wx", 0o600)
914
+ }
915
+ fs.writeSync(lockFd, `${process.pid}\n`)
916
+ fs.closeSync(lockFd)
917
+ return lockPath
918
+ }
919
+
920
+ // Releases the lock only once the session file proves the server is up.
921
+ async function startLocked(options) {
922
+ const running = getRunningInfo(options)
923
+ if (running && !running.ended) {
924
+ if (running.app_origin !== options.appOrigin) {
925
+ throw new Error(`An endpoint for this root is already running with --app-origin ${running.app_origin}; stop it first`)
926
+ }
927
+ jsonOut({ ...publicStartEnvelope(running), status: "running" })
928
+ return
929
+ }
930
+ if (running) await stopServer(options)
931
+ requireInspectableProcess(options, readPid(options))
932
+ fs.rmSync(options.pidFile, { force: true })
933
+ const previous = readSession(options)
934
+
935
+ if (options.foreground) {
936
+ // serve() resolves once listening; the server handle keeps the process up.
937
+ const serving = serve(options)
938
+ serving.catch(() => {})
939
+ const up = await waitForSession(options, process.pid, previous)
940
+ await serving
941
+ if (!up) throw new Error("Endpoint failed to start")
942
+ return null
943
+ }
944
+
945
+ await spawnServe(options, previous)
946
+ }
947
+
948
+ async function spawnServe(options, previous) {
949
+ const logFd = fs.openSync(options.logFile, "a", 0o600)
950
+ const child = spawn(process.execPath, [
951
+ scriptPath,
952
+ "serve",
953
+ "--root",
954
+ options.root,
955
+ "--app-origin",
956
+ options.appOrigin,
957
+ ...(options.hostExplicit ? ["--host", options.host] : []),
958
+ ...(options.port !== undefined ? ["--port", String(options.port)] : []),
959
+ ...(options.ownerPid ? ["--owner-pid", String(options.ownerPid)] : []),
960
+ ...(options.trustProxy.length > 0 ? ["--trust-proxy", options.trustProxy.join(",")] : []),
961
+ ], {
962
+ detached: true,
963
+ stdio: ["ignore", logFd, logFd],
964
+ })
965
+ child.unref()
966
+ fs.closeSync(logFd)
967
+
968
+ const started = await waitForSession(options, child.pid, previous)
969
+ if (!started) {
970
+ throw new Error(`Endpoint failed to start. See ${options.logFile}`)
971
+ }
972
+ // The child decides whether it resumed; the same tokens are the proof.
973
+ const resumed = Boolean(previous?.agent_token) && started.agent_token === previous.agent_token
974
+ jsonOut({ ...publicStartEnvelope(started), status: resumed ? "resumed" : "started" })
975
+ }
976
+
977
+ async function waitForSession(options, pid, previous) {
978
+ for (let i = 0; i < 100; i++) {
979
+ const session = readSession(options)
980
+ if (session && session.pid === pid && session.pid !== previous?.pid) return session
981
+ if (pid && !processAlive(pid)) return null
982
+ await new Promise((resolve) => setTimeout(resolve, 50))
983
+ }
984
+ return null
985
+ }
986
+
987
+ // A new session after an ended one keeps the old log by rotating it.
988
+ function rotateLog(options) {
989
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-")
990
+ const rotated = path.join(options.stateDir, `log-ended-${stamp}`)
991
+ try {
992
+ fs.renameSync(options.logDir, rotated)
993
+ } catch {
994
+ // Nothing to rotate.
995
+ }
996
+ ensureDirs(options)
997
+ }
998
+
999
+ // Work the agent still owes this root: a batch on disk (served or not, never
1000
+ // acknowledged), or a released unit the board holds at a status only the
1001
+ // agent's `applied`/`blocked` post can retire. An ended session that holds
1002
+ // work resumes on `start`, `wait` sends the agent back to `start` for it,
1003
+ // and the page cannot open a new session over it.
1004
+ function sessionHoldsWork(options) {
1005
+ if (loadBatches(options).length > 0) return true
1006
+ return unitsPendingCloseOut(readJsonOrNull(options.boardFile))
1007
+ }
1008
+
1009
+ function endedAndDrained(options) {
1010
+ const stopped = readSession(options)
1011
+ if (!stopped?.ended) return false
1012
+ // `stop` retired the tokens: nothing can be posted against this session
1013
+ // any more, so whatever the board still holds is terminal as it stands.
1014
+ if (!stopped.agent_token) return true
1015
+ return !sessionHoldsWork(options)
1016
+ }
1017
+
1018
+ function exitSessionEnded() {
1019
+ process.exitCode = 1
1020
+ jsonOut({ status: "session-ended" })
1021
+ }
1022
+
1023
+ async function wait(options) {
1024
+ process.stdout.on("error", (error) => {
1025
+ console.error(error.message)
1026
+ process.exitCode = 2
1027
+ })
1028
+ const info = getRunningInfo(options)
1029
+ if (!info?.port) {
1030
+ // Idle/owner shutdown leaves the session file in place; an ended session
1031
+ // must still report that terminal status rather than "not running".
1032
+ // Ended and drained is terminal; ended with batches on disk still holds
1033
+ // work and needs a resume.
1034
+ if (endedAndDrained(options)) return exitSessionEnded()
1035
+ console.error("Endpoint is not running; run `start --root` to resume the session")
1036
+ process.exit(2)
1037
+ }
1038
+ if (!info.agent_token) {
1039
+ if (info.ended) return exitSessionEnded()
1040
+ console.error("No agent token in state/session.json")
1041
+ process.exit(2)
1042
+ }
1043
+
1044
+ const url = `http://${localAddressFor(info.host)}:${info.port}/wait`
1045
+ const headers = { Authorization: `Bearer ${info.agent_token}` }
1046
+ while (true) {
1047
+ let response
1048
+ try {
1049
+ response = await fetch(url, { headers })
1050
+ } catch {
1051
+ // The helper may have died mid-poll; a retained final batch still needs
1052
+ // a resume, never a "session-ended" exit.
1053
+ if (endedAndDrained(options)) return exitSessionEnded()
1054
+ console.error("Endpoint is not running; run `start --root` to resume the session")
1055
+ process.exit(2)
1056
+ }
1057
+ if (response.status === 200 || response.status === 410) {
1058
+ const text = await response.text()
1059
+ // Let pending stdout writes drain instead of truncating a piped batch.
1060
+ process.exitCode = response.status === 200 ? 0 : 1
1061
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`)
1062
+ return
1063
+ }
1064
+ if (response.status === 409) {
1065
+ process.exitCode = 3
1066
+ jsonOut({ status: "wait-taken" })
1067
+ return
1068
+ }
1069
+ if (response.status === 204) continue
1070
+ console.error(`wait: unexpected HTTP ${response.status}`)
1071
+ process.exit(2)
1072
+ }
1073
+ }
1074
+
1075
+ async function stopServer(options) {
1076
+ const pid = readPid(options)
1077
+ requireInspectableProcess(options, pid)
1078
+ if (processAlive(pid) && ownsServerProcess(options, pid)) {
1079
+ process.kill(pid)
1080
+ for (let i = 0; i < 20; i++) {
1081
+ if (!processAlive(pid)) break
1082
+ await new Promise((resolve) => setTimeout(resolve, 100))
1083
+ }
1084
+ if (processAlive(pid)) {
1085
+ try {
1086
+ process.kill(pid, "SIGKILL")
1087
+ } catch {
1088
+ // Process may have exited between the liveness check and kill.
1089
+ }
1090
+ }
1091
+ }
1092
+ fs.rmSync(options.pidFile, { force: true })
1093
+ }
1094
+
1095
+ // `stop` ends the session: both tokens are invalidated, un-acked batches are
1096
+ // discarded, and state/log/ is kept for replay.
1097
+ async function stop(options) {
1098
+ await stopServer(options)
1099
+ const session = readSession(options)
1100
+ if (session) {
1101
+ writePrivateJson(options.sessionFile, { ...session, page_token: null, agent_token: null, ended: true, pid: null })
1102
+ }
1103
+ const board = readJsonOrNull(options.boardFile)
1104
+ if (board) writePrivateJson(options.boardFile, { ...board, ended: true })
1105
+ fs.rmSync(options.batchesDir, { recursive: true, force: true })
1106
+ jsonOut({ status: "stopped", root: options.root, log_dir: options.logDir })
1107
+ }
1108
+
1109
+ function status(options) {
1110
+ const running = getRunningInfo(options)
1111
+ const session = readSession(options)
1112
+ jsonOut({
1113
+ status: running ? "running" : "stopped",
1114
+ root: options.root,
1115
+ ...(running ? { url: running.url, port: running.port, app_origin: running.app_origin } : {}),
1116
+ session_ended: Boolean(session?.ended),
1117
+ board: readBoardSummary(options),
1118
+ })
1119
+ }
1120
+
1121
+ // ---------------------------------------------------------------------------
1122
+ // Replay: re-emit state/log/ under an evidence profile to another endpoint.
1123
+ // ---------------------------------------------------------------------------
1124
+
1125
+ const TELEMETRY_EVENT_TYPES = new Set(["click", "navigation", "network_request", "console_error"])
1126
+
1127
+ // `retainedFrames` is the set of frame ids the profile keeps, so unit
1128
+ // evidence never points at a frame the replay did not send.
1129
+ function applyProfile(envelope, profile, retainedFrames) {
1130
+ const rules = EVIDENCE_PROFILES[profile]
1131
+ const { type, payload } = envelope
1132
+ if (type === "frame") {
1133
+ if (rules.frames === "none") return null
1134
+ if (rules.frames === "composite" && payload?.kind !== "composite") return null
1135
+ return envelope
1136
+ }
1137
+ if (TELEMETRY_EVENT_TYPES.has(type) && !rules.telemetry) return null
1138
+ if (type === "annotation" && !rules.annotations) return null
1139
+ if ((type === "unit" || type === "unit_update") && payload && typeof payload === "object") {
1140
+ const next = { ...payload }
1141
+ if (next.evidence && typeof next.evidence === "object") {
1142
+ const evidence = { ...next.evidence }
1143
+ if (rules.frames === "none") evidence.frame_ids = []
1144
+ else if (Array.isArray(evidence.frame_ids)) evidence.frame_ids = evidence.frame_ids.filter((id) => retainedFrames.has(id))
1145
+ if (!rules.annotations) evidence.annotation_ids = []
1146
+ if (!rules.clips) delete evidence.audio_clip_id
1147
+ if (!rules.telemetry) delete evidence.telemetry_window
1148
+ next.evidence = evidence
1149
+ }
1150
+ return { ...envelope, payload: next }
1151
+ }
1152
+ return envelope
1153
+ }
1154
+
1155
+ async function replay(options) {
1156
+ const eventsFile = path.join(options.logDir, "events.ndjson")
1157
+ if (!fs.existsSync(eventsFile)) throw new Error(`No session log at ${eventsFile}`)
1158
+ // No longer than the recorded id: the rewrite must not push an envelope the
1159
+ // source accepted at the 64 KB cap over it at the target.
1160
+ const recorded = readJsonOrNull(options.boardFile)?.session_id ?? ""
1161
+ const sessionId = `r${randomBytes(16).toString("hex")}`.slice(0, Math.max(1, String(recorded).length))
1162
+ const headers = {
1163
+ "Content-Type": "application/json",
1164
+ Authorization: `Bearer ${options.token}`,
1165
+ "X-Riffrec-Session": sessionId,
1166
+ }
1167
+ let seq = 0
1168
+ let sent = 0
1169
+ let skipped = 0
1170
+ let batch = []
1171
+ let batchBytes = 0
1172
+
1173
+ // First pass: which frames the profile keeps, so evidence can be pruned to
1174
+ // them on the second pass. Only ids are held, and only for frames the
1175
+ // second pass will actually emit: one whose image file is missing is
1176
+ // skipped there, so its id must not survive in a unit's evidence either.
1177
+ const retainedFrames = new Set()
1178
+ const frameRule = EVIDENCE_PROFILES[options.profile].frames
1179
+ if (frameRule !== "none") {
1180
+ const scan = readline.createInterface({ input: fs.createReadStream(eventsFile, "utf8"), crlfDelay: Infinity })
1181
+ for await (const line of scan) {
1182
+ const stored = line ? parseJsonObject(line) : null
1183
+ if (stored?.type !== "frame" || typeof stored.payload?.id !== "string") continue
1184
+ if (frameRule === "composite" && stored.payload.kind !== "composite") continue
1185
+ const emittable = !stored.frame_file || frameFileWithin(options.logDir, stored.frame_file) !== null
1186
+ if (emittable) retainedFrames.add(stored.payload.id)
1187
+ }
1188
+ }
1189
+
1190
+ async function post(envelopes) {
1191
+ const body = JSON.stringify(envelopes.length === 1 ? envelopes[0] : envelopes)
1192
+ const response = await fetch(`${options.to}/events`, { method: "POST", headers, body })
1193
+ if (!response.ok) {
1194
+ const text = await response.text().catch(() => "")
1195
+ throw new Error(`replay: ${options.to}/events answered ${response.status} ${text.trim()}`)
1196
+ }
1197
+ sent += envelopes.length
1198
+ }
1199
+
1200
+ async function flush() {
1201
+ if (batch.length === 0) return
1202
+ const pending = batch
1203
+ batch = []
1204
+ batchBytes = 0
1205
+ await post(pending)
1206
+ }
1207
+
1208
+ // Encoded size of the request body the batch would produce: a lone
1209
+ // envelope is posted bare, so it fits whenever the target accepted it.
1210
+ function bodyBytes(count, payloadBytes) {
1211
+ return count <= 1 ? payloadBytes : payloadBytes + 2 + (count - 1)
1212
+ }
1213
+
1214
+ // Line by line: a log near the 500 MB budget must not be held whole.
1215
+ const lines = readline.createInterface({ input: fs.createReadStream(eventsFile, "utf8"), crlfDelay: Infinity })
1216
+ for await (const line of lines) {
1217
+ if (!line) continue
1218
+ const stored = parseJsonObject(line)
1219
+ if (!stored || typeof stored.type !== "string") continue
1220
+ let envelope = { schema_version: SCHEMA_VERSION, session_id: sessionId, seq: 0, t: stored.t, type: stored.type, payload: stored.payload }
1221
+ if (stored.type === "frame" && !stored.frame_file) {
1222
+ envelope = { ...envelope, payload: { ...stored.payload, jpeg_base64: "" } }
1223
+ } else if (stored.type === "frame") {
1224
+ try {
1225
+ const frameFile = frameFileWithin(options.logDir, stored.frame_file)
1226
+ if (frameFile === null) throw new Error("frame file outside the selected log")
1227
+ const jpeg = fs.readFileSync(frameFile)
1228
+ envelope = { ...envelope, payload: { ...stored.payload, jpeg_base64: jpeg.toString("base64") } }
1229
+ } catch {
1230
+ skipped += 1
1231
+ continue
1232
+ }
1233
+ }
1234
+ envelope = applyProfile(envelope, options.profile, retainedFrames)
1235
+ if (!envelope) {
1236
+ skipped += 1
1237
+ continue
1238
+ }
1239
+ envelope.seq = ++seq
1240
+ if (envelope.type === "frame") {
1241
+ await flush()
1242
+ await post([envelope])
1243
+ continue
1244
+ }
1245
+ // The target's 64 KB cap is in encoded bytes; measure the same way and
1246
+ // flush before the envelope that would cross it.
1247
+ const encoded = Buffer.byteLength(JSON.stringify(envelope))
1248
+ if (envelope.type !== "frame" && encoded > BODY_LIMIT) {
1249
+ throw new Error(`replay: envelope ${stored.seq ?? "?"} (${envelope.type}) is ${encoded} bytes, over the ${BODY_LIMIT}-byte cap even posted alone`)
1250
+ }
1251
+ if (batch.length > 0 && bodyBytes(batch.length + 1, batchBytes + encoded) > BODY_LIMIT) await flush()
1252
+ batch.push(envelope)
1253
+ batchBytes += encoded
1254
+ if (envelope.type === "checkpoint") await flush()
1255
+ }
1256
+ await flush()
1257
+ jsonOut({ status: "replayed", to: options.to, profile: options.profile, session_id: sessionId, envelopes_sent: sent, envelopes_skipped: skipped })
1258
+ }
1259
+
1260
+ // ---------------------------------------------------------------------------
1261
+ // Server
1262
+ // ---------------------------------------------------------------------------
1263
+
1264
+ async function serve(options) {
1265
+ ensureDirs(options)
1266
+
1267
+ // Resume when a live session file exists for this root; otherwise mint a
1268
+ // fresh pair of credentials and start a new board.
1269
+ const previous = readSession(options)
1270
+ // The end of a session is two writes (board, then session record). A
1271
+ // process that died between them left one saying ended and the other
1272
+ // not; either one saying so is the recorded intent, and both are brought
1273
+ // to it here before anything is served, so a resume cannot answer 410 on
1274
+ // page routes while `status` still reports the session live.
1275
+ const storedBoard = readJsonOrNull(options.boardFile)
1276
+ if (previous && storedBoard?.ended === true && !previous.ended) previous.ended = true
1277
+ // An ended session whose agent token is still retained resumes while the
1278
+ // agent still owes it close-out work: a batch not yet acknowledged, or a
1279
+ // unit the board holds at triaging/accepted/needs_info that the resumed
1280
+ // run must apply or mark blocked. Only a drained, reconciled ended root
1281
+ // starts fresh.
1282
+ const draining = Boolean(previous?.ended && previous.agent_token && sessionHoldsWork(options))
1283
+ const resuming = Boolean(previous && previous.agent_token && ((!previous.ended && previous.page_token) || draining))
1284
+ // A resume keeps the previous bind host unless the caller names a new one;
1285
+ // the documented recovery is a bare `start --root <dir>` again.
1286
+ if (resuming && !options.hostExplicit && typeof previous.host === "string" && previous.host) options.host = previous.host
1287
+ if (resuming && options.trustProxy.length === 0 && Array.isArray(previous.trust_proxy)) options.trustProxy = previous.trust_proxy.filter((ip) => net.isIP(ip))
1288
+ const pageToken = resuming ? previous.page_token : newToken()
1289
+ const agentToken = resuming ? previous.agent_token : newToken()
1290
+ if (!resuming) {
1291
+ fs.rmSync(options.batchesDir, { recursive: true, force: true })
1292
+ fs.rmSync(options.boardFile, { force: true })
1293
+ fs.mkdirSync(options.batchesDir, { recursive: true, mode: 0o700 })
1294
+ if (previous?.ended) rotateLog(options)
1295
+ }
1296
+ const board = resuming ? { ...emptyBoard(), ...(storedBoard ?? {}) } : emptyBoard()
1297
+ if (resuming && previous.ended) board.ended = true
1298
+ // A batch file whose checkpoint the board already records as acknowledged
1299
+ // is the retirement half of an ack that did not finish; it is not served again.
1300
+ const batches = []
1301
+ for (const batch of resuming ? loadBatches(options) : []) {
1302
+ if (board.acked_checkpoint_ids.includes(batch.envelope.checkpoint_id)) {
1303
+ bestEffort(() => fs.rmSync(path.join(options.batchesDir, `${encodeURIComponent(batch.envelope.checkpoint_id)}.json`), { force: true }))
1304
+ } else {
1305
+ batches.push(batch)
1306
+ }
1307
+ }
1308
+ let batchOrder = batches.reduce((max, batch) => Math.max(max, batch.order), 0)
1309
+ const port = options.port ?? (resuming && Number.isInteger(previous.port) ? previous.port : 0)
1310
+
1311
+ const eventsLog = path.join(options.logDir, "events.ndjson")
1312
+ const agentLog = path.join(options.logDir, "agent.ndjson")
1313
+ const framesDir = path.join(options.logDir, "frames")
1314
+ removeOrphanedArchiveParts(options.logDir)
1315
+ let logBytes = directorySize(options.logDir)
1316
+ let session = null
1317
+ let waiter = null
1318
+ // What the agent is actually doing, for the page: "listening" while a wait is
1319
+ // parked, "working" from the moment a batch is served until the next wait,
1320
+ // "away" when no wait has parked for a while.
1321
+ let agentState = { state: "away", since: Date.now() }
1322
+ let agentAwayTimer = null
1323
+ function setAgentState(state, extra = {}) {
1324
+ if (state !== "away" && agentAwayTimer) {
1325
+ clearTimeout(agentAwayTimer)
1326
+ agentAwayTimer = null
1327
+ }
1328
+ if (agentState.state === state && !extra.checkpoint_id) return
1329
+ agentState = { state, since: Date.now(), ...extra }
1330
+ broadcast("agent", agentState)
1331
+ }
1332
+ function agentMaybeAway() {
1333
+ if (agentAwayTimer || agentState.state === "working") return
1334
+ agentAwayTimer = setTimeout(() => {
1335
+ agentAwayTimer = null
1336
+ if (!waiter) setAgentState("away")
1337
+ }, AGENT_AWAY_GRACE_MS)
1338
+ agentAwayTimer.unref?.()
1339
+ }
1340
+ const streamClients = new Set()
1341
+ const outOfOrder = new Map()
1342
+ let endingInFlight = false
1343
+ // Non-null while an envelope transaction is open; SSE notices and wakes
1344
+ // queue here and are delivered only once the transaction commits.
1345
+ let heldNotices = null
1346
+ // Ids are validated but may still name inherited Object properties.
1347
+ const unitById = (id) => (Object.hasOwn(board.units, id) ? board.units[id] : undefined)
1348
+ const annotationById = (id) => (Object.hasOwn(board.annotations, id) ? board.annotations[id] : undefined)
1349
+ // Bytes of frames waiting in the gap buffer, counted against the disk cap
1350
+ // before they land so a burst of early frames cannot overshoot it.
1351
+ let reservedBytes = 0
1352
+ let pageLostTimer = null
1353
+ let mintInFlight = false
1354
+ const mintTimes = []
1355
+ let lastActivity = Date.now()
1356
+ const touch = () => {
1357
+ lastActivity = Date.now()
1358
+ }
1359
+
1360
+ function saveBoard() {
1361
+ writePrivateJson(options.boardFile, board)
1362
+ }
1363
+
1364
+ function saveSession(patch) {
1365
+ session = { ...session, ...patch }
1366
+ writePrivateJson(options.sessionFile, session)
1367
+ }
1368
+
1369
+ function logEvent(record) {
1370
+ const line = `${JSON.stringify(record)}\n`
1371
+ appendPrivate(eventsLog, line)
1372
+ logBytes += Buffer.byteLength(line)
1373
+ }
1374
+
1375
+ function logAgent(record) {
1376
+ const line = `${JSON.stringify({ t: Date.now(), ...record })}\n`
1377
+ appendPrivate(agentLog, line)
1378
+ logBytes += Buffer.byteLength(line)
1379
+ }
1380
+
1381
+ // Events a reconnecting stream cannot rebuild from the board (`applied`
1382
+ // is a moment, not a state) wait here while no stream is attached: after
1383
+ // a flushed response the page is between connections for a second or
1384
+ // more, and a notice posted then would otherwise never reach it. Events
1385
+ // the connect-time replay reconstructs are not kept; the replay is newer.
1386
+ const REPLAYED_ON_CONNECT = new Set(["ack", "unit_status", "ask", "agent"])
1387
+ const PENDING_DELIVERY_CAP = 256
1388
+ const pendingDeliveries = []
1389
+
1390
+ function broadcast(event, payload) {
1391
+ if (heldNotices) {
1392
+ heldNotices.push({ event, payload })
1393
+ return
1394
+ }
1395
+ const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`
1396
+ if (streamClients.size === 0 && !REPLAYED_ON_CONNECT.has(event) && !board.ended) {
1397
+ pendingDeliveries.push(frame)
1398
+ if (pendingDeliveries.length > PENDING_DELIVERY_CAP) pendingDeliveries.shift()
1399
+ }
1400
+ for (const client of streamClients) {
1401
+ if (!client.writableEnded) client.write(frame)
1402
+ }
1403
+ // An ack rides every POST and the page already has it in that POST's
1404
+ // response; ending the stream for it would keep the page reconnecting.
1405
+ // The agent state changes on every wait; it rides the stream without forcing a reconnect.
1406
+ if (event !== "ack" && event !== "agent") scheduleStreamFlush()
1407
+ }
1408
+
1409
+ // Delivers what a transaction held once it has committed: wakes to the
1410
+ // parked agent, notices to the stream. A no-op when nothing is held.
1411
+ function releaseHeldNotices() {
1412
+ const notices = heldNotices
1413
+ heldNotices = null
1414
+ if (!notices) return
1415
+ for (const notice of notices) {
1416
+ if (notice.wake) fulfillWaiter()
1417
+ else broadcast(notice.event, notice.payload)
1418
+ }
1419
+ }
1420
+
1421
+ // A stream response is ended shortly after a delivery. Some TLS-terminating
1422
+ // intermediaries (cloudflared quick tunnels, fronted by a Worker) hold a
1423
+ // streaming body until the response completes, so an open SSE stream never
1424
+ // reaches the page; a completed one does at once. The page reconnects a
1425
+ // second later and handleStream replays every unit's current state, so on
1426
+ // such a path the stream is a sequence of short responses, and on a direct
1427
+ // path the same responses arrive as they are written. The delay lets a
1428
+ // burst (a release, an accepted then applied) ride one response.
1429
+ let streamFlushTimer = null
1430
+ function scheduleStreamFlush() {
1431
+ if (streamClients.size === 0) return
1432
+ if (streamFlushTimer) clearTimeout(streamFlushTimer)
1433
+ streamFlushTimer = setTimeout(() => {
1434
+ streamFlushTimer = null
1435
+ for (const client of streamClients) {
1436
+ if (!client.writableEnded) client.end()
1437
+ }
1438
+ }, STREAM_FLUSH_MS)
1439
+ streamFlushTimer.unref?.()
1440
+ }
1441
+
1442
+ // --- batches ---------------------------------------------------------------
1443
+
1444
+ function batchFile(checkpointId) {
1445
+ return path.join(options.batchesDir, `${encodeURIComponent(checkpointId)}.json`)
1446
+ }
1447
+
1448
+ function persistBatch(batch) {
1449
+ writePrivateJson(batchFile(batch.envelope.checkpoint_id), batch)
1450
+ }
1451
+
1452
+ function heldUnits() {
1453
+ return board.unit_order
1454
+ .map((id) => unitById(id))
1455
+ .filter((unit) => unit && !unit.released && unit.status !== "withdrawn")
1456
+ }
1457
+
1458
+ function heldAnnotations() {
1459
+ return board.annotation_order
1460
+ .map((id) => annotationById(id))
1461
+ .filter((annotation) => annotation && !annotation.released)
1462
+ }
1463
+
1464
+ function makeEnvelope(checkpointId, kind, mode, extra) {
1465
+ return {
1466
+ schema_version: SCHEMA_VERSION,
1467
+ checkpoint_id: checkpointId,
1468
+ kind,
1469
+ mode_at_checkpoint: mode,
1470
+ session_status: "live",
1471
+ units: [],
1472
+ annotations: [],
1473
+ answers: [],
1474
+ ...extra,
1475
+ }
1476
+ }
1477
+
1478
+ function publicUnit(unit) {
1479
+ const { released, checkpoint_id, ...rest } = unit
1480
+ return rest
1481
+ }
1482
+
1483
+ function publicAnnotation(annotation) {
1484
+ const { released, ...rest } = annotation
1485
+ return rest
1486
+ }
1487
+
1488
+ // KTD12: units the agent accepted but has not applied or blocked since.
1489
+ // `final` and `mode_change` carry them so a Collect backlog is applied.
1490
+ function backlogUnits() {
1491
+ return board.unit_order
1492
+ .map((id) => unitById(id))
1493
+ .filter((unit) => unit && unit.released && unit.status === "accepted")
1494
+ }
1495
+
1496
+ // A checkpoint releases every held unit and annotation plus the
1497
+ // withdrawals that arrived after an earlier release. `silence`,
1498
+ // `page_change`, and `send` wake the agent only when they release
1499
+ // something; `final` always wakes and also carries the accepted backlog.
1500
+ function releaseCheckpoint(checkpointId, kind, mode) {
1501
+ const units = heldUnits()
1502
+ const annotations = heldAnnotations()
1503
+ const withdrawn = board.pending_withdrawn.map((id) => unitById(id)).filter(Boolean)
1504
+ const backlog = kind === "final" ? backlogUnits() : []
1505
+ const releases = units.length + annotations.length + withdrawn.length + backlog.length > 0
1506
+ if (!releases && !ALWAYS_WAKE_KINDS.has(kind)) return null
1507
+ for (const unit of units) {
1508
+ unit.released = true
1509
+ unit.status = "triaging"
1510
+ unit.checkpoint_id = checkpointId
1511
+ board.released_unit_ids.push(unit.id)
1512
+ }
1513
+ for (const annotation of annotations) {
1514
+ annotation.released = true
1515
+ board.released_annotation_ids.push(annotation.id)
1516
+ }
1517
+ board.pending_withdrawn = []
1518
+ board.mode = mode
1519
+ board.checkpoints.push({ id: checkpointId, kind, mode, t: Date.now() })
1520
+ if (kind === "final") board.final_emitted = true
1521
+ const envelope = makeEnvelope(checkpointId, kind, mode, {
1522
+ units: [
1523
+ ...units.map(publicUnit),
1524
+ ...backlog.map(publicUnit),
1525
+ ...withdrawn.map((unit) => ({ ...publicUnit(unit), status: "withdrawn" })),
1526
+ ],
1527
+ annotations: annotations.map(publicAnnotation),
1528
+ })
1529
+ enqueueBatch(envelope)
1530
+ for (const unit of units) broadcast("unit_status", { unit_id: unit.id, status: "triaging" })
1531
+ saveBoard()
1532
+ return envelope
1533
+ }
1534
+
1535
+ // KTD12: leaving Collect wakes the agent at once with the accepted backlog.
1536
+ function emitModeChange(mode) {
1537
+ const checkpointId = `ck-mode-${randomUUID()}`
1538
+ board.checkpoints.push({ id: checkpointId, kind: "mode_change", mode, t: Date.now() })
1539
+ enqueueBatch(makeEnvelope(checkpointId, "mode_change", mode, { units: backlogUnits().map(publicUnit) }))
1540
+ }
1541
+
1542
+ function enqueueBatch(envelope) {
1543
+ const batch = { order: ++batchOrder, served: false, envelope }
1544
+ batches.push(batch)
1545
+ persistBatch(batch)
1546
+ fulfillWaiter()
1547
+ }
1548
+
1549
+ function nextBatch() {
1550
+ return batches.find((batch) => batch.served) ?? batches.find((batch) => !batch.served) ?? null
1551
+ }
1552
+
1553
+ function takeWaiter() {
1554
+ const parked = waiter
1555
+ waiter = null
1556
+ clearTimeout(parked.timer)
1557
+ return parked.res
1558
+ }
1559
+
1560
+ // Serve order: a batch served without an ack first, then the oldest new
1561
+ // batch, then a pending page-lost notice; an ended session with nothing
1562
+ // held answers 410.
1563
+ function fulfillWaiter() {
1564
+ if (heldNotices) {
1565
+ heldNotices.push({ wake: true })
1566
+ return
1567
+ }
1568
+ if (!waiter || waiter.res.writableEnded) return
1569
+ const batch = nextBatch()
1570
+ if (batch) {
1571
+ batch.served = true
1572
+ persistBatch(batch)
1573
+ sendJson(takeWaiter(), 200, batch.envelope)
1574
+ setAgentState("working", { checkpoint_id: batch.envelope.checkpoint_id })
1575
+ return
1576
+ }
1577
+ if (board.page_lost_pending) {
1578
+ // Left by a helper from before page-lost wakes were queued as batches.
1579
+ const envelope = board.page_lost_pending
1580
+ board.page_lost_pending = null
1581
+ saveBoard()
1582
+ enqueueBatch(envelope)
1583
+ return
1584
+ }
1585
+ if (board.ended) {
1586
+ sendJson(takeWaiter(), 410, { status: "session-ended" })
1587
+ }
1588
+ }
1589
+
1590
+ // --- page-lost detection (KTD8) ----------------------------------------------
1591
+
1592
+ function armPageLost() {
1593
+ if (pageLostTimer || board.ended) return
1594
+ pageLostTimer = setTimeout(() => {
1595
+ pageLostTimer = null
1596
+ if (streamClients.size > 0 || board.ended) return
1597
+ const last = board.checkpoints[board.checkpoints.length - 1]
1598
+ // Queued like any batch: persisted and re-served until acknowledged.
1599
+ // The batch lands before the watch is cleared, so a failure at either
1600
+ // step leaves the durable board still watching: a restart re-arms it
1601
+ // and the loss is reported then, rather than lost with a crashed timer.
1602
+ // The wake is held until the whole transition has committed, so a
1603
+ // parked agent never receives a batch that a failed board save then
1604
+ // takes back from under its acknowledgment.
1605
+ const undo = snapshotState()
1606
+ heldNotices = []
1607
+ try {
1608
+ enqueueBatch(makeEnvelope(`page-lost-${randomUUID()}`, last?.kind ?? "send", board.mode, {
1609
+ session_status: "page_lost",
1610
+ lost_after_checkpoint_id: last?.id ?? null,
1611
+ }))
1612
+ board.page.lost_episodes += 1
1613
+ board.page.stream = "lost"
1614
+ board.watch_for_loss = false
1615
+ saveBoard()
1616
+ } catch (error) {
1617
+ heldNotices = null
1618
+ undo()
1619
+ bestEffort(() => logAgent({ kind: "page_lost_persist_failed", error: error.code ?? error.message }))
1620
+ armPageLost()
1621
+ return
1622
+ }
1623
+ releaseHeldNotices()
1624
+ }, PAGE_LOST_GRACE_MS)
1625
+ pageLostTimer.unref()
1626
+ }
1627
+
1628
+ function disarmPageLost() {
1629
+ if (pageLostTimer) {
1630
+ clearTimeout(pageLostTimer)
1631
+ pageLostTimer = null
1632
+ }
1633
+ }
1634
+
1635
+ // The watch set by an applied notice is one-shot: it ends when the page
1636
+ // reconnects, or once the grace window passes with the stream still up
1637
+ // (the edit did not take the page down). Without that expiry, a stream
1638
+ // that never dropped would carry the watch to the next ordinary disconnect.
1639
+ let watchForLossTimer = null
1640
+
1641
+ function clearWatchForLoss() {
1642
+ if (watchForLossTimer) {
1643
+ clearTimeout(watchForLossTimer)
1644
+ watchForLossTimer = null
1645
+ }
1646
+ if (board.watch_for_loss) board.watch_for_loss = false
1647
+ }
1648
+
1649
+ function watchForLoss() {
1650
+ board.watch_for_loss = true
1651
+ saveBoard()
1652
+ if (streamClients.size === 0) {
1653
+ armPageLost()
1654
+ return
1655
+ }
1656
+ if (watchForLossTimer) clearTimeout(watchForLossTimer)
1657
+ watchForLossTimer = setTimeout(() => {
1658
+ watchForLossTimer = null
1659
+ if (streamClients.size === 0 || board.ended) return
1660
+ clearWatchForLoss()
1661
+ bestEffort(() => saveBoard())
1662
+ }, PAGE_LOST_GRACE_MS)
1663
+ watchForLossTimer.unref()
1664
+ }
1665
+
1666
+ // --- session end -----------------------------------------------------------
1667
+
1668
+ // /session/end ends the board, not the tokens. The page token stays valid so
1669
+ // the same link can open another session once this one drains (see
1670
+ // openSession); both tokens live until `stop` (or a fresh start on the ended
1671
+ // root), per I4/KTD18: an agent that crashes between the final ack and
1672
+ // `stop` can still resume, `status`, and `stop`.
1673
+ function endSession() {
1674
+ if (board.ended) return
1675
+ // Terminal state is committed before anything observable happens; if the
1676
+ // writes fail the session stays live and /session/end can be retried.
1677
+ const before = { ended: board.ended, stream: board.page.stream, session }
1678
+ board.ended = true
1679
+ board.page.stream = streamClients.size > 0 ? "connected" : board.page.stream
1680
+ try {
1681
+ saveBoard()
1682
+ saveSession({ ended: true })
1683
+ } catch (error) {
1684
+ board.ended = before.ended
1685
+ board.page.stream = before.stream
1686
+ session = before.session
1687
+ bestEffort(() => saveBoard())
1688
+ bestEffort(() => writePrivateJson(options.sessionFile, session))
1689
+ throw error
1690
+ }
1691
+ disarmPageLost()
1692
+ // A transition that released a final batch before ending held its wake
1693
+ // and notices until this commit; they go out now, ahead of the end.
1694
+ releaseHeldNotices()
1695
+ broadcast("session_ended", { reason: "session_end", session_id: board.session_id, log_dir: options.logDir })
1696
+ for (const client of streamClients) {
1697
+ if (!client.writableEnded) client.end()
1698
+ }
1699
+ streamClients.clear()
1700
+ fulfillWaiter()
1701
+ }
1702
+
1703
+ // Nothing of the ended session is still owed to the agent: every batch is
1704
+ // acknowledged, every released unit is terminal, and no /session/end
1705
+ // upload is in flight. Until then a new session id is refused rather than
1706
+ // erasing a board the agent still has to reconcile.
1707
+ function drained() {
1708
+ return batches.length === 0 && !endingInFlight && !unitsPendingCloseOut(board)
1709
+ }
1710
+
1711
+ // A new session id on an ended, drained board: what a fresh `start` on an
1712
+ // ended root does, in-process and with the same tokens.
1713
+ function openSession(sessionId) {
1714
+ // The durable writes come first and the irreversible steps last: if the
1715
+ // new board or the live session record cannot be saved, the ended
1716
+ // session is restored in memory and on disk, and its log and batches
1717
+ // directory are untouched, so a refused opener leaves it intact.
1718
+ const before = { board: structuredClone(board), session }
1719
+ for (const key of Object.keys(board)) delete board[key]
1720
+ Object.assign(board, emptyBoard(), { session_id: sessionId })
1721
+ try {
1722
+ saveBoard()
1723
+ saveSession({ ended: false })
1724
+ } catch (error) {
1725
+ for (const key of Object.keys(board)) delete board[key]
1726
+ Object.assign(board, before.board)
1727
+ session = before.session
1728
+ bestEffort(() => saveBoard())
1729
+ bestEffort(() => writePrivateJson(options.sessionFile, session))
1730
+ throw error
1731
+ }
1732
+ fs.rmSync(options.batchesDir, { recursive: true, force: true })
1733
+ rotateLog(options)
1734
+ batches.length = 0
1735
+ batchOrder = 0
1736
+ outOfOrder.clear()
1737
+ pendingDeliveries.length = 0
1738
+ reservedBytes = 0
1739
+ logBytes = 0
1740
+ disarmPageLost()
1741
+ clearWatchForLoss()
1742
+ // Presence is per session: the wait that drained the old one is gone.
1743
+ setAgentState("away")
1744
+ bestEffort(() => logAgent({ kind: "session_opened", session_id: sessionId }))
1745
+ }
1746
+
1747
+ // --- auth and CORS -----------------------------------------------------------
1748
+
1749
+ function corsHeaders() {
1750
+ return {
1751
+ "Access-Control-Allow-Origin": options.appOrigin,
1752
+ Vary: "Origin",
1753
+ }
1754
+ }
1755
+
1756
+ // The page credential: the page token and an Origin that is either absent
1757
+ // or exactly --app-origin.
1758
+ function authorizePageToken(req, res) {
1759
+ const origin = req.headers.origin
1760
+ if (typeof origin === "string" && origin !== options.appOrigin) {
1761
+ sendJson(res, 403, { reason: "origin" }, corsHeaders())
1762
+ return null
1763
+ }
1764
+ const token = bearerToken(req)
1765
+ if (!token) {
1766
+ sendJson(res, 401, { error: "unauthorized" }, corsHeaders())
1767
+ return null
1768
+ }
1769
+ if (tokenMatches(token, agentToken)) {
1770
+ sendJson(res, 403, { reason: "wrong_credential" }, corsHeaders())
1771
+ return null
1772
+ }
1773
+ if (!tokenMatches(token, pageToken)) {
1774
+ sendJson(res, 401, { error: "unauthorized" }, corsHeaders())
1775
+ return null
1776
+ }
1777
+ return true
1778
+ }
1779
+
1780
+ // Page routes: the page credential plus the session header. On an ended
1781
+ // board the ended id stays 410; any other id opens a fresh board once the
1782
+ // ended session has drained, and is then handled as that session's first
1783
+ // request.
1784
+ function authorizePage(req, res) {
1785
+ if (!authorizePageToken(req, res)) return null
1786
+ const sessionId = req.headers["x-riffrec-session"]
1787
+ const named = typeof sessionId === "string" && sessionId.trim() !== ""
1788
+ if (board.ended) {
1789
+ if (!named || sessionId === board.session_id) {
1790
+ sendJson(res, 410, { status: "session-ended" }, corsHeaders())
1791
+ return null
1792
+ }
1793
+ if (!drained()) {
1794
+ sendJson(res, 409, { error: "previous_session_draining" }, corsHeaders())
1795
+ return null
1796
+ }
1797
+ openSession(sessionId)
1798
+ }
1799
+ if (!named) {
1800
+ sendJson(res, 400, { error: "missing X-Riffrec-Session" }, corsHeaders())
1801
+ return null
1802
+ }
1803
+ // A closed tab ends its session: when the bound page said it was unloading
1804
+ // (or went lost) and no stream is open, a new page takes over instead of
1805
+ // being refused. A reload keeps its session id, so it never lands here.
1806
+ if (board.session_id && board.session_id !== sessionId && pageClosed()) {
1807
+ bestEffort(() => logAgent({ kind: "session_closed_by_page", session_id: board.session_id }))
1808
+ endSession()
1809
+ if (!drained()) {
1810
+ sendJson(res, 409, { error: "previous_session_draining" }, corsHeaders())
1811
+ return null
1812
+ }
1813
+ openSession(sessionId)
1814
+ }
1815
+ if (board.session_id && board.session_id !== sessionId) {
1816
+ sendJson(res, 409, { active_session_id: board.session_id }, corsHeaders())
1817
+ return null
1818
+ }
1819
+ if (!board.session_id) {
1820
+ // The first request binds the board to its session id; a bind that
1821
+ // does not persist is not a bind, so a later legitimate id is not
1822
+ // refused as foreign against an in-memory value the disk never held.
1823
+ board.session_id = sessionId
1824
+ try {
1825
+ saveBoard()
1826
+ } catch (error) {
1827
+ board.session_id = null
1828
+ throw error
1829
+ }
1830
+ }
1831
+ return sessionId
1832
+ }
1833
+
1834
+ function pageClosed() {
1835
+ if (board.ended || streamClients.size > 0) return false
1836
+ return board.page.last_stream_state === "unloading" || board.page.stream === "lost"
1837
+ }
1838
+
1839
+ // Agent routes: the agent token, no Origin header at all, no CORS.
1840
+ function authorizeAgent(req, res) {
1841
+ if (req.headers.origin !== undefined) {
1842
+ sendJson(res, 403, { reason: "browser_origin" })
1843
+ return false
1844
+ }
1845
+ const token = bearerToken(req)
1846
+ if (!token) {
1847
+ sendJson(res, 401, { error: "unauthorized" })
1848
+ return false
1849
+ }
1850
+ if (tokenMatches(token, pageToken)) {
1851
+ sendJson(res, 403, { reason: "wrong_credential" })
1852
+ return false
1853
+ }
1854
+ if (!tokenMatches(token, agentToken)) {
1855
+ sendJson(res, 401, { error: "unauthorized" })
1856
+ return false
1857
+ }
1858
+ if (session.agent_token === null) {
1859
+ sendJson(res, 410, { status: "session-ended" })
1860
+ return false
1861
+ }
1862
+ return true
1863
+ }
1864
+
1865
+ // --- page routes -------------------------------------------------------------
1866
+
1867
+ // Rejection names follow riffrec's LiveEnvelopeRejection; only
1868
+ // `unsupported_schema_version` maps to 409, everything else to 400.
1869
+ function validEnvelope(value, sessionId) {
1870
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "not_object"
1871
+ if (value.schema_version !== SCHEMA_VERSION) return "unsupported_schema_version"
1872
+ if (typeof value.session_id !== "string" || !value.session_id) return "missing_session_id"
1873
+ if (value.session_id !== sessionId) return "session_mismatch"
1874
+ if (value.seq === undefined || value.seq === null) return "missing_seq"
1875
+ if (!Number.isInteger(value.seq) || value.seq < 1) return "invalid_seq"
1876
+ if (typeof value.t !== "number" || !Number.isFinite(value.t)) return "invalid_t"
1877
+ if (typeof value.type !== "string" || !PAGE_EVENT_TYPES.has(value.type)) return "unknown_type"
1878
+ if (!validPayload(value.type, value.payload)) return "invalid_payload"
1879
+ // Ids become object keys and batch file names.
1880
+ for (const field of ["id", "unit_id"]) {
1881
+ const id = value.payload[field]
1882
+ if (id !== undefined && !SAFE_ID.test(String(id))) return "invalid_payload"
1883
+ }
1884
+ return null
1885
+ }
1886
+
1887
+ function applyEnvelope(envelope) {
1888
+ const { type, payload } = envelope
1889
+ if (type === "transcript") {
1890
+ board.transcript_count += 1
1891
+ } else if (type === "unit") {
1892
+ if (typeof payload.id !== "string" || !payload.id) return
1893
+ const existing = unitById(payload.id)
1894
+ board.units[payload.id] = {
1895
+ ...(existing ?? {}),
1896
+ ...payload,
1897
+ status: existing?.released ? existing.status : (payload.status ?? "initial"),
1898
+ released: Boolean(existing?.released),
1899
+ }
1900
+ if (!existing) board.unit_order.push(payload.id)
1901
+ // Instant means continuous pickup: the unit is released the moment it
1902
+ // lands, with whatever is held beside it, and the agent wakes now. The
1903
+ // page sees `triaging` on the stream at once; no checkpoint is waited for.
1904
+ if (board.mode === "instant" && !board.units[payload.id].released) {
1905
+ releaseCheckpoint(`instant-${payload.id}`, "instant", "instant")
1906
+ }
1907
+ } else if (type === "unit_update") {
1908
+ const unit = unitById(payload.unit_id)
1909
+ if (!unit) return
1910
+ // KTD5: a unit is refined only while it is still initial and unreleased;
1911
+ // the riffer's confirmation pass (KTD22) may land at any point.
1912
+ if (payload.confirmed !== undefined) unit.confirmed = payload.confirmed
1913
+ if (unit.released || unit.status !== "initial") return
1914
+ if (typeof payload.statement === "string") unit.statement = payload.statement
1915
+ if (Array.isArray(payload.anchors_add)) unit.anchors = [...(unit.anchors ?? []), ...payload.anchors_add]
1916
+ } else if (type === "unit_withdraw") {
1917
+ const unit = unitById(payload.unit_id)
1918
+ if (!unit || unit.status === "withdrawn") return
1919
+ unit.status = "withdrawn"
1920
+ unit.withdraw_reason = payload.reason ?? null
1921
+ if (unit.released && !board.pending_withdrawn.includes(unit.id)) board.pending_withdrawn.push(unit.id)
1922
+ broadcast("unit_status", { unit_id: unit.id, status: "withdrawn" })
1923
+ } else if (type === "annotation") {
1924
+ if (typeof payload.id !== "string" || !payload.id) return
1925
+ const existing = annotationById(payload.id)
1926
+ board.annotations[payload.id] = { ...(existing ?? {}), ...payload, released: Boolean(existing?.released) }
1927
+ if (!existing) board.annotation_order.push(payload.id)
1928
+ } else if (type === "checkpoint") {
1929
+ const trigger = PAGE_CHECKPOINT_KINDS.has(payload.trigger) ? payload.trigger : "send"
1930
+ const mode = EXECUTION_MODES.has(payload.mode) ? payload.mode : board.mode
1931
+ board.mode = mode
1932
+ // A checkpoint id keys its batch file and its ack route, so a reused
1933
+ // page id gets a suffix rather than overwriting the earlier batch.
1934
+ const wanted = typeof payload.id === "string" && payload.id ? payload.id : `ck-${randomUUID()}`
1935
+ let checkpointId = wanted
1936
+ for (let n = 2; board.checkpoints.some((c) => c.id === checkpointId); n += 1) checkpointId = `${wanted}-${n}`
1937
+ releaseCheckpoint(checkpointId, trigger, mode)
1938
+ } else if (type === "answer") {
1939
+ const answer = { unit_id: payload.unit_id, text: payload.text, t: envelope.t }
1940
+ board.answers.push(answer)
1941
+ const checkpointId = `ck-answer-${randomUUID()}`
1942
+ board.checkpoints.push({ id: checkpointId, kind: "answer", mode: board.mode, t: Date.now() })
1943
+ enqueueBatch(makeEnvelope(checkpointId, "answer", board.mode, { answers: [{ unit_id: answer.unit_id, text: answer.text }] }))
1944
+ } else if (type === "frame") {
1945
+ board.frame_count += 1
1946
+ } else if (type === "mic") {
1947
+ board.page.mic = payload.state ?? null
1948
+ } else if (type === "mode") {
1949
+ if (!EXECUTION_MODES.has(payload.mode)) return
1950
+ const leavingCollect = board.mode === "collect" && payload.mode !== "collect"
1951
+ board.mode = payload.mode
1952
+ if (leavingCollect) emitModeChange(payload.mode)
1953
+ // Switching to Instant flushes what is held right away, since nothing
1954
+ // will wait for a checkpoint from here on. (Leaving Collect woke the
1955
+ // agent with the accepted backlog above; this carries the unreleased.)
1956
+ if (payload.mode === "instant" && (heldUnits().length > 0 || heldAnnotations().length > 0)) {
1957
+ releaseCheckpoint(`ck-instant-${randomUUID()}`, "mode_change", "instant")
1958
+ }
1959
+ } else if (type === "stream_state") {
1960
+ board.page.last_stream_state = payload.state ?? null
1961
+ }
1962
+ }
1963
+
1964
+ // Everything applyEnvelope may touch: the board and the batch queue.
1965
+ function snapshotState() {
1966
+ const savedBoard = structuredClone(board)
1967
+ const savedBatches = batches.slice()
1968
+ const savedOrder = batchOrder
1969
+ return () => {
1970
+ for (const key of Object.keys(board)) delete board[key]
1971
+ Object.assign(board, savedBoard)
1972
+ for (const batch of batches.slice(savedBatches.length)) bestEffort(() => fs.rmSync(batchFile(batch.envelope.checkpoint_id), { force: true }))
1973
+ batches.length = 0
1974
+ batches.push(...savedBatches)
1975
+ batchOrder = savedOrder
1976
+ // applyEnvelope may have saved the board mid-way (releaseCheckpoint);
1977
+ // the durable copy must match the restored state.
1978
+ bestEffort(() => saveBoard())
1979
+ }
1980
+ }
1981
+
1982
+ // Where the event log stood before an envelope's transaction, so a failed
1983
+ // commit can take back exactly what that envelope stored: the appended
1984
+ // line, the frame file keyed by its seq, and the bytes counted for both.
1985
+ function markLog() {
1986
+ let eventsSize = 0
1987
+ try {
1988
+ eventsSize = fs.statSync(eventsLog).size
1989
+ } catch {
1990
+ eventsSize = 0
1991
+ }
1992
+ return { eventsSize, logBytes }
1993
+ }
1994
+
1995
+ function unwindLog(mark, seq) {
1996
+ bestEffort(() => {
1997
+ if (fs.existsSync(eventsLog) && fs.statSync(eventsLog).size > mark.eventsSize) fs.truncateSync(eventsLog, mark.eventsSize)
1998
+ })
1999
+ bestEffort(() => {
2000
+ for (const name of fs.readdirSync(framesDir)) {
2001
+ if (name.startsWith(`${seq}-`)) fs.rmSync(path.join(framesDir, name), { force: true })
2002
+ }
2003
+ })
2004
+ logBytes = mark.logBytes
2005
+ }
2006
+
2007
+ function storeEnvelope(envelope) {
2008
+ if (envelope.type === "frame") {
2009
+ const id = typeof envelope.payload.id === "string" && envelope.payload.id ? envelope.payload.id : randomUUID()
2010
+ // Keyed by seq too: a reused frame id must not overwrite earlier evidence.
2011
+ const fileName = `${envelope.seq}-${encodeURIComponent(id)}.jpg`
2012
+ const frameFile = path.join("frames", fileName)
2013
+ const { jpeg_base64: jpeg, ...rest } = envelope.payload
2014
+ // A `dropped` frame kept its seq but not its bytes: keep the metadata,
2015
+ // write no file, and treat the image as absent.
2016
+ if (rest.dropped) {
2017
+ logEvent({ seq: envelope.seq, t: envelope.t, type: "frame", payload: rest, frame_file: null })
2018
+ return
2019
+ }
2020
+ if (typeof jpeg === "string") {
2021
+ const bytes = Buffer.from(jpeg, "base64")
2022
+ fs.writeFileSync(path.join(framesDir, fileName), bytes, { mode: 0o600 })
2023
+ logBytes += bytes.length
2024
+ }
2025
+ logEvent({ seq: envelope.seq, t: envelope.t, type: "frame", payload: rest, frame_file: frameFile })
2026
+ return
2027
+ }
2028
+ logEvent({ seq: envelope.seq, t: envelope.t, type: envelope.type, payload: envelope.payload })
2029
+ }
2030
+
2031
+ // Envelopes are applied strictly in `seq` order. One that arrives ahead
2032
+ // of a gap waits in memory until the gap closes; it is not acknowledged,
2033
+ // so a restart loses nothing the page will not replay. The buffer is
2034
+ // bounded: past the cap an early envelope is dropped unacknowledged and
2035
+ // the page replays it after the gap closes.
2036
+ function admitEnvelope(envelope, bodySize) {
2037
+ const { seq } = envelope
2038
+ if (seq <= board.acked_seq || outOfOrder.has(seq)) return
2039
+ if (seq !== board.acked_seq + 1) {
2040
+ if (outOfOrder.size < OUT_OF_ORDER_CAP) {
2041
+ // Every buffered envelope is stored once the gap closes, so its bytes
2042
+ // are reserved against the caps now: the frame's whole body (posted
2043
+ // alone), or the envelope's own encoding out of a batch.
2044
+ const reserved = envelope.type === "frame" ? bodySize : Buffer.byteLength(JSON.stringify(envelope))
2045
+ outOfOrder.set(seq, { envelope, reserved })
2046
+ reservedBytes += reserved
2047
+ }
2048
+ return
2049
+ }
2050
+ let next = { envelope, reserved: 0 }
2051
+ while (next) {
2052
+ // One transaction per envelope: apply, store, save the board with the
2053
+ // advanced seq. A failure rolls everything back and leaves the seq
2054
+ // unacknowledged, so the page retries it instead of discarding an event
2055
+ // the endpoint lost. Wakes and SSE notices are held until the commit.
2056
+ const undo = snapshotState()
2057
+ const logMark = markLog()
2058
+ heldNotices = []
2059
+ try {
2060
+ applyEnvelope(next.envelope)
2061
+ storeEnvelope(next.envelope)
2062
+ board.acked_seq = next.envelope.seq
2063
+ saveBoard()
2064
+ } catch (error) {
2065
+ heldNotices = null
2066
+ undo()
2067
+ // The log is part of the transaction: a line or frame stored for a
2068
+ // seq that was never acknowledged would be stored again on retry and
2069
+ // replayed twice.
2070
+ unwindLog(logMark, next.envelope.seq)
2071
+ // A buffered envelope that failed leaves the buffer: the page retries
2072
+ // it from the returned acked_seq, and that retry must be admitted
2073
+ // rather than skipped as a duplicate of the entry still held here.
2074
+ if (outOfOrder.get(next.envelope.seq) === next) {
2075
+ outOfOrder.delete(next.envelope.seq)
2076
+ reservedBytes -= next.reserved
2077
+ }
2078
+ throw error
2079
+ }
2080
+ outOfOrder.delete(next.envelope.seq)
2081
+ reservedBytes -= next.reserved
2082
+ releaseHeldNotices()
2083
+ next = outOfOrder.get(board.acked_seq + 1)
2084
+ }
2085
+ }
2086
+
2087
+ async function handleEvents(req, res, sessionId) {
2088
+ const body = await readBody(req, FRAME_BODY_LIMIT)
2089
+ // The session may have ended while this body was in flight.
2090
+ if (board.ended || board.session_id !== sessionId) {
2091
+ sendJson(res, 410, { status: "session-ended" }, corsHeaders())
2092
+ return
2093
+ }
2094
+ if (body.tooLarge) {
2095
+ sendJson(res, 413, { max_bytes: BODY_LIMIT, frame_max_bytes: FRAME_BODY_LIMIT }, corsHeaders())
2096
+ return
2097
+ }
2098
+ const parsed = parseJsonObject(body.text)
2099
+ const envelopes = Array.isArray(parsed) ? parsed : parsed ? [parsed] : null
2100
+ if (!envelopes || envelopes.length === 0) {
2101
+ sendJson(res, 400, { error: "body must be an envelope or an array of envelopes" }, corsHeaders())
2102
+ return
2103
+ }
2104
+ const loneFrame = envelopes.length === 1 && envelopes[0]?.type === "frame"
2105
+ if (!loneFrame && body.size > BODY_LIMIT) {
2106
+ sendJson(res, 413, { max_bytes: BODY_LIMIT }, corsHeaders())
2107
+ return
2108
+ }
2109
+ for (const envelope of envelopes) {
2110
+ const problem = validEnvelope(envelope, sessionId)
2111
+ if (problem === "unsupported_schema_version") {
2112
+ sendJson(res, 409, { expected_schema_version: SCHEMA_VERSION }, corsHeaders())
2113
+ return
2114
+ }
2115
+ if (problem) {
2116
+ sendJson(res, 400, { reason: problem, seq: Number.isInteger(envelope?.seq) ? envelope.seq : null }, corsHeaders())
2117
+ return
2118
+ }
2119
+ if (envelope.type === "frame" && envelopes.length > 1) {
2120
+ sendJson(res, 400, { reason: "invalid_payload", seq: envelope.seq, detail: "frame envelopes are posted alone" }, corsHeaders())
2121
+ return
2122
+ }
2123
+ }
2124
+ // The cap guards image bytes. A frame the page already dropped (`dropped`
2125
+ // set, no image kept) stores only its metadata line, and it is how the
2126
+ // page keeps the sequence moving once the cap is reached, so refusing it
2127
+ // would hold every later event behind that seq.
2128
+ const storesImage = loneFrame && !envelopes[0].payload.dropped
2129
+ if (storesImage && logBytes + reservedBytes + body.size > DISK_CAP_BYTES) {
2130
+ sendJson(res, 507, { reason: "disk_cap", stream_state: "buffering", max_bytes: DISK_CAP_BYTES, acked_seq: board.acked_seq }, corsHeaders())
2131
+ return
2132
+ }
2133
+ // The hard ceiling applies to every envelope, dropped frames included.
2134
+ if (logBytes + reservedBytes + body.size > DISK_HARD_CAP_BYTES) {
2135
+ sendJson(res, 507, { reason: "disk_cap", stream_state: "buffering", max_bytes: DISK_HARD_CAP_BYTES, acked_seq: board.acked_seq }, corsHeaders())
2136
+ return
2137
+ }
2138
+ touch()
2139
+ let storageError = null
2140
+ try {
2141
+ for (const envelope of envelopes) admitEnvelope(envelope, body.size)
2142
+ } catch (error) {
2143
+ storageError = error
2144
+ }
2145
+ broadcast("ack", { acked_seq: board.acked_seq })
2146
+ if (storageError) {
2147
+ // Whatever was acknowledged before the failure stays acknowledged;
2148
+ // the rest is retriable from acked_seq.
2149
+ sendJson(res, 500, { error: "storage_failed", code: storageError.code ?? null, acked_seq: board.acked_seq }, corsHeaders())
2150
+ return
2151
+ }
2152
+ sendJson(res, 200, { acked_seq: board.acked_seq }, corsHeaders())
2153
+ }
2154
+
2155
+ function handleStream(req, res) {
2156
+ // No `Connection` header: it is hop-by-hop, and a TLS-terminating tunnel
2157
+ // (cloudflared) forwards this response over HTTP/2, where that header is
2158
+ // invalid; the edge then held the whole stream and the page never saw a
2159
+ // status. `no-transform` and `X-Accel-Buffering` tell intermediaries not
2160
+ // to buffer, and the flush pushes the headers out with the first bytes.
2161
+ res.writeHead(200, {
2162
+ "Content-Type": "text/event-stream",
2163
+ "Cache-Control": "no-cache, no-transform",
2164
+ "X-Accel-Buffering": "no",
2165
+ ...corsHeaders(),
2166
+ })
2167
+ res.flushHeaders()
2168
+ res.write(":ok\n\n")
2169
+ res.write(`event: ack\ndata: ${JSON.stringify({ acked_seq: board.acked_seq })}\n\n`)
2170
+ res.write(`event: agent\ndata: ${JSON.stringify(agentState)}\n\n`)
2171
+ // A page that just reloaded, or reconnected after a flushed response,
2172
+ // reconciles its board from these: every unit the endpoint has moved,
2173
+ // with the note or guess that moved it, and any question still open.
2174
+ let replayed = false
2175
+ for (const id of board.unit_order) {
2176
+ const unit = unitById(id)
2177
+ if (!unit.released && unit.status !== "withdrawn") continue
2178
+ const status = { unit_id: id, status: unit.status }
2179
+ if (typeof unit.note === "string") status.note = unit.note
2180
+ if (typeof unit.guess === "string") status.guess = unit.guess
2181
+ res.write(`event: unit_status\ndata: ${JSON.stringify(status)}\n\n`)
2182
+ replayed = true
2183
+ if (unit.status === "needs_info" && typeof unit.question === "string") {
2184
+ res.write(`event: ask\ndata: ${JSON.stringify({ unit_id: id, question: unit.question })}\n\n`)
2185
+ }
2186
+ }
2187
+ // Notices that fell in the gap between this connection and the last.
2188
+ for (const frame of pendingDeliveries) res.write(frame)
2189
+ if (pendingDeliveries.length > 0) replayed = true
2190
+ pendingDeliveries.length = 0
2191
+ streamClients.add(res)
2192
+ // Replayed state is a delivery like any other: behind a buffering tunnel
2193
+ // the page sees it only once this response completes.
2194
+ if (replayed) scheduleStreamFlush()
2195
+ disarmPageLost()
2196
+ // The page is back, so the edit it was watched for did not take it down;
2197
+ // a later disconnect (closing the tab, navigating away) is not a loss.
2198
+ clearWatchForLoss()
2199
+ board.page.stream = "connected"
2200
+ saveBoard()
2201
+ touch()
2202
+ req.on("close", () => {
2203
+ // endSession already dropped every client; a late close must not touch
2204
+ // a board that has since reopened for a new session.
2205
+ if (!streamClients.delete(res)) return
2206
+ if (streamClients.size === 0 && !board.ended) {
2207
+ board.page.stream = "disconnected"
2208
+ // A socket close runs outside any request's error handling; a disk
2209
+ // that refuses this bookkeeping write must not take the session down.
2210
+ bestEffort(() => saveBoard())
2211
+ if (board.watch_for_loss) armPageLost()
2212
+ }
2213
+ })
2214
+ }
2215
+
2216
+ async function handleMint(req, res, sessionId) {
2217
+ const body = await readBody(req, BODY_LIMIT)
2218
+ if (body.tooLarge) {
2219
+ sendJson(res, 413, { max_bytes: BODY_LIMIT }, corsHeaders())
2220
+ return
2221
+ }
2222
+ const parsed = parseJsonObject(body.text || "{}")
2223
+ if (!parsed || (parsed.session_id !== undefined && parsed.session_id !== sessionId)) {
2224
+ sendJson(res, 400, { error: "session_id must match X-Riffrec-Session" }, corsHeaders())
2225
+ return
2226
+ }
2227
+ // A loopback peer is either the local browser or a TLS-terminating
2228
+ // tunnel on this host. Any other peer must be a proxy named with
2229
+ // --trust-proxy for its X-Forwarded-Proto to count; the header alone
2230
+ // proves nothing about the transport it arrived on.
2231
+ const peer = req.socket.remoteAddress
2232
+ const forwardedProto = String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim().toLowerCase()
2233
+ const trustedProxy = options.trustProxy.includes(String(peer ?? "").replace(/^::ffff:/, ""))
2234
+ if (!isLoopback(peer) && !(trustedProxy && forwardedProto === "https")) {
2235
+ sendJson(res, 403, { reason: "tls_required" }, corsHeaders())
2236
+ return
2237
+ }
2238
+ // A key the riffer pasted into the page wins over the environment's, so a
2239
+ // missing or rejected OPENAI_API_KEY can be fixed from the browser.
2240
+ const pastedKey = String(req.headers["x-riffrec-openai-key"] ?? "").trim()
2241
+ const apiKey = pastedKey || process.env.OPENAI_API_KEY
2242
+ if (!apiKey) {
2243
+ sendJson(res, 503, { reason: "no_key" }, corsHeaders())
2244
+ return
2245
+ }
2246
+ const brief = readBrief(options)
2247
+ if (brief && briefContainsSecret(brief)) {
2248
+ sendJson(res, 503, { reason: "brief_contains_secret" }, corsHeaders())
2249
+ return
2250
+ }
2251
+ const now = Date.now()
2252
+ while (mintTimes.length > 0 && now - mintTimes[0] > 60 * 1000) mintTimes.shift()
2253
+ if (mintInFlight) {
2254
+ sendJson(res, 429, { retry_after: 1 }, corsHeaders())
2255
+ return
2256
+ }
2257
+ if (mintTimes.length >= MINTS_PER_MINUTE) {
2258
+ sendJson(res, 429, { retry_after: Math.ceil((60 * 1000 - (now - mintTimes[0])) / 1000) }, corsHeaders())
2259
+ return
2260
+ }
2261
+ mintTimes.push(now)
2262
+ mintInFlight = true
2263
+ touch()
2264
+ try {
2265
+ const instructions = brief ? `${INTERVIEWER_PERSONA}\n\n[SESSION BRIEF]\n${brief}` : INTERVIEWER_PERSONA
2266
+ const upstreamBody = {
2267
+ expires_after: { anchor: "created_at", seconds: CLIENT_SECRET_TTL_S },
2268
+ session: {
2269
+ type: "realtime",
2270
+ model: REALTIME_MODEL,
2271
+ instructions,
2272
+ tools: INTERVIEWER_TOOLS,
2273
+ tool_choice: "auto",
2274
+ audio: {
2275
+ input: {
2276
+ transcription: { model: "gpt-4o-mini-transcribe" },
2277
+ turn_detection: { type: "semantic_vad", create_response: true, interrupt_response: true },
2278
+ },
2279
+ output: { voice: REALTIME_VOICE },
2280
+ },
2281
+ },
2282
+ }
2283
+ let upstream
2284
+ try {
2285
+ upstream = await fetch(`${OPENAI_BASE_URL}/v1/realtime/client_secrets`, {
2286
+ method: "POST",
2287
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
2288
+ body: JSON.stringify(upstreamBody),
2289
+ signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
2290
+ })
2291
+ } catch {
2292
+ sendJson(res, 502, { reason: "openai_error", upstream_status: null }, corsHeaders())
2293
+ return
2294
+ }
2295
+ if (!upstream.ok) {
2296
+ // The upstream body is discarded: it may echo the request.
2297
+ await upstream.arrayBuffer().catch(() => undefined)
2298
+ sendJson(res, 502, { reason: "openai_error", upstream_status: upstream.status }, corsHeaders())
2299
+ return
2300
+ }
2301
+ const minted = await upstream.json().catch(() => null)
2302
+ const secret = typeof minted?.value === "string" ? minted.value : minted?.client_secret?.value
2303
+ const expiresAt = minted?.expires_at ?? minted?.client_secret?.expires_at ?? null
2304
+ if (!secret) {
2305
+ sendJson(res, 502, { reason: "openai_error", upstream_status: upstream.status }, corsHeaders())
2306
+ return
2307
+ }
2308
+ // The secret is minted; the audit line must not cost the page its mint.
2309
+ bestEffort(() => logAgent({ kind: "mint", session_id: sessionId, expires_at: expiresAt }))
2310
+ // The session may have ended while the upstream call was in flight.
2311
+ if (board.ended || board.session_id !== sessionId) {
2312
+ sendJson(res, 410, { status: "session-ended" }, corsHeaders())
2313
+ return
2314
+ }
2315
+ sendJson(res, 200, { client_secret: secret, expires_at: expiresAt, model: REALTIME_MODEL }, corsHeaders())
2316
+ } finally {
2317
+ mintInFlight = false
2318
+ }
2319
+ }
2320
+
2321
+ function archiveExtension(contentType) {
2322
+ const type = String(contentType ?? "").split(";")[0].trim().toLowerCase()
2323
+ if (type === "application/zip") return "zip"
2324
+ if (type === "application/json") return "json"
2325
+ return "bin"
2326
+ }
2327
+
2328
+ // The page's full-evidence archive is streamed to state/log/ then the
2329
+ // session ends: a final checkpoint releases anything still held and the
2330
+ // stream announces session_ended. The page token stays valid.
2331
+ function handleSessionEnd(req, res) {
2332
+ if (endingInFlight) {
2333
+ sendJson(res, 409, { error: "session_end_in_progress" }, corsHeaders())
2334
+ return
2335
+ }
2336
+ endingInFlight = true
2337
+ // The cap counts what is on disk plus what is still landing: frames held
2338
+ // ahead of a gap, frames that land while this upload runs, and this
2339
+ // archive as it streams in. The archive's bytes are reserved chunk by
2340
+ // chunk and its budget is read again at every chunk, so the archive and
2341
+ // concurrent /events frames both see one shrinking budget rather than the
2342
+ // free space each of them is filling.
2343
+ const archivePath = path.join(options.logDir, `archive.${archiveExtension(req.headers["content-type"])}`)
2344
+ const tmpPath = `${archivePath}.${randomUUID()}${ARCHIVE_PART_SUFFIX}`
2345
+ const out = fs.createWriteStream(tmpPath, { mode: 0o600 })
2346
+ let size = 0
2347
+ let tooLarge = false
2348
+ let archiveReserved = 0
2349
+ const releaseArchiveReservation = () => {
2350
+ reservedBytes -= archiveReserved
2351
+ archiveReserved = 0
2352
+ }
2353
+ // What this archive may still grow to: the body cap, or what the disk
2354
+ // cap leaves after everything stored and everything else reserved.
2355
+ const archiveBudget = () => Math.max(0, Math.min(ARCHIVE_BODY_LIMIT, DISK_CAP_BYTES - logBytes - (reservedBytes - archiveReserved)))
2356
+ let remaining = archiveBudget()
2357
+ req.on("data", (chunk) => {
2358
+ size += chunk.length
2359
+ if (tooLarge) return
2360
+ remaining = archiveBudget()
2361
+ if (size > remaining) {
2362
+ tooLarge = true
2363
+ releaseArchiveReservation()
2364
+ out.destroy()
2365
+ return
2366
+ }
2367
+ reservedBytes += chunk.length
2368
+ archiveReserved += chunk.length
2369
+ touch()
2370
+ // Pause the upload while the disk catches up; a fast sender must not
2371
+ // park the archive in process memory.
2372
+ if (!out.write(chunk)) {
2373
+ req.pause()
2374
+ out.once("drain", () => req.resume())
2375
+ }
2376
+ })
2377
+ let failed = false
2378
+ out.on("error", (error) => {
2379
+ // ENOSPC or a permission error must not take the endpoint down; the
2380
+ // session stays live and resumable and the page may retry.
2381
+ if (failed || tooLarge) return
2382
+ failed = true
2383
+ endingInFlight = false
2384
+ releaseArchiveReservation()
2385
+ req.pause()
2386
+ // The response must not depend on another write to the disk that just failed.
2387
+ sendJson(res, 500, { error: "archive_write_failed", code: error.code ?? null }, corsHeaders())
2388
+ bestEffort(() => fs.rmSync(tmpPath, { force: true }))
2389
+ bestEffort(() => logAgent({ kind: "archive_failed", error: error.code ?? error.message }))
2390
+ })
2391
+ req.on("error", () => {
2392
+ endingInFlight = false
2393
+ releaseArchiveReservation()
2394
+ out.destroy()
2395
+ bestEffort(() => fs.rmSync(tmpPath, { force: true }))
2396
+ })
2397
+ req.on("end", () => {
2398
+ if (failed) return
2399
+ if (tooLarge) {
2400
+ endingInFlight = false
2401
+ bestEffort(() => fs.rmSync(tmpPath, { force: true }))
2402
+ sendJson(res, 413, { max_bytes: remaining }, corsHeaders())
2403
+ return
2404
+ }
2405
+ out.end(() => {
2406
+ if (failed) return
2407
+ try {
2408
+ finalize()
2409
+ } catch (error) {
2410
+ // Any filesystem failure here takes the same recoverable path.
2411
+ out.emit("error", error)
2412
+ }
2413
+ })
2414
+
2415
+ function finalize() {
2416
+ if (size > 0) {
2417
+ fs.renameSync(tmpPath, archivePath)
2418
+ logBytes += size
2419
+ } else {
2420
+ bestEffort(() => fs.rmSync(tmpPath, { force: true }))
2421
+ }
2422
+ // Landed bytes are counted in logBytes now, not as a reservation.
2423
+ releaseArchiveReservation()
2424
+ touch()
2425
+ // The overlay's Done control sends the `final` checkpoint before
2426
+ // /session/end; a page that ended without one still hands the agent
2427
+ // whatever is held or accepted. The fallback release and the end are
2428
+ // one transition: if ending fails, the board, the queued batch, and
2429
+ // the archive all go back to how they were, so the page's retry
2430
+ // starts from the same state and emits one final checkpoint, not two.
2431
+ const undo = snapshotState()
2432
+ heldNotices = []
2433
+ try {
2434
+ // Nothing the page sent stays unreleased past the end: a unit that
2435
+ // landed after the page's own final checkpoint goes out as a last
2436
+ // ordinary batch, so it is the agent's to finish (and holds the
2437
+ // board from draining) rather than an `initial` the next session
2438
+ // would erase.
2439
+ const held = heldUnits().length > 0 || heldAnnotations().length > 0 || board.pending_withdrawn.length > 0
2440
+ if (!board.final_emitted && (held || backlogUnits().length > 0)) {
2441
+ releaseCheckpoint(`ck-final-${randomUUID()}`, "final", board.mode)
2442
+ } else if (held) {
2443
+ releaseCheckpoint(`ck-late-${randomUUID()}`, "send", board.mode)
2444
+ }
2445
+ endSession()
2446
+ } catch (error) {
2447
+ heldNotices = null
2448
+ undo()
2449
+ // The session did not end, so the page retries with the whole
2450
+ // archive: the copy just landed is a replacement-in-waiting, not
2451
+ // stored evidence, and must not be charged against that retry.
2452
+ if (size > 0) {
2453
+ bestEffort(() => fs.rmSync(archivePath, { force: true }))
2454
+ logBytes -= size
2455
+ }
2456
+ throw error
2457
+ }
2458
+ // endSession delivered the held wake and notices as it committed; if
2459
+ // the board was already ended it did nothing, and they go out here.
2460
+ releaseHeldNotices()
2461
+ // The end is committed; the audit line must not turn it into a 500.
2462
+ bestEffort(() => logAgent({ kind: "session_end", archive: size > 0 ? path.basename(archivePath) : null, bytes: size }))
2463
+ endingInFlight = false
2464
+ sendJson(res, 200, { status: "session-ended", log_dir: options.logDir, archive_bytes: size }, corsHeaders())
2465
+ }
2466
+ })
2467
+ }
2468
+
2469
+ // --- agent routes ------------------------------------------------------------
2470
+
2471
+ function handleWait(req, res) {
2472
+ touch()
2473
+ if (waiter && !waiter.res.writableEnded) {
2474
+ sendJson(res, 409, { status: "wait-taken" })
2475
+ return
2476
+ }
2477
+ const parked = { res, timer: null }
2478
+ parked.timer = setTimeout(() => {
2479
+ if (waiter === parked) waiter = null
2480
+ if (!res.writableEnded) {
2481
+ res.writeHead(204)
2482
+ res.end()
2483
+ }
2484
+ agentMaybeAway()
2485
+ }, WAIT_TIMEOUT_MS)
2486
+ waiter = parked
2487
+ req.on("close", () => {
2488
+ clearTimeout(parked.timer)
2489
+ if (waiter === parked) {
2490
+ waiter = null
2491
+ agentMaybeAway()
2492
+ }
2493
+ })
2494
+ setAgentState("listening")
2495
+ fulfillWaiter()
2496
+ }
2497
+
2498
+ function handleAck(req, res, checkpointId) {
2499
+ const index = batches.findIndex((batch) => batch.envelope.checkpoint_id === checkpointId)
2500
+ if (index === -1) {
2501
+ // Idempotent: a repeated ack of a batch this session already dropped is
2502
+ // still a success, so a crashed agent can safely re-ack on resume.
2503
+ if (board.acked_checkpoint_ids.includes(checkpointId)) {
2504
+ sendJson(res, 200, { ok: true, checkpoint_id: checkpointId, already_acked: true })
2505
+ return
2506
+ }
2507
+ sendJson(res, 404, { error: "unknown checkpoint" })
2508
+ return
2509
+ }
2510
+ // The acknowledgment is durable before the batch file goes: a board save
2511
+ // that fails leaves the batch in place and answers 500, so the agent
2512
+ // retries the ack; a batch file that outlives its recorded ack is dropped
2513
+ // at the next start instead of being served again.
2514
+ board.acked_checkpoint_ids.push(checkpointId)
2515
+ try {
2516
+ saveBoard()
2517
+ } catch (error) {
2518
+ board.acked_checkpoint_ids.pop()
2519
+ sendJson(res, 500, { error: "storage_failed", code: error.code ?? null, checkpoint_id: checkpointId })
2520
+ return
2521
+ }
2522
+ batches.splice(index, 1)
2523
+ bestEffort(() => fs.rmSync(batchFile(checkpointId), { force: true }))
2524
+ bestEffort(() => logAgent({ kind: "ack", checkpoint_id: checkpointId }))
2525
+ touch()
2526
+ sendJson(res, 200, { ok: true, checkpoint_id: checkpointId })
2527
+ }
2528
+
2529
+ // An agent's unit transition is visible (to /status, the stream, and a
2530
+ // later board save) only once the board commit holds it. A save that
2531
+ // fails restores the unit's prior fields and answers 500, so the agent
2532
+ // retries against a board that still reads as it did before the post.
2533
+ function commitUnit(res, unit, transition) {
2534
+ const before = { ...unit }
2535
+ transition()
2536
+ try {
2537
+ saveBoard()
2538
+ } catch (error) {
2539
+ for (const key of Object.keys(unit)) delete unit[key]
2540
+ Object.assign(unit, before)
2541
+ sendJson(res, 500, { error: "storage_failed", code: error.code ?? null, unit_id: unit.id })
2542
+ return false
2543
+ }
2544
+ return true
2545
+ }
2546
+
2547
+ async function handleUnitStatus(req, res, unitId) {
2548
+ const body = await readBody(req, BODY_LIMIT)
2549
+ const parsed = body.tooLarge ? null : parseJsonObject(body.text)
2550
+ if (!parsed || typeof parsed.status !== "string" || !AGENT_UNIT_STATUSES.has(parsed.status)) {
2551
+ sendJson(res, 400, { error: `status must be one of ${[...AGENT_UNIT_STATUSES].join(", ")}` })
2552
+ return
2553
+ }
2554
+ const unit = unitById(unitId)
2555
+ if (!unit) {
2556
+ sendJson(res, 404, { error: "unknown unit" })
2557
+ return
2558
+ }
2559
+ // A page withdrawal is terminal; a late agent status must not revive it.
2560
+ if (unit.status === "withdrawn") {
2561
+ sendJson(res, 409, { error: "unit withdrawn", unit_id: unitId, status: "withdrawn" })
2562
+ return
2563
+ }
2564
+ const transition = () => {
2565
+ unit.status = parsed.status
2566
+ if (typeof parsed.note === "string") unit.note = parsed.note
2567
+ if (typeof parsed.guess === "string") unit.guess = parsed.guess
2568
+ }
2569
+ if (!commitUnit(res, unit, transition)) return
2570
+ bestEffort(() => logAgent({ kind: "unit_status", unit_id: unitId, status: parsed.status, note: parsed.note ?? null, guess: parsed.guess ?? null }))
2571
+ const notice = { unit_id: unitId, status: parsed.status, ...(parsed.note !== undefined ? { note: parsed.note } : {}), ...(parsed.guess !== undefined ? { guess: parsed.guess } : {}) }
2572
+ broadcast("unit_status", notice)
2573
+ if (parsed.status === "applied") {
2574
+ broadcast("applied", { checkpoint_id: unit.checkpoint_id ?? null, unit_ids: [unitId] })
2575
+ watchForLoss()
2576
+ }
2577
+ touch()
2578
+ sendJson(res, 200, { ok: true, unit_id: unitId, status: parsed.status })
2579
+ }
2580
+
2581
+ async function handleUnitAsk(req, res, unitId) {
2582
+ const body = await readBody(req, BODY_LIMIT)
2583
+ const parsed = body.tooLarge ? null : parseJsonObject(body.text)
2584
+ if (!parsed || typeof parsed.question !== "string" || !parsed.question.trim()) {
2585
+ sendJson(res, 400, { error: "question is required" })
2586
+ return
2587
+ }
2588
+ const unit = unitById(unitId)
2589
+ if (!unit) {
2590
+ sendJson(res, 404, { error: "unknown unit" })
2591
+ return
2592
+ }
2593
+ if (unit.status === "withdrawn") {
2594
+ sendJson(res, 409, { error: "unit withdrawn", unit_id: unitId, status: "withdrawn" })
2595
+ return
2596
+ }
2597
+ const transition = () => {
2598
+ unit.status = "needs_info"
2599
+ unit.question = parsed.question
2600
+ }
2601
+ if (!commitUnit(res, unit, transition)) return
2602
+ bestEffort(() => logAgent({ kind: "ask", unit_id: unitId, question: parsed.question }))
2603
+ broadcast("unit_status", { unit_id: unitId, status: "needs_info" })
2604
+ broadcast("ask", { unit_id: unitId, question: parsed.question })
2605
+ touch()
2606
+ sendJson(res, 200, { ok: true, unit_id: unitId, status: "needs_info" })
2607
+ }
2608
+
2609
+ function handleStatus(req, res) {
2610
+ sendJson(res, 200, boardSummary(board, batches, logBytes))
2611
+ }
2612
+
2613
+ // GET /session: the page asks, with its token alone, whether the board is
2614
+ // live or ended and whether a new session id would open a fresh board. It
2615
+ // binds nothing and is not activity, so an abandoned endpoint still idles out.
2616
+ function handleSessionProbe(req, res) {
2617
+ if (!authorizePageToken(req, res)) return
2618
+ sendJson(res, 200, {
2619
+ status: board.ended ? "ended" : "live",
2620
+ session_id: board.session_id,
2621
+ accepts_new_session: board.ended && drained(),
2622
+ }, corsHeaders())
2623
+ }
2624
+
2625
+ // --- dispatch ----------------------------------------------------------------
2626
+
2627
+ const PAGE_ROUTES = new Set(["/events", "/stream", "/mint", "/session/end"])
2628
+
2629
+ function preflight(res) {
2630
+ res.writeHead(204, {
2631
+ ...corsHeaders(),
2632
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Riffrec-Session, X-Riffrec-OpenAI-Key",
2633
+ "Access-Control-Allow-Methods": "GET, POST",
2634
+ "Access-Control-Max-Age": "600",
2635
+ })
2636
+ res.end()
2637
+ }
2638
+
2639
+ async function handleRequest(req, res) {
2640
+ let urlPath
2641
+ try {
2642
+ urlPath = decodeURIComponent(req.url.split("?")[0].split("#")[0])
2643
+ } catch {
2644
+ sendJson(res, 400, { error: "bad request" })
2645
+ return
2646
+ }
2647
+
2648
+ if (urlPath === "/session") {
2649
+ if (req.method === "OPTIONS") {
2650
+ preflight(res)
2651
+ return
2652
+ }
2653
+ if (req.method !== "GET") {
2654
+ sendJson(res, 405, { error: "method not allowed" }, { ...corsHeaders(), Allow: "GET, OPTIONS" })
2655
+ return
2656
+ }
2657
+ return handleSessionProbe(req, res)
2658
+ }
2659
+
2660
+ if (PAGE_ROUTES.has(urlPath)) {
2661
+ if (req.method === "OPTIONS") {
2662
+ preflight(res)
2663
+ return
2664
+ }
2665
+ const expected = urlPath === "/stream" ? "GET" : "POST"
2666
+ if (req.method !== expected) {
2667
+ sendJson(res, 405, { error: "method not allowed" }, { ...corsHeaders(), Allow: `${expected}, OPTIONS` })
2668
+ return
2669
+ }
2670
+ const sessionId = authorizePage(req, res)
2671
+ if (!sessionId) return
2672
+ if (urlPath === "/events") return handleEvents(req, res, sessionId)
2673
+ if (urlPath === "/stream") return handleStream(req, res)
2674
+ if (urlPath === "/mint") return handleMint(req, res, sessionId)
2675
+ return handleSessionEnd(req, res)
2676
+ }
2677
+
2678
+ const ack = urlPath.match(/^\/checkpoints\/([^/]+)\/ack$/)
2679
+ const unitStatus = urlPath.match(/^\/units\/([^/]+)\/status$/)
2680
+ const unitAsk = urlPath.match(/^\/units\/([^/]+)\/ask$/)
2681
+ const agentRoute = urlPath === "/wait" || urlPath === "/status" || ack || unitStatus || unitAsk
2682
+ if (agentRoute) {
2683
+ if (!authorizeAgent(req, res)) return
2684
+ if (req.method === "GET" && urlPath === "/wait") return handleWait(req, res)
2685
+ if (req.method === "GET" && urlPath === "/status") return handleStatus(req, res)
2686
+ if (req.method === "POST" && ack) return handleAck(req, res, ack[1])
2687
+ if (req.method === "POST" && unitStatus) return handleUnitStatus(req, res, unitStatus[1])
2688
+ if (req.method === "POST" && unitAsk) return handleUnitAsk(req, res, unitAsk[1])
2689
+ sendJson(res, 405, { error: "method not allowed" })
2690
+ return
2691
+ }
2692
+
2693
+ // Nothing is served from the run directory (R40).
2694
+ sendJson(res, 404, { error: "not found" })
2695
+ }
2696
+
2697
+ // Every refusal (4xx/5xx) leaves one line in agent.ndjson: method, path,
2698
+ // status, and the reason this server gave. Never the credential, never the
2699
+ // request headers or body, so a session can be debugged from its log.
2700
+ // Refusals need no credential to produce, so on a LAN or tunnel-exposed
2701
+ // endpoint they are the one write an outsider can drive: the log takes a
2702
+ // bounded number per minute, notes once that the rest of the minute is
2703
+ // suppressed, and writes nothing once the session's disk cap is reached.
2704
+ let rejectionWindowStart = 0
2705
+ let rejectionWindowCount = 0
2706
+ function logRejection(req, res) {
2707
+ const now = Date.now()
2708
+ if (now - rejectionWindowStart >= REJECTION_LOG_WINDOW_MS) {
2709
+ rejectionWindowStart = now
2710
+ rejectionWindowCount = 0
2711
+ }
2712
+ if (logBytes >= DISK_CAP_BYTES) return
2713
+ rejectionWindowCount += 1
2714
+ if (rejectionWindowCount > REJECTION_LOG_PER_WINDOW) return
2715
+ if (rejectionWindowCount === REJECTION_LOG_PER_WINDOW) {
2716
+ bestEffort(() => logAgent({ kind: "rejected_suppressed", until: rejectionWindowStart + REJECTION_LOG_WINDOW_MS, per_window: REJECTION_LOG_PER_WINDOW }))
2717
+ return
2718
+ }
2719
+ const value = res.rejection
2720
+ const record = { kind: "rejected", method: req.method ?? null, route: loggedRoute(req), status: res.statusCode, reason: rejectionReason(value) }
2721
+ if (isRecord(value) && Number.isInteger(value.seq)) record.seq = value.seq
2722
+ bestEffort(() => logAgent(record))
2723
+ }
2724
+
2725
+ const server = http.createServer((req, res) => {
2726
+ res.on("finish", () => {
2727
+ if (res.statusCode >= 400) logRejection(req, res)
2728
+ })
2729
+ Promise.resolve(handleRequest(req, res)).catch(() => {
2730
+ if (!res.headersSent) sendJson(res, 500, { error: "internal error" })
2731
+ else if (!res.writableEnded) res.end()
2732
+ })
2733
+ })
2734
+ // The session archive on /session/end can take longer than Node's default
2735
+ // 5-minute request budget on a slow link.
2736
+ server.requestTimeout = 0
2737
+
2738
+ const listen = (onPort) => new Promise((resolve, reject) => {
2739
+ const onError = (error) => reject(error)
2740
+ server.once("error", onError)
2741
+ server.listen(onPort, options.host, () => {
2742
+ server.off("error", onError)
2743
+ resolve()
2744
+ })
2745
+ })
2746
+ try {
2747
+ await listen(port)
2748
+ } catch (error) {
2749
+ // A resumed session prefers its old port; when something else took it,
2750
+ // any free port still resumes the session (the page learns the new
2751
+ // origin from the URL the skill hands over).
2752
+ if (options.port !== undefined || error?.code !== "EADDRINUSE") throw error
2753
+ await listen(0)
2754
+ }
2755
+
2756
+ const address = server.address()
2757
+ const boundPort = typeof address === "object" && address ? address.port : port
2758
+ const urlHost = options.host === DEFAULT_HOST || options.host === "0.0.0.0" || options.host === "::" ? DEFAULT_URL_HOST : localAddressFor(options.host)
2759
+ const url = `http://${urlHost}:${boundPort}`
2760
+ // A resume rewrites only what this process changed: pid, owner_pid, url
2761
+ // (and host/port when the old port was taken). Tokens and everything else
2762
+ // are the previous session's.
2763
+ session = resuming
2764
+ ? { ...previous, url, app_origin: options.appOrigin, host: options.host, port: boundPort, trust_proxy: options.trustProxy, pid: process.pid, owner_pid: options.ownerPid ?? null, ended: Boolean(previous.ended) }
2765
+ : {
2766
+ page_token: pageToken,
2767
+ agent_token: agentToken,
2768
+ url,
2769
+ app_origin: options.appOrigin,
2770
+ host: options.host,
2771
+ port: boundPort,
2772
+ trust_proxy: options.trustProxy,
2773
+ pid: process.pid,
2774
+ owner_pid: options.ownerPid ?? null,
2775
+ ended: false,
2776
+ root: options.root,
2777
+ log_dir: options.logDir,
2778
+ started_at: new Date().toISOString(),
2779
+ }
2780
+ if (board.page.stream === "connected") board.page.stream = "disconnected"
2781
+ // A shutdown inside the grace window dropped the timer; the promise of a
2782
+ // page_lost wake survives in the board, so pick it up again.
2783
+ if (board.watch_for_loss && !board.ended) armPageLost()
2784
+ writePrivate(options.pidFile, `${process.pid}\n`)
2785
+ saveBoard()
2786
+ writePrivateJson(options.sessionFile, session)
2787
+ // The agent token never leaves the state file. This line is the start
2788
+ // envelope for `--foreground`; detached, it lands in server.log (0600).
2789
+ jsonOut({ ...publicStartEnvelope(session), status: resuming ? "resumed" : "started" })
2790
+
2791
+ // Owner death, idle timeout, SIGTERM: stop the process, keep the session.
2792
+ // The board, batches, and session file are already on disk, so a later
2793
+ // `start --root` resumes with the same tokens.
2794
+ function shutdown() {
2795
+ disarmPageLost()
2796
+ if (waiter && !waiter.res.writableEnded) {
2797
+ clearTimeout(waiter.timer)
2798
+ waiter.res.writeHead(204)
2799
+ waiter.res.end()
2800
+ waiter = null
2801
+ }
2802
+ for (const client of streamClients) {
2803
+ if (!client.writableEnded) client.end()
2804
+ }
2805
+ streamClients.clear()
2806
+ server.close(() => process.exit(0))
2807
+ server.closeAllConnections()
2808
+ setTimeout(() => process.exit(0), 2000).unref()
2809
+ }
2810
+ process.on("SIGTERM", shutdown)
2811
+ process.on("SIGINT", shutdown)
2812
+
2813
+ // An archive still streaming in is work in progress even with no stream
2814
+ // client attached: the idle timeout waits for it, and its chunks count
2815
+ // as activity so a slow remote link is not cut off mid-upload.
2816
+ const idleTimer = setInterval(() => {
2817
+ if (options.ownerPid && !processAlive(options.ownerPid)) shutdown()
2818
+ else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS && streamClients.size === 0 && !endingInFlight) shutdown()
2819
+ }, LIFECYCLE_CHECK_MS)
2820
+ idleTimer.unref()
2821
+ }
2822
+
2823
+ async function main() {
2824
+ let command
2825
+ try {
2826
+ const options = parseArgs(process.argv)
2827
+ command = options.command
2828
+ if (command === "start") await start(options)
2829
+ else if (command === "serve") await serve(options)
2830
+ else if (command === "stop") await stop(options)
2831
+ else if (command === "status") status(options)
2832
+ else if (command === "wait") await wait(options)
2833
+ else if (command === "replay") await replay(options)
2834
+ } catch (error) {
2835
+ console.error(error.message)
2836
+ // Wait reserves exit 1 for session-ended and 3 for wait-taken; any other failure is exit 2.
2837
+ process.exit((command ?? process.argv[2]) === "wait" ? 2 : 1)
2838
+ }
2839
+ }
2840
+
2841
+ await main()