elanous 0.2.7 → 0.2.9

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 (296) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/bFI5EEwg7T6aG88ibX4Zq/_buildManifest.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/7032-f4ef698518fba61c.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/8067-d976b441a104ab74.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/8519-5632377cce994f86.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/8630-cdfc61e82c653ffc.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/8894-e263b332208e3957.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/9454-139040d37c79dbc9.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/9740-8f5ff96c522b5f55.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/approvals/page-fb7c2d458f32fcad.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-35e2713597dd24d1.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/app/board/page-918b656f064577ba.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-624f7a2f89b60c8f.js → page-f972fc1c00a62e88.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/bots/{page-5a5fb385a4df0ffe.js → page-5f30e00693527ebd.js} +1 -1
  16. package/apps/pwa/out/_next/static/chunks/app/chat/{page-80497e93a878ddc5.js → page-7fbae173b3c103a7.js} +1 -1
  17. package/apps/pwa/out/_next/static/chunks/app/consult/{page-7457f52683562161.js → page-3b4edcce364062ea.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/control/page-c5794f484c251f44.js +1 -0
  19. package/apps/pwa/out/_next/static/chunks/app/design-check/page-1130d8eefcd87e76.js +1 -0
  20. package/apps/pwa/out/_next/static/chunks/app/editor/page-973996d584f008c1.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/exec/{page-b7dcf5d48a854b76.js → page-016e2bda04eecb1f.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/field/{page-51480757e7d68b92.js → page-b4d6d9a3b56f2940.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/intake/page-ec49a466121233ba.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/app/layout-f72db356744ceade.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/live/page-a6fd9b50d1b85378.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/market/page-8b0402a46a749a3d.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-07267f1391b96aba.js → page-037f83e2a1edc8e2.js} +1 -1
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-55d50e9b0006a91d.js → page-adfbd2ba9598e59a.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/not-found-46fedff57d4bec67.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/page-0cf318e9dcee2bbf.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-899b327cb8b6845c.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-6c1e6ba743a29dfe.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/{page-a7e37af7d0399cb8.js → page-1a8e4807292f6b17.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/reflection/page-28deba6bcb834916.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-de68e2205794037d.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-15e5a15db84c56aa.js → page-c71c33a573230672.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-a2e572e4511f2a16.js → page-0df30e0c7e50148a.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/settings/page-4fc56790fe0d2682.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-59df2aebb38a7c1e.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/setup/page-4647be5dbcbd0795.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/app/share/page-e025892bda74b743.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/showroom/page-a0955de73b89f078.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-c1c95739c0bff3b8.js → page-369846d2921af584.js} +1 -1
  44. package/apps/pwa/out/_next/static/chunks/app/term/page-72cbc1c8cdd0f65d.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/trace/{page-71d18eec33b9c667.js → page-77b28bfb0996d5ea.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/vault/page-3220c4934f49c6c2.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-2c4e59079ee357ac.js → page-90670589b87abea8.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/workflows/page-7df9243ed021a4cc.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-5d422f38c052967e.js → page-4498c3853f4f688d.js} +1 -1
  50. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-35bedde6d885f08f.js +1 -0
  51. package/apps/pwa/out/_next/static/css/f92f63a5df0ba229.css +1 -0
  52. package/apps/pwa/out/approvals/index.html +1 -1
  53. package/apps/pwa/out/approvals/index.txt +30 -29
  54. package/apps/pwa/out/autopilot/index.html +1 -1
  55. package/apps/pwa/out/autopilot/index.txt +30 -29
  56. package/apps/pwa/out/board/index.html +1 -1
  57. package/apps/pwa/out/board/index.txt +30 -29
  58. package/apps/pwa/out/botlab/index.html +1 -1
  59. package/apps/pwa/out/botlab/index.txt +27 -26
  60. package/apps/pwa/out/bots/index.html +1 -1
  61. package/apps/pwa/out/bots/index.txt +27 -26
  62. package/apps/pwa/out/chat/index.html +1 -1
  63. package/apps/pwa/out/chat/index.txt +27 -26
  64. package/apps/pwa/out/consult/index.html +1 -1
  65. package/apps/pwa/out/consult/index.txt +30 -29
  66. package/apps/pwa/out/control/index.html +1 -1
  67. package/apps/pwa/out/control/index.txt +30 -29
  68. package/apps/pwa/out/design-check/index.html +1 -1
  69. package/apps/pwa/out/design-check/index.txt +30 -29
  70. package/apps/pwa/out/editor/index.html +1 -1
  71. package/apps/pwa/out/editor/index.txt +30 -29
  72. package/apps/pwa/out/exec/index.html +1 -1
  73. package/apps/pwa/out/exec/index.txt +30 -29
  74. package/apps/pwa/out/field/index.html +1 -1
  75. package/apps/pwa/out/field/index.txt +30 -29
  76. package/apps/pwa/out/healthz +1 -1
  77. package/apps/pwa/out/index.html +1 -1
  78. package/apps/pwa/out/index.txt +27 -26
  79. package/apps/pwa/out/intake/index.html +1 -1
  80. package/apps/pwa/out/intake/index.txt +30 -29
  81. package/apps/pwa/out/live/index.html +1 -1
  82. package/apps/pwa/out/live/index.txt +30 -29
  83. package/apps/pwa/out/market/index.html +1 -1
  84. package/apps/pwa/out/market/index.txt +27 -26
  85. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  86. package/apps/pwa/out/missions/placeholder/index.txt +27 -26
  87. package/apps/pwa/out/morning/index.html +1 -1
  88. package/apps/pwa/out/morning/index.txt +30 -29
  89. package/apps/pwa/out/observatory/index.html +1 -1
  90. package/apps/pwa/out/observatory/index.txt +30 -29
  91. package/apps/pwa/out/ops/checklist/index.html +1 -0
  92. package/apps/pwa/out/ops/checklist/index.txt +30 -0
  93. package/apps/pwa/out/ops/release/index.html +1 -0
  94. package/apps/pwa/out/ops/release/index.txt +30 -0
  95. package/apps/pwa/out/reflection/index.html +1 -1
  96. package/apps/pwa/out/reflection/index.txt +28 -27
  97. package/apps/pwa/out/scheduler/index.html +1 -1
  98. package/apps/pwa/out/scheduler/index.txt +30 -29
  99. package/apps/pwa/out/sessions/index.html +1 -1
  100. package/apps/pwa/out/sessions/index.txt +27 -26
  101. package/apps/pwa/out/settings/devices/index.html +1 -1
  102. package/apps/pwa/out/settings/devices/index.txt +30 -29
  103. package/apps/pwa/out/settings/index.html +1 -1
  104. package/apps/pwa/out/settings/index.txt +30 -29
  105. package/apps/pwa/out/setup/done/index.html +1 -1
  106. package/apps/pwa/out/setup/done/index.txt +30 -29
  107. package/apps/pwa/out/setup/index.html +1 -1
  108. package/apps/pwa/out/setup/index.txt +30 -29
  109. package/apps/pwa/out/share/index.html +1 -1
  110. package/apps/pwa/out/share/index.txt +30 -29
  111. package/apps/pwa/out/showroom/index.html +1 -1
  112. package/apps/pwa/out/showroom/index.txt +27 -26
  113. package/apps/pwa/out/tasks/index.html +1 -1
  114. package/apps/pwa/out/tasks/index.txt +30 -29
  115. package/apps/pwa/out/term/index.html +1 -1
  116. package/apps/pwa/out/term/index.txt +30 -29
  117. package/apps/pwa/out/trace/index.html +1 -1
  118. package/apps/pwa/out/trace/index.txt +30 -29
  119. package/apps/pwa/out/vault/index.html +1 -1
  120. package/apps/pwa/out/vault/index.txt +30 -29
  121. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  122. package/apps/pwa/out/workflows/chat-ui/index.txt +30 -29
  123. package/apps/pwa/out/workflows/index.html +1 -1
  124. package/apps/pwa/out/workflows/index.txt +30 -29
  125. package/apps/pwa/out/workspace/index.html +1 -1
  126. package/apps/pwa/out/workspace/index.txt +30 -29
  127. package/apps/pwa/out/worktrees/index.html +1 -1
  128. package/apps/pwa/out/worktrees/index.txt +30 -29
  129. package/graphs/release/recipes.yaml +3 -0
  130. package/graphs/release/release-loop.yaml +3 -1
  131. package/graphs/test-diet/nightly-audit.yaml +18 -0
  132. package/graphs/test-diet/recipes.yaml +12 -0
  133. package/graphs/test-diet/test-diet.yaml +22 -0
  134. package/package.json +2 -1
  135. package/persona-presets/_index.yaml +10 -0
  136. package/persona-presets/_schema.json +146 -0
  137. package/persona-presets/dana.yaml +26 -0
  138. package/persona-presets/haru.yaml +27 -0
  139. package/persona-presets/kai.yaml +27 -0
  140. package/persona-presets/leo.yaml +27 -0
  141. package/persona-presets/mira.yaml +30 -0
  142. package/persona-presets/nari.yaml +27 -0
  143. package/persona-presets/sora.yaml +27 -0
  144. package/persona-presets/yuna.yaml +27 -0
  145. package/plugins/doc-draft/graphs/run-step.ts +1 -1
  146. package/plugins/geo-check/graphs/run-step.ts +34 -10
  147. package/scripts/field-feed/deliver-node.ts +8 -3
  148. package/scripts/field-feed/lib.ts +2 -0
  149. package/scripts/install.sh +20 -11
  150. package/scripts/persona-presets/lint.ts +79 -0
  151. package/scripts/release-loop/checklist-gate-node.ts +2 -2
  152. package/scripts/release-loop/cutoff.ts +20 -2
  153. package/scripts/release-loop/export-check-node.ts +85 -0
  154. package/scripts/release-loop/gate-node.ts +45 -7
  155. package/scripts/release-loop/node-verdict.ts +8 -1
  156. package/scripts/release-loop/npm-publish-node.ts +10 -4
  157. package/scripts/release-loop/ops-upgrade-node.ts +11 -3
  158. package/scripts/release-loop/publish-node.ts +23 -1
  159. package/scripts/release-loop/shard-plan.ts +17 -0
  160. package/scripts/storyboard/storyboard.ts +167 -0
  161. package/scripts/test-diet/lib.ts +89 -0
  162. package/scripts/test-diet/node.ts +144 -0
  163. package/src/acp/auth-methods.ts +30 -0
  164. package/src/acp/server.ts +5 -5
  165. package/src/auth/setup-link-tokens.fixture.ts +19 -0
  166. package/src/auth/setup-link-tokens.ts +153 -0
  167. package/src/budget/unified-usage.ts +75 -0
  168. package/src/channel-bot-token.ts +37 -10
  169. package/src/channel-import/detect.ts +191 -0
  170. package/src/channel-import/run.ts +111 -0
  171. package/src/chat/index.ts +9 -9
  172. package/src/cli/config-test-sync.ts +4 -0
  173. package/src/cli/decisions-cli.ts +13 -3
  174. package/src/cli/doctor-bundle.ts +128 -0
  175. package/src/cli/doctor-cli.ts +36 -1
  176. package/src/cli/doctor-local-llm.ts +95 -0
  177. package/src/cli/import-cli.ts +60 -0
  178. package/src/cli/mcp-login.ts +13 -4
  179. package/src/cli/op-proxy-sync.ts +83 -0
  180. package/src/cli/persona-cli.ts +116 -0
  181. package/src/cli/pr-cli.ts +6 -2
  182. package/src/cli/release-cli.ts +49 -8
  183. package/src/cli/skills-cli.ts +173 -0
  184. package/src/cli/start-cli.ts +13 -4
  185. package/src/codex/setup.ts +15 -13
  186. package/src/dashboard/dashboard-status-lines.ts +45 -0
  187. package/src/dashboard/first-screen-band.ts +35 -0
  188. package/src/dashboard/index.ts +87 -14
  189. package/src/dashboard/provider-slash-runtime.ts +28 -11
  190. package/src/dashboard/slash-runtime/dashboard-handlers.ts +12 -2
  191. package/src/dashboard/slash-runtime/help-from-registry.ts +4 -2
  192. package/src/dashboard/turn-stream-formatter.ts +1 -1
  193. package/src/decisions/decision-cards.ts +291 -0
  194. package/src/decisions/decision-ledger.ts +5 -2
  195. package/src/decisions/discord-decision-cards.ts +133 -0
  196. package/src/decisions/telegram-decision-cards.ts +100 -0
  197. package/src/directives/version-at.ts +77 -0
  198. package/src/discord.ts +17 -0
  199. package/src/domains/outbound-alert.ts +44 -1
  200. package/src/domains/repeated-failure.ts +1 -1
  201. package/src/error-report/report.ts +200 -0
  202. package/src/exec-requests/planner.ts +8 -2
  203. package/src/exec-requests/runner.ts +64 -24
  204. package/src/exec-requests/store.ts +2 -0
  205. package/src/field/field-feed-auto.ts +48 -0
  206. package/src/field/field-reel.ts +8 -1
  207. package/src/hooks/error-report.ts +210 -0
  208. package/src/hooks/receiver.ts +41 -1
  209. package/src/import/apply.ts +128 -0
  210. package/src/import/plan.ts +197 -0
  211. package/src/index.ts +98 -11
  212. package/src/input-core/text-entry.ts +13 -5
  213. package/src/intake-plane/seat-answer.ts +96 -0
  214. package/src/intake-plane/seat-doc-route.ts +17 -3
  215. package/src/maturity/feature-maturity.ts +2 -0
  216. package/src/nexus/api/consult-requests.ts +19 -8
  217. package/src/nexus/api/error-reports.ts +108 -0
  218. package/src/nexus/api/http-server.ts +41 -1
  219. package/src/nexus/api/meta-api.ts +27 -0
  220. package/src/nexus/api/operator.ts +61 -0
  221. package/src/nexus/api/ops-api.ts +139 -0
  222. package/src/nexus/api/ops-seats-sources.ts +65 -0
  223. package/src/nexus/api/ops-seats.ts +114 -0
  224. package/src/nexus/api/outbound-report.ts +7 -0
  225. package/src/nexus/api/public-routes.ts +14 -1
  226. package/src/nexus/api/running-runs-background.ts +68 -0
  227. package/src/nexus/api/running-runs-child.ts +6 -0
  228. package/src/nexus/api/setup-claim.ts +21 -0
  229. package/src/nexus/api/share-captures.ts +91 -0
  230. package/src/nexus/api/skill-problems.ts +56 -0
  231. package/src/nexus/api/terminals.ts +24 -8
  232. package/src/nexus/config/secrets/file-backend.ts +56 -18
  233. package/src/nexus/index.ts +25 -1
  234. package/src/nexus/setup-status.ts +7 -2
  235. package/src/oauth/browser-availability.ts +14 -0
  236. package/src/oauth/codex-quota-policy.ts +16 -0
  237. package/src/onboarding/continue-here.ts +13 -8
  238. package/src/onboarding/first-run-doctor.ts +54 -0
  239. package/src/onboarding/web-first.ts +8 -1
  240. package/src/onboarding.ts +66 -12
  241. package/src/persona/presets.ts +105 -0
  242. package/src/release-loop/checklist.ts +33 -69
  243. package/src/release-loop/feature-store.ts +327 -0
  244. package/src/release-loop/manifest.ts +59 -5
  245. package/src/seat-dispatch/ceo-commands.ts +65 -0
  246. package/src/self-dev/orchestrate.ts +18 -11
  247. package/src/shell-env-bootstrap.ts +44 -7
  248. package/src/skills/connect-claude-plugin.ts +142 -0
  249. package/src/skills/connect.ts +70 -0
  250. package/src/skills/index.ts +29 -1
  251. package/src/skills/repair.ts +124 -0
  252. package/src/skills/runner.ts +11 -2
  253. package/src/skills/tools/usage-slash.ts +8 -7
  254. package/src/skills/trigger-extract.ts +1 -1
  255. package/src/task-orchestrator/surfaces/pod-command-job.ts +16 -8
  256. package/src/telegram-commands.ts +29 -0
  257. package/src/telegram-run.ts +83 -13
  258. package/src/telegram.ts +34 -0
  259. package/src/thinking-line.ts +28 -20
  260. package/src/tui/chat/execution-badge.ts +1 -1
  261. package/src/user-config.ts +111 -13
  262. package/src/ux-sim/turn-states.ts +1 -1
  263. package/src/version/packed-revision.json +1 -1
  264. package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-10ed24f413ed66e9.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/board/page-211810b6c93ab66a.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/live/page-30533117beb53625.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +0 -1
  284. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +0 -1
  285. package/apps/pwa/out/_next/static/chunks/app/settings/page-dd107bf1c7d52a90.js +0 -1
  286. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +0 -1
  287. package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +0 -1
  288. package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +0 -1
  289. package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +0 -1
  290. package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +0 -1
  291. package/apps/pwa/out/_next/static/chunks/app/vault/page-56adbe1a1f1379bd.js +0 -1
  292. package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +0 -1
  293. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +0 -1
  294. package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +0 -1
  295. package/apps/pwa/out/_next/static/keajW7DDyghEs1y8oJe6T/_buildManifest.js +0 -1
  296. /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → bFI5EEwg7T6aG88ibX4Zq}/_ssgManifest.js +0 -0
@@ -10,6 +10,7 @@ import { getElanousConfigDir } from '../elanous-config-dir.js';
10
10
  import type { ExecRequest } from '../exec-requests/store.js';
11
11
  import { resolveDaemonEndpoint } from '../nexus/daemon-endpoint.js';
12
12
  import { parseSeatAddress, resolveSeat } from '../seat-address/seat-address.js';
13
+ import { answerAsSeat, isNoGraphFailure } from './seat-answer.js';
13
14
 
14
15
  const POLL_MS = 5_000;
15
16
  const TIMEOUT_MS = 30 * 60_000;
@@ -34,7 +35,9 @@ export interface SeatDocDeps {
34
35
  sleep?: (ms: number) => Promise<void>;
35
36
  now?: () => number;
36
37
  pwaUrl?: () => string | undefined;
37
- log?: (event: 'submitted' | 'research' | 'settled' | 'delivered' | 'delivery-failed', data: Record<string, unknown>) => void;
38
+ log?: (event: 'submitted' | 'research' | 'settled' | 'delivered' | 'delivery-failed' | 'seat-answer', data: Record<string, unknown>) => void;
39
+ /** EV12d — the seat's own answer when no installed graph fits (default = role file ⊕ owned checklist ⊕ recent, one LLM call). */
40
+ seatAnswer?: (seatAddress: string, question: string) => Promise<{ title: string; text: string } | null>;
38
41
  }
39
42
 
40
43
  export function isSeatDocRequest(text: string): boolean {
@@ -194,9 +197,20 @@ export async function submitSeatDocRequest({ text, reportTo, deps = {} }: {
194
197
  }
195
198
  log('settled', { id, status: result?.status ?? (lookupAvailable ? 'timeout' : 'lookup-unavailable'), lastConfirmedStatus });
196
199
  const links = result?.status === 'done' ? await fileLinks(id, result, deps) : new Map<string, string>();
200
+ let reply = message(id, result, withoutResearch, lookupAvailable, accessiblePwaUrl((deps.pwaUrl ?? chatPwaUrl)()), links);
201
+ if (isNoGraphFailure(result)) {
202
+ // EV12d — a question with no fitting graph gets the seat's own answer, not «문서 요청 실패».
203
+ const seat = result!.seats![0]!.seat;
204
+ try {
205
+ const answer = await (deps.seatAnswer ?? answerAsSeat)(seat, body);
206
+ if (answer) reply = `${answer.title} 답 (접수번호 ${id})\n${answer.text}`;
207
+ log('seat-answer', { id, seat, answered: !!answer });
208
+ } catch (error) {
209
+ log('seat-answer', { id, seat, answered: false, reason: String(error).slice(0, 200) });
210
+ }
211
+ }
197
212
  try {
198
- const sent = await (deps.sendOutbound ?? sendOutbound)(message(id, result, withoutResearch, lookupAvailable,
199
- accessiblePwaUrl((deps.pwaUrl ?? chatPwaUrl)()), links), 'report', reportTo);
213
+ const sent = await (deps.sendOutbound ?? sendOutbound)(reply, 'report', reportTo);
200
214
  log(sent ? 'delivered' : 'delivery-failed', { id });
201
215
  } catch (error) {
202
216
  log('delivery-failed', { id, reason: String(error).slice(0, 200) });
@@ -38,6 +38,8 @@ export const FEATURE_MATURITY = {
38
38
  '/design-check': { pwa: 'tool' },
39
39
  '/control': { pwa: 'tool' },
40
40
  '/scheduler': { pwa: 'ops' },
41
+ '/ops/release': { pwa: 'ops' },
42
+ '/ops/checklist': { pwa: 'ops' },
41
43
  '/dashboard': { pwa: 'ops' },
42
44
  '/bots': { pwa: 'ops' },
43
45
  '/botlab': { pwa: 'ops' },
@@ -2,7 +2,8 @@ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, re
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { debug } from '../../debug/log.js';
5
- import { sendOutbound } from '../../domains/outbound-alert.js';
5
+ import { routeOutbound } from '../outbound/router.js';
6
+ import { getUserConfig } from '../../user-config.js';
6
7
  import { effectiveInstanceRoot } from '../../instance/resolve.js';
7
8
  import { jsonResponse } from './json-response.js';
8
9
 
@@ -22,7 +23,10 @@ interface ConsultRequest {
22
23
  export interface ConsultRequestsDeps {
23
24
  root?: () => string;
24
25
  now?: () => string;
25
- send?: typeof sendOutbound;
26
+ /** Owner alert. Default = the daemon's own in-process router (the same one `/v1/outbound` uses).
27
+ * ⛔ Never `sendOutbound` here: it is a synchronous curl to this very daemon's `/v1/outbound`, so inside the
28
+ * daemon it blocks the event loop until it times out (~25 s · `unreachable`) and the alert is lost (CS1 · 10-01). */
29
+ send?: (text: string, kind: string) => unknown;
26
30
  }
27
31
 
28
32
  function file(root: string): string {
@@ -107,11 +111,18 @@ export async function handleConsultRequests(req: Request, deps: ConsultRequestsD
107
111
  return jsonResponse({ error: 'journal-unavailable' }, 503);
108
112
  }
109
113
  debug.log('consult.request', 'accepted', { receiptId: entry.receiptId });
110
- try {
111
- (deps.send ?? sendOutbound)(
112
- `📮 상담 문의 ${entry.receiptId} · ${entry.name}(${entry.kind === 'personal' ? '개인' : (entry.org ?? '회사')}) · 관심 ${entry.interest}\n앱에서 보기`,
113
- 'alert',
114
- );
115
- } catch { /* the local receipt remains accepted if delivery is unavailable */ }
114
+ const alert = `📮 상담 문의 ${entry.receiptId} · ${entry.name}(${entry.kind === 'personal' ? '개인' : (entry.org ?? '회사')}) · 관심 ${entry.interest}\n앱에서 보기`;
115
+ // Fire and forget — the receipt is already on disk; the answer must not wait for the channel.
116
+ void Promise.resolve()
117
+ .then(() => (deps.send ?? notifyInProcess)(alert, 'alert'))
118
+ .catch((error: unknown) => { debug.log('consult.request', 'notify-failed', { receiptId: entry.receiptId, reason: error instanceof Error ? error.message : String(error) }); });
116
119
  return jsonResponse({ receiptId: entry.receiptId, receivedAt: entry.receivedAt }, 202);
117
120
  }
121
+
122
+ async function notifyInProcess(text: string, kind: string): Promise<void> {
123
+ const result = await routeOutbound(getUserConfig(), { text, markdown: false, kind });
124
+ debug.log('consult.request', result.delivered ? 'notified' : 'notify-failed', {
125
+ delivered: result.delivered,
126
+ channels: result.channels.map((channel) => `${channel.type}:${channel.ok ? 'ok' : 'fail'}`),
127
+ });
128
+ }
@@ -0,0 +1,108 @@
1
+ // ER2 — the Primary side of error reports. The bot VM forwards each accepted report here over the tailnet
2
+ // (ingest token). It is stored under <instance>/error-reports/YYYY/MM/DD/<reportId>.json for 30 days, and the
3
+ // operator gets one Telegram line per distinct error per hour — never the message, stack or identity.
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { debug } from '../../debug/log.js';
7
+ import { effectiveInstanceRoot } from '../../instance/resolve.js';
8
+ import type { ReceivedReport } from '../../hooks/error-report.js';
9
+ import { validateErrorReport } from '../../hooks/error-report.js';
10
+ import { jsonResponse } from './json-response.js';
11
+ import { routeOutboundInProcess } from './outbound-report.js';
12
+
13
+ export const ERROR_REPORT_KEEP_DAYS = 30;
14
+ const DAY_MS = 86_400_000;
15
+
16
+ export interface ErrorReportIngestDeps {
17
+ root?: () => string;
18
+ now?: () => number;
19
+ /** Operator alert. Default = the daemon's in-process router (never a self-call · OB8). */
20
+ alert?: (text: string, kind: string) => Promise<boolean>;
21
+ }
22
+
23
+ function storeDir(root: string): string { return join(root, 'error-reports'); }
24
+
25
+ /** Removes day folders older than the keep window. Returns how many days were removed. */
26
+ export function pruneErrorReports(root: string, now: number): number {
27
+ const base = storeDir(root);
28
+ if (!existsSync(base)) return 0;
29
+ let removed = 0;
30
+ for (const year of readdirSync(base).filter((n) => /^\d{4}$/.test(n))) {
31
+ for (const month of readdirSync(join(base, year)).filter((n) => /^\d{2}$/.test(n))) {
32
+ for (const day of readdirSync(join(base, year, month)).filter((n) => /^\d{2}$/.test(n))) {
33
+ const at = Date.parse(`${year}-${month}-${day}T00:00:00Z`);
34
+ if (Number.isFinite(at) && now - at > ERROR_REPORT_KEEP_DAYS * DAY_MS + DAY_MS) {
35
+ rmSync(join(base, year, month, day), { recursive: true, force: true });
36
+ removed++;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ return removed;
42
+ }
43
+
44
+ /** One alert per dedupe per hour, remembered on disk so a restart does not repeat it. */
45
+ function shouldAlert(root: string, dedupe: string, now: number): boolean {
46
+ const path = join(storeDir(root), 'alerts.json');
47
+ let seen: Record<string, number> = {};
48
+ try { seen = JSON.parse(readFileSync(path, 'utf8')) as Record<string, number>; } catch { /* first alert */ }
49
+ for (const [k, t] of Object.entries(seen)) if (now - t > DAY_MS) delete seen[k];
50
+ const last = seen[dedupe];
51
+ if (last !== undefined && now - last < 3600_000) return false;
52
+ seen[dedupe] = now;
53
+ mkdirSync(storeDir(root), { recursive: true, mode: 0o700 });
54
+ writeFileSync(path, JSON.stringify(seen), { mode: 0o600 });
55
+ return true;
56
+ }
57
+
58
+ export function errorReportAlertText(item: ReceivedReport, count24h: number): string {
59
+ const r = item.report;
60
+ return `🧯 오류 보고 ${r.code} · v${r.app.version} · ${r.app.surface} · 같은 오류 24h ${count24h}회 · ${item.reportId}`;
61
+ }
62
+
63
+ /** Same dedupe stored in the last 24 h, counted from the store itself. */
64
+ function count24h(root: string, dedupe: string, now: number): number {
65
+ let n = 0;
66
+ for (const offset of [0, 1]) {
67
+ const d = new Date(now - offset * DAY_MS);
68
+ const dir = join(storeDir(root), String(d.getUTCFullYear()), String(d.getUTCMonth() + 1).padStart(2, '0'), String(d.getUTCDate()).padStart(2, '0'));
69
+ if (!existsSync(dir)) continue;
70
+ for (const name of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
71
+ try {
72
+ const item = JSON.parse(readFileSync(join(dir, name), 'utf8')) as ReceivedReport;
73
+ if (item.dedupe === dedupe && now - Date.parse(item.receivedAt) < DAY_MS) n++;
74
+ } catch { /* a broken file is not counted */ }
75
+ }
76
+ }
77
+ return n;
78
+ }
79
+
80
+ /** POST /v1/reports/ingest — the caller (router) has already checked the ingest token. */
81
+ export async function handleErrorReportIngest(req: Request, deps: ErrorReportIngestDeps = {}): Promise<Response> {
82
+ const root = (deps.root ?? effectiveInstanceRoot)();
83
+ const now = (deps.now ?? Date.now)();
84
+ let body: unknown;
85
+ try { body = await req.json(); } catch { return jsonResponse({ error: 'invalid', field: 'body' }, 400); }
86
+ const item = body as Partial<ReceivedReport>;
87
+ if (typeof item.reportId !== 'string' || !/^er_[0-9a-z]{26}$/.test(item.reportId)) return jsonResponse({ error: 'invalid', field: 'reportId' }, 400);
88
+ if (typeof item.dedupe !== 'string' || !/^[0-9a-f]{12}$/.test(item.dedupe)) return jsonResponse({ error: 'invalid', field: 'dedupe' }, 400);
89
+ if (typeof item.receivedAt !== 'string' || !Number.isFinite(Date.parse(item.receivedAt))) return jsonResponse({ error: 'invalid', field: 'receivedAt' }, 400);
90
+ const checked = validateErrorReport(item.report);
91
+ if (!checked.ok) return jsonResponse({ error: 'invalid', field: `report.${checked.field}` }, 400);
92
+ const stored: ReceivedReport = { reportId: item.reportId, dedupe: item.dedupe, receivedAt: item.receivedAt, report: checked.report };
93
+ const at = new Date(Date.parse(stored.receivedAt));
94
+ const dir = join(storeDir(root), String(at.getUTCFullYear()), String(at.getUTCMonth() + 1).padStart(2, '0'), String(at.getUTCDate()).padStart(2, '0'));
95
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
96
+ writeFileSync(join(dir, `${stored.reportId}.json`), JSON.stringify(stored), { mode: 0o600 });
97
+ let pruned = 0;
98
+ try { pruned = pruneErrorReports(root, now); } catch { /* pruning never blocks a receipt */ }
99
+ const count = count24h(root, stored.dedupe, now);
100
+ const alerted = shouldAlert(root, stored.dedupe, now);
101
+ debug.log('error-report.ingest', 'stored', { code: stored.report.code, surface: stored.report.app.surface, count24h: count, alerted, pruned });
102
+ if (alerted) {
103
+ const alert = deps.alert ?? routeOutboundInProcess;
104
+ void Promise.resolve().then(() => alert(errorReportAlertText(stored, count), 'alert'))
105
+ .catch((error: unknown) => debug.log('error-report.ingest', 'alert-failed', { reason: error instanceof Error ? error.message : String(error) }));
106
+ }
107
+ return jsonResponse({ stored: true, reportId: stored.reportId, count }, 202);
108
+ }
@@ -15,6 +15,7 @@
15
15
  // Port policy (N1-2 decision): auto-pick from 31415 upward. Actual port
16
16
  // is recorded in runtime.json + printed in boot banner.
17
17
 
18
+ import { setInProcessOutbound } from '../../domains/outbound-alert.js';
18
19
  import { getUserConfig } from '../../user-config.js';
19
20
  import type { TelegramEvent } from '../../workflow-runtime/triggers/telegram-source.js';
20
21
  import { spawnSync } from 'node:child_process';
@@ -154,11 +155,14 @@ import {
154
155
  import { handleBuildsGet, parseBuildsPath } from './builds-api.js';
155
156
  import { handleHarnessAskPost, handleHarnessAskStatusGet, handleHarnessRunEventsGet, handleHarnessRunScreenGet, handleHarnessRunsGet, handleHarnessStopPost } from './harness-api.js';
156
157
  import { dispatchPersonaRoute } from './personas.js';
158
+ import { handleMe } from './operator.js';
159
+ import { handleOpsApi } from './ops-api.js';
157
160
  import { handleRoleJudge } from './role-judge.js';
158
161
  import { handleAudioStt } from './audio-stt.js';
159
162
  import { handleLlmModels } from './llm-models.js';
160
163
  import { handleLlmHostsConfig } from './llm-hosts-config.js';
161
164
  import { handleLlmRotationGet, handleLlmRotationNext } from './llm-rotation.js';
165
+ import { handleSetupClaim } from './setup-claim.js';
162
166
  import {
163
167
  handleLlmProvidersList,
164
168
  handleLlmProviderSet,
@@ -311,6 +315,7 @@ import {
311
315
  handleVoiceTranscribe,
312
316
  checkAuth,
313
317
  bearerCredential,
318
+ setupBearerScopeDenied,
314
319
  registerAuthPeerAddress,
315
320
  type MetaApiOpts,
316
321
  } from './meta-api.js';
@@ -385,6 +390,8 @@ import {
385
390
  import { handleSessionTurnControl, SESSION_TURN_CONTROL_PATH } from '../../session/session-turn-control.js';
386
391
  import { authenticatePodCredential, handlePodGrokCredential, handlePodGithubCredential, POD_CREDENTIAL_GROK_PATH, POD_CREDENTIAL_GITHUB_PATH } from './pod-credential-api.js';
387
392
  import { handleOutboundReport } from './outbound-report.js';
393
+ import { handleErrorReportIngest } from './error-reports.js';
394
+ import { routeOutboundInProcess } from './outbound-report.js';
388
395
  import { handleSelfEvent } from './self-event.js';
389
396
  /** 매매 «실행»은 애드온이다(대표 09-20 결정 ③ · 별도 상용 저장소 · release/trading-export.yaml) — 공개 코어엔 없다.
390
397
  * 있으면 그 경로를 켜고, 없으면 501. 경로를 변수로 둬 공개본 타입 검사가 «모듈 없음»으로 막히지 않게 한다. */
@@ -757,6 +764,8 @@ export function startNexusHttpServer(opts: NexusHttpServerOpts): NexusHttpServer
757
764
  ...(websocketHandler ? { websocket: websocketHandler } : {}),
758
765
  } as unknown as Parameters<typeof Bun.serve>[0];
759
766
  server = Bun.serve(fetchOpts);
767
+ // OB8 — this process now answers /v1/outbound itself; its own sendOutbound calls must not curl it.
768
+ setInProcessOutbound(routeOutboundInProcess);
760
769
  // 데몬이 «실제로» 떴을 때만 재시작 복구(맡긴 일) — 주입된 러너(시험)는 건드리지 않는다.
761
770
  if (!opts.execRequests) { try { execRequestRunner().reconcileInterrupted(); } catch { /* 복구 실패가 서버 기동을 막지 않는다 */ } }
762
771
  resolvedPort = port;
@@ -781,6 +790,7 @@ export function startNexusHttpServer(opts: NexusHttpServerOpts): NexusHttpServer
781
790
  url,
782
791
  stop() {
783
792
  try { server!.stop(true); } catch { /* idempotent */ }
793
+ setInProcessOutbound(null);
784
794
  },
785
795
  };
786
796
  }
@@ -820,6 +830,9 @@ export async function routeRequest(
820
830
  registerAuthPeerAddress(req, (server as BunServerLike & {
821
831
  requestIP?: (request: Request) => { address: string } | null;
822
832
  }).requestIP?.(req)?.address);
833
+ if (opts.metaApi && setupBearerScopeDenied(req, opts.metaApi)) {
834
+ return jsonResponse({ error: 'forbidden' }, 403);
835
+ }
823
836
 
824
837
  // PR c — try WS upgrade before HTTP routing. On undefined return the
825
838
  // bridge has accepted the upgrade (Bun completes the handshake) and
@@ -846,6 +859,12 @@ export async function routeRequest(
846
859
  const { pathname } = url;
847
860
  const method = req.method;
848
861
 
862
+ // Operator proxy credentials are distinct from the owner bearer. The ops handler
863
+ // performs its own operatorSignal gate before reading any run or checklist data.
864
+ if (pathname === '/v1/ops' || pathname.startsWith('/v1/ops/')) {
865
+ return handleOpsApi(req, opts.metaApi);
866
+ }
867
+
849
868
  // Default-deny for every `/v1/` path that is not on PUBLIC_ROUTES.
850
869
  // Reuses checkAuth (same-origin exemption + constant-time bearer).
851
870
  // setupMode is not implemented on this server (P24b) — pass false.
@@ -876,7 +895,7 @@ export async function routeRequest(
876
895
  const fallbackAuthorized = fallbackToken !== undefined && offered?.startsWith('Bearer ') === true
877
896
  && compareTokenConstTime(offered.slice('Bearer '.length).trim(), fallbackToken);
878
897
  if (!(opts.metaApi ? checkAuth(req, opts.metaApi) : fallbackAuthorized)) {
879
- const name = method === 'POST' && pathname === '/v1/tasks' && opts.metaApi
898
+ const name = method === 'POST' && (pathname === '/v1/tasks' || pathname === '/v1/reports/ingest') && opts.metaApi
880
899
  ? matchIngestAuthorization(req)?.name
881
900
  : undefined;
882
901
  if (name === undefined) {
@@ -1304,6 +1323,8 @@ export async function routeRequest(
1304
1323
  if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1305
1324
  return handleTaskCreatePost(req, opts.metaApi);
1306
1325
  }
1326
+ // ER2 — error reports forwarded by the bot VM (ingest token · same as /v1/tasks).
1327
+ if (method === 'POST' && pathname === '/v1/reports/ingest') return handleErrorReportIngest(req);
1307
1328
  if (method === 'POST' && pathname.startsWith('/v1/tasks/') && pathname.endsWith('/approve')) {
1308
1329
  if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1309
1330
  let taskId: string;
@@ -1512,6 +1533,10 @@ export async function routeRequest(
1512
1533
  if (pathname === '/v1/llm/rotation' && method === 'OPTIONS') {
1513
1534
  return handleLlmRotationGet(req);
1514
1535
  }
1536
+ // The one-use link is verified by the claim handler, not by the owner bearer.
1537
+ if (pathname === '/v1/setup/claim' && (method === 'POST' || method === 'OPTIONS')) {
1538
+ return method === 'OPTIONS' ? corsPreflight('POST, OPTIONS') : handleSetupClaim(req);
1539
+ }
1515
1540
  // PWA `/setup` wizard Phase 1 (2026-05-19) — LLM provider 첫 셋업.
1516
1541
  // POST 는 mutation 블록 안에서 등록 (memory `feedback_post_route_must_be_in_method_block`).
1517
1542
  // GET sibling 은 아래 GET-only 블록에 등록.
@@ -1969,6 +1994,18 @@ export async function routeRequest(
1969
1994
  }
1970
1995
  // HANDOFF §4.2 follow-up — skill-side mirror of the workflow
1971
1996
  // router. Same regex → LLM cascade, applied to the skill index.
1997
+ // SK2 (PWA) — skills the index could not read: list ⊕ confirmed repair.
1998
+ if (pathname === '/v1/skills/problems' || pathname === '/v1/skills/problems/repair') {
1999
+ if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
2000
+ const { handleSkillProblems } = await import('./skill-problems.js');
2001
+ return handleSkillProblems(req, opts.metaApi);
2002
+ }
2003
+ // SC1 (PWA · beta) — «공유용 캡처»: masked screen PNG in, metadata chunks stripped, stored under <configDir>/captures.
2004
+ if (pathname === '/v1/captures' || pathname.startsWith('/v1/captures/')) {
2005
+ if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
2006
+ const { handleShareCaptures } = await import('./share-captures.js');
2007
+ return handleShareCaptures(req, opts.metaApi);
2008
+ }
1972
2009
  if (pathname === '/v1/skills/route' && method === 'POST') {
1973
2010
  if (!opts.metaApi) return jsonResponse({ error: 'meta-api-not-wired' }, 503);
1974
2011
  return handleSkillRoute(req, opts.metaApi);
@@ -2133,6 +2170,9 @@ export async function routeRequest(
2133
2170
  }
2134
2171
  return jsonResponse({ error: 'static-not-wired' }, 404);
2135
2172
  }
2173
+ // OPS1·OPS2 — whether this caller is an operator (draws the ops area). Answers everyone before any auth gate;
2174
+ // false unless this daemon's config has operator.enabled.
2175
+ if (pathname === '/v1/me' && req.method === 'GET') return handleMe(req, opts.metaApi ?? ({} as MetaApiOpts));
2136
2176
  if (pathname === '/v1/health') {
2137
2177
  return handleHealth(opts.state, opts.registry, {
2138
2178
  bindHost: bind.hostname,
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { randomUUID } from 'node:crypto';
18
18
  import { matchTempToken } from '../../auth/temp-tokens.js';
19
+ import { matchSetupBearer } from '../../auth/setup-link-tokens.js';
19
20
  import { existsSync, readFileSync, statSync } from 'node:fs';
20
21
  import { extname } from 'node:path';
21
22
 
@@ -215,6 +216,8 @@ type AuthReason =
215
216
  | 'untrusted-same-origin-peer'
216
217
  | 'bearer-match'
217
218
  | 'temp-token'
219
+ | 'setup-bearer'
220
+ | 'setup-scope-denied'
218
221
  | 'no-bearer-configured'
219
222
  | 'missing-auth-header'
220
223
  | 'bearer-length-mismatch'
@@ -248,8 +251,27 @@ export function registerAuthPeerAddress(req: Request, peerAddress: string | unde
248
251
  authPeerAddresses.set(req, peerAddress);
249
252
  }
250
253
 
254
+ function setupRouteAllowed(req: Request): boolean {
255
+ const path = new URL(req.url).pathname;
256
+ if (path.startsWith('/v1/setup/')) return true;
257
+ return (req.method === 'POST' && path === '/v1/prompt/stream')
258
+ || (req.method === 'GET' && (
259
+ /^\/v1\/sessions\/store\/[^/]+$/.test(path) || path === '/v1/chat/events'
260
+ ));
261
+ }
262
+
251
263
  function decideAuth(req: Request, opts: MetaApiOpts): { ok: boolean; reason: AuthReason } {
252
264
  if (opts.noAuth) return { ok: true, reason: 'noauth' };
265
+ const offered = req.headers.get('authorization');
266
+ if (offered?.startsWith('Bearer elsb_')) {
267
+ const owner = decideBearer(req, opts);
268
+ if (owner.ok) return owner;
269
+ const bearer = offered.slice('Bearer '.length).trim();
270
+ if (!matchSetupBearer(bearer)) return { ok: false, reason: 'bearer-mismatch' };
271
+ return setupRouteAllowed(req)
272
+ ? { ok: true, reason: 'setup-bearer' }
273
+ : { ok: false, reason: 'setup-scope-denied' };
274
+ }
253
275
  const peerAddress = authPeerAddresses.get(req);
254
276
  if (isSameOriginRequest(req, peerAddress)) return { ok: true, reason: 'same-origin' };
255
277
  const peerRejected = req.headers.get('sec-fetch-site') === 'same-origin'
@@ -292,6 +314,10 @@ export function bearerCredential(req: Request, opts: MetaApiOpts): 'noauth' | 'b
292
314
  // check — without duplicating security-sensitive code.
293
315
  const authDecisions = new WeakMap<Request, { ok: boolean; reason: AuthReason }>();
294
316
 
317
+ export function setupBearerScopeDenied(req: Request, opts: MetaApiOpts): boolean {
318
+ return decideAuth(req, opts).reason === 'setup-scope-denied';
319
+ }
320
+
295
321
  export function checkAuth(req: Request, opts: MetaApiOpts): boolean {
296
322
  const decision = decideAuth(req, opts);
297
323
  authDecisions.set(req, decision);
@@ -322,6 +348,7 @@ export function checkAuth(req: Request, opts: MetaApiOpts): boolean {
322
348
  * intentionally collapse here to avoid exposing token-length information. */
323
349
  function authFailureResponse(req: Request, opts: MetaApiOpts): Response {
324
350
  const decision = authDecisions.get(req) ?? decideAuth(req, opts);
351
+ if (decision.reason === 'setup-scope-denied') return jsonResponse({ error: 'forbidden' }, 403);
325
352
  return jsonResponse({ error: 'unauthorized', reason: publicAuthFailureReason(decision.reason) }, 401);
326
353
  }
327
354
 
@@ -0,0 +1,61 @@
1
+ // OPS1·OPS2 — who is an «operator» (sees the ops area). Off unless this daemon's config says `operator.enabled: true`
2
+ // (only our own machines); an external install never reports an operator, whatever header or token arrives.
3
+ // Design: 내부 문서 `DESIGN-pwa-menu-reorg-and-ops-area-2026-10-02` §2.
4
+ import { timingSafeEqual } from 'node:crypto';
5
+ import { readFileSync, statSync } from 'node:fs';
6
+ import { debug } from '../../debug/log.js';
7
+ import { getUserConfig } from '../../user-config.js';
8
+ import { checkAuth, type MetaApiOpts } from './meta-api.js';
9
+
10
+ /** Set by the op.elanous.ai proxy (Caddy) after Google sign-in, server side; the proxy strips any incoming copy. */
11
+ export const OPERATOR_HEADER = 'x-elanous-operator';
12
+
13
+ export type OperatorSource = 'op-proxy' | 'owner' | null;
14
+ export interface OperatorSignal { operator: boolean; operatorSource: OperatorSource }
15
+ export interface OperatorConfig { enabled: boolean; proxySecretFile?: string }
16
+
17
+ export function readOperatorConfig(raw: unknown = getUserConfig().raw?.operator): OperatorConfig {
18
+ const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
19
+ return {
20
+ enabled: o.enabled === true,
21
+ ...(typeof o.proxySecretFile === 'string' && o.proxySecretFile.trim() ? { proxySecretFile: o.proxySecretFile.trim() } : {}),
22
+ };
23
+ }
24
+
25
+ /** The proxy secret, only from a regular file nobody but the owner can read (mode 600); anything else turns the header path off. */
26
+ export function readProxySecret(path: string | undefined): string | null {
27
+ if (!path) return null;
28
+ try {
29
+ const st = statSync(path);
30
+ if (!st.isFile() || (st.mode & 0o077) !== 0) return null;
31
+ const value = readFileSync(path, 'utf8').trim();
32
+ return value.length >= 16 ? value : null;
33
+ } catch { return null; }
34
+ }
35
+
36
+ function sameSecret(offered: string, secret: string): boolean {
37
+ const a = Buffer.from(offered);
38
+ const b = Buffer.from(secret);
39
+ return a.length === b.length && timingSafeEqual(a, b);
40
+ }
41
+
42
+ export interface OperatorDeps { config?: OperatorConfig; readSecret?: (path: string | undefined) => string | null; isOwner?: (req: Request) => boolean }
43
+
44
+ export function operatorSignal(req: Request, opts: MetaApiOpts, deps: OperatorDeps = {}): OperatorSignal {
45
+ const config = deps.config ?? readOperatorConfig();
46
+ if (!config.enabled) return { operator: false, operatorSource: null };
47
+ const offered = req.headers.get(OPERATOR_HEADER);
48
+ if (offered) {
49
+ const secret = (deps.readSecret ?? readProxySecret)(config.proxySecretFile);
50
+ if (secret && sameSecret(offered, secret)) return { operator: true, operatorSource: 'op-proxy' };
51
+ }
52
+ if ((deps.isOwner ?? ((r: Request) => checkAuth(r, opts)))(req)) return { operator: true, operatorSource: 'owner' };
53
+ return { operator: false, operatorSource: null };
54
+ }
55
+
56
+ /** `GET /v1/me` — answers every caller (no auth needed: it only says whether to draw the ops area). */
57
+ export function handleMe(req: Request, opts: MetaApiOpts, deps: OperatorDeps = {}): Response {
58
+ const signal = operatorSignal(req, opts, deps);
59
+ debug.log('ops.api', 'me', { operator: signal.operator, source: signal.operatorSource });
60
+ return Response.json(signal, { headers: { 'cache-control': 'no-store' } });
61
+ }
@@ -0,0 +1,139 @@
1
+ import { lstatSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { debug, redactSecretText } from '../../debug/log.js';
4
+ import { effectiveInstanceRoot } from '../../instance/resolve.js';
5
+ import { listChecklist } from '../../release-loop/checklist.js';
6
+ import { jsonResponse } from './json-response.js';
7
+ import { operatorSignal } from './operator.js';
8
+ import { createSeatsCache, todayKst } from './ops-seats.js';
9
+ import { liveSeatsSources } from './ops-seats-sources.js';
10
+ import type { MetaApiOpts } from './meta-api.js';
11
+
12
+ const VERSION = /^\d+\.\d+\.\d+(?:-(?:rc|alpha|beta)\.\d+)?$/;
13
+ const IDENTIFIER = /^[A-Za-z0-9-]+$/;
14
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
15
+ let seatsCache: ReturnType<typeof createSeatsCache> | null = null;
16
+ /** Tests swap the sources; production builds them lazily on first use. */
17
+ export function setSeatsCacheForTest(cache: ReturnType<typeof createSeatsCache> | null): void { seatsCache = cache; }
18
+ const LOG_PATH = /^\/v1\/ops\/release\/runs\/([^/]+)\/nodes\/([^/]+)\/log$/;
19
+
20
+ interface RunNode { nodeId: string; ok?: unknown; output?: unknown }
21
+ interface RunRecord {
22
+ status: string;
23
+ path: string[];
24
+ startedAt: string;
25
+ input?: unknown;
26
+ nodes: RunNode[];
27
+ }
28
+
29
+ function recordAt(path: string): RunRecord | null {
30
+ try {
31
+ if (!lstatSync(path).isFile()) return null;
32
+ const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
33
+ if (!value || typeof value !== 'object') return null;
34
+ const run = value as Partial<RunRecord>;
35
+ if (typeof run.status !== 'string' || !Array.isArray(run.path)
36
+ || typeof run.startedAt !== 'string' || !Array.isArray(run.nodes)) return null;
37
+ return run as RunRecord;
38
+ } catch { return null; }
39
+ }
40
+
41
+ function outputText(output: unknown): string {
42
+ if (typeof output === 'string') return output;
43
+ if (output === undefined || output === null) return '';
44
+ return JSON.stringify(output) ?? '';
45
+ }
46
+
47
+ function nodeSummary(output: unknown): string {
48
+ let value = output;
49
+ if (typeof value === 'string') {
50
+ const text = value;
51
+ try { value = JSON.parse(text) as unknown; }
52
+ catch {
53
+ const last = text.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
54
+ try { value = JSON.parse(last ?? '') as unknown; } catch { return ''; }
55
+ }
56
+ }
57
+ if (!value || typeof value !== 'object') return '';
58
+ const summary = (value as { summary?: unknown }).summary;
59
+ return typeof summary === 'string' ? redactSecretText(summary).slice(0, 300) : '';
60
+ }
61
+
62
+ function runVersion(input: unknown): string | null {
63
+ if (!input || typeof input !== 'object') return null;
64
+ const version = (input as { version?: unknown }).version;
65
+ return typeof version === 'string' && VERSION.test(version) ? version : null;
66
+ }
67
+
68
+ /** The sole data gate for every read-only /v1/ops endpoint. */
69
+ export function handleOpsApi(req: Request, metaApi: MetaApiOpts | undefined): Response | Promise<Response> {
70
+ const url = new URL(req.url);
71
+ const { pathname } = url;
72
+ if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed' }, 405);
73
+
74
+ const signal = metaApi ? operatorSignal(req, metaApi) : { operator: false, operatorSource: null };
75
+ if (!signal.operator) {
76
+ debug.log('ops.api', 'refused', { path: pathname, source: signal.operatorSource });
77
+ return jsonResponse({ error: 'forbidden' }, 403);
78
+ }
79
+ const served = (response: Response): Response => {
80
+ debug.log('ops.api', 'served', { path: pathname, source: signal.operatorSource });
81
+ response.headers.set('cache-control', 'no-store');
82
+ return response;
83
+ };
84
+ if (pathname === '/v1/ops/seats') {
85
+ const date = url.searchParams.get('date') ?? todayKst();
86
+ if (!DATE.test(date) || Number.isNaN(Date.parse(`${date}T00:00:00+09:00`))) return jsonResponse({ error: 'invalid-date' }, 400);
87
+ seatsCache ??= createSeatsCache(liveSeatsSources());
88
+ return seatsCache(date).then((board) => served(jsonResponse(board)), () => jsonResponse({ error: 'seats-unavailable' }, 500));
89
+ }
90
+ const version = url.searchParams.get('version');
91
+ if (pathname === '/v1/ops/checklist') {
92
+ if (!version || !VERSION.test(version)) return jsonResponse({ error: 'invalid-version' }, 400);
93
+ return served(jsonResponse(listChecklist(version)));
94
+ }
95
+ if (pathname === '/v1/ops/release/runs') {
96
+ if (version !== null && !VERSION.test(version)) return jsonResponse({ error: 'invalid-version' }, 400);
97
+ const dir = join(effectiveInstanceRoot(), 'graph-runs', 'release-loop');
98
+ let filenames: string[];
99
+ try {
100
+ filenames = readdirSync(dir, { withFileTypes: true })
101
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json')
102
+ && IDENTIFIER.test(entry.name.slice(0, -5)))
103
+ .map((entry) => entry.name);
104
+ }
105
+ catch (error) {
106
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return served(jsonResponse([]));
107
+ return jsonResponse({ error: 'runs-unavailable' }, 500);
108
+ }
109
+ const runs = filenames.flatMap((filename) => {
110
+ const run = recordAt(join(dir, filename));
111
+ if (!run) return [];
112
+ const v = runVersion(run.input);
113
+ if (version !== null && v !== version) return [];
114
+ return [{
115
+ runId: redactSecretText(filename.slice(0, -5)), status: redactSecretText(run.status),
116
+ startedAt: redactSecretText(run.startedAt), version: v,
117
+ path: run.path.filter((step): step is string => typeof step === 'string').map(redactSecretText),
118
+ nodes: run.nodes.filter((node) => node && typeof node.nodeId === 'string').map((node) => ({
119
+ nodeId: redactSecretText(node.nodeId), ok: typeof node.ok === 'boolean' ? node.ok : null,
120
+ summary: nodeSummary(node.output),
121
+ })),
122
+ }];
123
+ }).sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, 20);
124
+ return served(jsonResponse(runs));
125
+ }
126
+ // WHATWG URL normalizes a literal runs/../nodes/... before routing.
127
+ if (pathname.startsWith('/v1/ops/release/nodes/')) return jsonResponse({ error: 'invalid-id' }, 400);
128
+ if (pathname.startsWith('/v1/ops/release/runs/')) {
129
+ const match = LOG_PATH.exec(pathname);
130
+ if (!match) return jsonResponse({ error: 'invalid-id' }, 400);
131
+ const [, id, nodeId] = match;
132
+ if (!IDENTIFIER.test(id!) || !IDENTIFIER.test(nodeId!)) return jsonResponse({ error: 'invalid-id' }, 400);
133
+ const run = recordAt(join(effectiveInstanceRoot(), 'graph-runs', 'release-loop', `${id}.json`));
134
+ const node = run?.nodes.find((candidate) => candidate?.nodeId === nodeId);
135
+ if (!node || node.output === undefined || node.output === null) return jsonResponse({ error: 'not-found' }, 404);
136
+ return served(jsonResponse({ log: redactSecretText(outputText(node.output)).slice(-4_000) }));
137
+ }
138
+ return jsonResponse({ error: 'not-found' }, 404);
139
+ }