@loompad/cli 0.1.0 → 1.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 (687) hide show
  1. package/ARCHITECTURE.md +96 -11
  2. package/CHANGELOG.md +1042 -0
  3. package/README.md +1473 -44
  4. package/THIRD_PARTY_NOTICES.md +275 -0
  5. package/dist/adapters/antigravity-cli.d.ts +62 -0
  6. package/dist/adapters/antigravity-cli.js +258 -0
  7. package/dist/adapters/antigravity-cli.js.map +1 -0
  8. package/dist/adapters/base.d.ts +61 -9
  9. package/dist/adapters/base.js +266 -15
  10. package/dist/adapters/base.js.map +1 -1
  11. package/dist/adapters/bridges/antigravity.d.ts +9 -12
  12. package/dist/adapters/bridges/antigravity.js +9 -12
  13. package/dist/adapters/bridges/antigravity.js.map +1 -1
  14. package/dist/adapters/echo.d.ts +1 -0
  15. package/dist/adapters/echo.js +59 -3
  16. package/dist/adapters/echo.js.map +1 -1
  17. package/dist/adapters/grok.d.ts +7 -0
  18. package/dist/adapters/grok.js +20 -10
  19. package/dist/adapters/grok.js.map +1 -1
  20. package/dist/adapters/index.d.ts +5 -1
  21. package/dist/adapters/index.js +53 -7
  22. package/dist/adapters/index.js.map +1 -1
  23. package/dist/adapters/model.d.ts +137 -0
  24. package/dist/adapters/model.js +527 -0
  25. package/dist/adapters/model.js.map +1 -0
  26. package/dist/adapters/opencode.d.ts +101 -1
  27. package/dist/adapters/opencode.js +874 -98
  28. package/dist/adapters/opencode.js.map +1 -1
  29. package/dist/cli/doctor.d.ts +32 -0
  30. package/dist/cli/doctor.js +89 -4
  31. package/dist/cli/doctor.js.map +1 -1
  32. package/dist/cli/index.js +2651 -87
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/tui-model.js +3 -1
  35. package/dist/cli/tui-model.js.map +1 -1
  36. package/dist/cli/tui.js +44 -1
  37. package/dist/cli/tui.js.map +1 -1
  38. package/dist/cli/ui.d.ts +2 -0
  39. package/dist/cli/ui.js +13 -0
  40. package/dist/cli/ui.js.map +1 -1
  41. package/dist/core/ades.d.ts +32 -16
  42. package/dist/core/ades.js +57 -20
  43. package/dist/core/ades.js.map +1 -1
  44. package/dist/core/agent-state.d.ts +14 -0
  45. package/dist/core/agent-state.js +32 -0
  46. package/dist/core/agent-state.js.map +1 -0
  47. package/dist/core/approvals.d.ts +33 -0
  48. package/dist/core/approvals.js +29 -0
  49. package/dist/core/approvals.js.map +1 -0
  50. package/dist/core/backup.d.ts +19 -0
  51. package/dist/core/backup.js +86 -0
  52. package/dist/core/backup.js.map +1 -0
  53. package/dist/core/baton.d.ts +2 -2
  54. package/dist/core/baton.js.map +1 -1
  55. package/dist/core/brain-extract.d.ts +17 -0
  56. package/dist/core/brain-extract.js +28 -2
  57. package/dist/core/brain-extract.js.map +1 -1
  58. package/dist/core/brain-index.d.ts +71 -0
  59. package/dist/core/brain-index.js +204 -5
  60. package/dist/core/brain-index.js.map +1 -1
  61. package/dist/core/brain.d.ts +58 -2
  62. package/dist/core/brain.js +79 -1
  63. package/dist/core/brain.js.map +1 -1
  64. package/dist/core/chat-import.d.ts +71 -0
  65. package/dist/core/chat-import.js +419 -0
  66. package/dist/core/chat-import.js.map +1 -0
  67. package/dist/core/checkpoint.d.ts +107 -0
  68. package/dist/core/checkpoint.js +259 -0
  69. package/dist/core/checkpoint.js.map +1 -0
  70. package/dist/core/claude-cli.js +1 -1
  71. package/dist/core/claude-cli.js.map +1 -1
  72. package/dist/core/continuity/artifacts.d.ts +14 -0
  73. package/dist/core/continuity/artifacts.js +118 -0
  74. package/dist/core/continuity/artifacts.js.map +1 -0
  75. package/dist/core/continuity/capabilities.d.ts +65 -0
  76. package/dist/core/continuity/capabilities.js +129 -0
  77. package/dist/core/continuity/capabilities.js.map +1 -0
  78. package/dist/core/continuity/contracts.d.ts +352 -0
  79. package/dist/core/continuity/contracts.js +117 -0
  80. package/dist/core/continuity/contracts.js.map +1 -0
  81. package/dist/core/continuity/engine.d.ts +55 -0
  82. package/dist/core/continuity/engine.js +504 -0
  83. package/dist/core/continuity/engine.js.map +1 -0
  84. package/dist/core/continuity/store.d.ts +73 -0
  85. package/dist/core/continuity/store.js +438 -0
  86. package/dist/core/continuity/store.js.map +1 -0
  87. package/dist/core/conversations.d.ts +19 -0
  88. package/dist/core/conversations.js +87 -0
  89. package/dist/core/conversations.js.map +1 -0
  90. package/dist/core/crew-coordination.d.ts +6 -0
  91. package/dist/core/crew-coordination.js +24 -0
  92. package/dist/core/crew-coordination.js.map +1 -0
  93. package/dist/core/crew-policy.d.ts +18 -0
  94. package/dist/core/crew-policy.js +40 -0
  95. package/dist/core/crew-policy.js.map +1 -0
  96. package/dist/core/crew-protocol.d.ts +64 -0
  97. package/dist/core/crew-protocol.js +146 -0
  98. package/dist/core/crew-protocol.js.map +1 -0
  99. package/dist/core/crew-requests.d.ts +14 -0
  100. package/dist/core/crew-requests.js +22 -0
  101. package/dist/core/crew-requests.js.map +1 -0
  102. package/dist/core/crew.d.ts +395 -0
  103. package/dist/core/crew.js +1857 -0
  104. package/dist/core/crew.js.map +1 -0
  105. package/dist/core/digest.d.ts +40 -0
  106. package/dist/core/digest.js +94 -0
  107. package/dist/core/digest.js.map +1 -0
  108. package/dist/core/distill.js +3 -5
  109. package/dist/core/distill.js.map +1 -1
  110. package/dist/core/eventlog.d.ts +27 -25
  111. package/dist/core/eventlog.js +97 -150
  112. package/dist/core/eventlog.js.map +1 -1
  113. package/dist/core/events/contracts.d.ts +51 -0
  114. package/dist/core/events/contracts.js +2 -0
  115. package/dist/core/events/contracts.js.map +1 -0
  116. package/dist/core/events/jsonl-store.d.ts +23 -0
  117. package/dist/core/events/jsonl-store.js +90 -0
  118. package/dist/core/events/jsonl-store.js.map +1 -0
  119. package/dist/core/events/sqlite-store.d.ts +37 -0
  120. package/dist/core/events/sqlite-store.js +215 -0
  121. package/dist/core/events/sqlite-store.js.map +1 -0
  122. package/dist/core/forecast.d.ts +44 -0
  123. package/dist/core/forecast.js +46 -0
  124. package/dist/core/forecast.js.map +1 -0
  125. package/dist/core/free-pool.d.ts +65 -0
  126. package/dist/core/free-pool.js +171 -0
  127. package/dist/core/free-pool.js.map +1 -0
  128. package/dist/core/git.d.ts +30 -0
  129. package/dist/core/git.js +50 -2
  130. package/dist/core/git.js.map +1 -1
  131. package/dist/core/github-events.d.ts +53 -0
  132. package/dist/core/github-events.js +227 -0
  133. package/dist/core/github-events.js.map +1 -0
  134. package/dist/core/goal-lanes.d.ts +53 -0
  135. package/dist/core/goal-lanes.js +99 -0
  136. package/dist/core/goal-lanes.js.map +1 -0
  137. package/dist/core/hosted.d.ts +27 -0
  138. package/dist/core/hosted.js +50 -0
  139. package/dist/core/hosted.js.map +1 -0
  140. package/dist/core/http-server.d.ts +5 -0
  141. package/dist/core/http-server.js +8 -0
  142. package/dist/core/http-server.js.map +1 -0
  143. package/dist/core/invite-link.d.ts +31 -0
  144. package/dist/core/invite-link.js +45 -0
  145. package/dist/core/invite-link.js.map +1 -0
  146. package/dist/core/loops.d.ts +37 -0
  147. package/dist/core/loops.js +81 -0
  148. package/dist/core/loops.js.map +1 -0
  149. package/dist/core/mcp-catalog.d.ts +182 -0
  150. package/dist/core/mcp-catalog.js +431 -0
  151. package/dist/core/mcp-catalog.js.map +1 -0
  152. package/dist/core/mcp.d.ts +87 -0
  153. package/dist/core/mcp.js +181 -0
  154. package/dist/core/mcp.js.map +1 -0
  155. package/dist/core/memory.d.ts +1 -0
  156. package/dist/core/memory.js +5 -2
  157. package/dist/core/memory.js.map +1 -1
  158. package/dist/core/model-tools.d.ts +97 -0
  159. package/dist/core/model-tools.js +388 -0
  160. package/dist/core/model-tools.js.map +1 -0
  161. package/dist/core/notify.js +19 -1
  162. package/dist/core/notify.js.map +1 -1
  163. package/dist/core/orchestra.d.ts +635 -0
  164. package/dist/core/orchestra.js +2120 -0
  165. package/dist/core/orchestra.js.map +1 -0
  166. package/dist/core/permissions.d.ts +56 -0
  167. package/dist/core/permissions.js +122 -0
  168. package/dist/core/permissions.js.map +1 -0
  169. package/dist/core/preview-proxy.d.ts +70 -0
  170. package/dist/core/preview-proxy.js +563 -0
  171. package/dist/core/preview-proxy.js.map +1 -0
  172. package/dist/core/preview-shot.d.ts +46 -0
  173. package/dist/core/preview-shot.js +109 -0
  174. package/dist/core/preview-shot.js.map +1 -0
  175. package/dist/core/process.d.ts +36 -0
  176. package/dist/core/process.js +168 -0
  177. package/dist/core/process.js.map +1 -0
  178. package/dist/core/prompt-queue.d.ts +136 -0
  179. package/dist/core/prompt-queue.js +269 -0
  180. package/dist/core/prompt-queue.js.map +1 -0
  181. package/dist/core/prompts.d.ts +53 -0
  182. package/dist/core/prompts.js +127 -0
  183. package/dist/core/prompts.js.map +1 -0
  184. package/dist/core/proof.d.ts +82 -0
  185. package/dist/core/proof.js +216 -0
  186. package/dist/core/proof.js.map +1 -0
  187. package/dist/core/provider-text.d.ts +23 -0
  188. package/dist/core/provider-text.js +66 -0
  189. package/dist/core/provider-text.js.map +1 -0
  190. package/dist/core/providers.d.ts +152 -0
  191. package/dist/core/providers.js +343 -0
  192. package/dist/core/providers.js.map +1 -0
  193. package/dist/core/quota.d.ts +33 -0
  194. package/dist/core/quota.js +74 -0
  195. package/dist/core/quota.js.map +1 -0
  196. package/dist/core/recipes.d.ts +50 -0
  197. package/dist/core/recipes.js +99 -0
  198. package/dist/core/recipes.js.map +1 -0
  199. package/dist/core/registry.d.ts +54 -0
  200. package/dist/core/registry.js +62 -5
  201. package/dist/core/registry.js.map +1 -1
  202. package/dist/core/relay-client.d.ts +67 -0
  203. package/dist/core/relay-client.js +184 -0
  204. package/dist/core/relay-client.js.map +1 -0
  205. package/dist/core/relay-protocol.d.ts +140 -0
  206. package/dist/core/relay-protocol.js +189 -0
  207. package/dist/core/relay-protocol.js.map +1 -0
  208. package/dist/core/replay.d.ts +18 -0
  209. package/dist/core/replay.js +191 -0
  210. package/dist/core/replay.js.map +1 -0
  211. package/dist/core/review-triage.d.ts +41 -0
  212. package/dist/core/review-triage.js +124 -0
  213. package/dist/core/review-triage.js.map +1 -0
  214. package/dist/core/router.js +2 -2
  215. package/dist/core/router.js.map +1 -1
  216. package/dist/core/routes.d.ts +39 -3
  217. package/dist/core/routes.js +137 -4
  218. package/dist/core/routes.js.map +1 -1
  219. package/dist/core/run-command.d.ts +14 -0
  220. package/dist/core/run-command.js +45 -0
  221. package/dist/core/run-command.js.map +1 -0
  222. package/dist/core/runner-setup.d.ts +36 -0
  223. package/dist/core/runner-setup.js +114 -0
  224. package/dist/core/runner-setup.js.map +1 -0
  225. package/dist/core/search.js +0 -0
  226. package/dist/core/search.js.map +1 -1
  227. package/dist/core/semantic.d.ts +92 -0
  228. package/dist/core/semantic.js +227 -0
  229. package/dist/core/semantic.js.map +1 -0
  230. package/dist/core/servers.d.ts +115 -0
  231. package/dist/core/servers.js +305 -0
  232. package/dist/core/servers.js.map +1 -0
  233. package/dist/core/setup.js +64 -6
  234. package/dist/core/setup.js.map +1 -1
  235. package/dist/core/shield.d.ts +17 -0
  236. package/dist/core/shield.js +81 -0
  237. package/dist/core/shield.js.map +1 -0
  238. package/dist/core/shift.d.ts +69 -0
  239. package/dist/core/shift.js +120 -0
  240. package/dist/core/shift.js.map +1 -0
  241. package/dist/core/skill-install.d.ts +125 -0
  242. package/dist/core/skill-install.js +328 -0
  243. package/dist/core/skill-install.js.map +1 -0
  244. package/dist/core/skills.d.ts +116 -0
  245. package/dist/core/skills.js +223 -0
  246. package/dist/core/skills.js.map +1 -0
  247. package/dist/core/step-conditions.d.ts +65 -0
  248. package/dist/core/step-conditions.js +102 -0
  249. package/dist/core/step-conditions.js.map +1 -0
  250. package/dist/core/subagents.d.ts +45 -0
  251. package/dist/core/subagents.js +90 -0
  252. package/dist/core/subagents.js.map +1 -0
  253. package/dist/core/team-canon.d.ts +42 -0
  254. package/dist/core/team-canon.js +121 -0
  255. package/dist/core/team-canon.js.map +1 -0
  256. package/dist/core/team-crypto.d.ts +97 -0
  257. package/dist/core/team-crypto.js +175 -0
  258. package/dist/core/team-crypto.js.map +1 -0
  259. package/dist/core/team-hub.d.ts +514 -0
  260. package/dist/core/team-hub.js +964 -0
  261. package/dist/core/team-hub.js.map +1 -0
  262. package/dist/core/team-landing.d.ts +239 -0
  263. package/dist/core/team-landing.js +504 -0
  264. package/dist/core/team-landing.js.map +1 -0
  265. package/dist/core/team-leases.d.ts +44 -0
  266. package/dist/core/team-leases.js +145 -0
  267. package/dist/core/team-leases.js.map +1 -0
  268. package/dist/core/team-memory.d.ts +42 -0
  269. package/dist/core/team-memory.js +84 -0
  270. package/dist/core/team-memory.js.map +1 -0
  271. package/dist/core/team-policy.d.ts +85 -0
  272. package/dist/core/team-policy.js +191 -0
  273. package/dist/core/team-policy.js.map +1 -0
  274. package/dist/core/thread-md.d.ts +6 -0
  275. package/dist/core/thread-md.js +42 -0
  276. package/dist/core/thread-md.js.map +1 -0
  277. package/dist/core/turn-stats.d.ts +61 -0
  278. package/dist/core/turn-stats.js +124 -0
  279. package/dist/core/turn-stats.js.map +1 -0
  280. package/dist/core/updater.d.ts +73 -0
  281. package/dist/core/updater.js +153 -0
  282. package/dist/core/updater.js.map +1 -0
  283. package/dist/core/usage.d.ts +75 -0
  284. package/dist/core/usage.js +163 -0
  285. package/dist/core/usage.js.map +1 -0
  286. package/dist/core/windows-jobs.d.ts +15 -0
  287. package/dist/core/windows-jobs.js +170 -0
  288. package/dist/core/windows-jobs.js.map +1 -0
  289. package/dist/core/worktree-merge.d.ts +57 -0
  290. package/dist/core/worktree-merge.js +123 -0
  291. package/dist/core/worktree-merge.js.map +1 -0
  292. package/dist/core/worktree.d.ts +5 -0
  293. package/dist/core/worktree.js +45 -5
  294. package/dist/core/worktree.js.map +1 -1
  295. package/dist/core/worktrees.d.ts +6 -0
  296. package/dist/core/worktrees.js +21 -0
  297. package/dist/core/worktrees.js.map +1 -0
  298. package/dist/daemon/app-page.d.ts +0 -17
  299. package/dist/daemon/app-page.js +10 -6346
  300. package/dist/daemon/app-page.js.map +1 -1
  301. package/dist/daemon/auth.d.ts +24 -3
  302. package/dist/daemon/auth.js +66 -7
  303. package/dist/daemon/auth.js.map +1 -1
  304. package/dist/daemon/board.d.ts +7 -0
  305. package/dist/daemon/board.js +12 -1
  306. package/dist/daemon/board.js.map +1 -1
  307. package/dist/daemon/brand-icons.d.ts +6 -1
  308. package/dist/daemon/brand-icons.js +11 -3
  309. package/dist/daemon/brand-icons.js.map +1 -1
  310. package/dist/daemon/client.d.ts +665 -1
  311. package/dist/daemon/client.js +381 -3
  312. package/dist/daemon/client.js.map +1 -1
  313. package/dist/daemon/delivery.d.ts +34 -0
  314. package/dist/daemon/delivery.js +63 -0
  315. package/dist/daemon/delivery.js.map +1 -0
  316. package/dist/daemon/deploys.d.ts +62 -0
  317. package/dist/daemon/deploys.js +159 -0
  318. package/dist/daemon/deploys.js.map +1 -0
  319. package/dist/daemon/landing.d.ts +192 -0
  320. package/dist/daemon/landing.js +992 -0
  321. package/dist/daemon/landing.js.map +1 -0
  322. package/dist/daemon/onboard.d.ts +150 -0
  323. package/dist/daemon/onboard.js +390 -0
  324. package/dist/daemon/onboard.js.map +1 -0
  325. package/dist/daemon/proof.d.ts +70 -0
  326. package/dist/daemon/proof.js +172 -0
  327. package/dist/daemon/proof.js.map +1 -0
  328. package/dist/daemon/push.d.ts +19 -1
  329. package/dist/daemon/push.js +90 -3
  330. package/dist/daemon/push.js.map +1 -1
  331. package/dist/daemon/relay.d.ts +82 -0
  332. package/dist/daemon/relay.js +351 -0
  333. package/dist/daemon/relay.js.map +1 -0
  334. package/dist/daemon/routes/agent-discovery.d.ts +4 -0
  335. package/dist/daemon/routes/agent-discovery.js +29 -0
  336. package/dist/daemon/routes/agent-discovery.js.map +1 -0
  337. package/dist/daemon/routes/agent-settings.d.ts +4 -0
  338. package/dist/daemon/routes/agent-settings.js +94 -0
  339. package/dist/daemon/routes/agent-settings.js.map +1 -0
  340. package/dist/daemon/routes/agents.d.ts +4 -0
  341. package/dist/daemon/routes/agents.js +105 -0
  342. package/dist/daemon/routes/agents.js.map +1 -0
  343. package/dist/daemon/routes/alerts.d.ts +4 -0
  344. package/dist/daemon/routes/alerts.js +137 -0
  345. package/dist/daemon/routes/alerts.js.map +1 -0
  346. package/dist/daemon/routes/approvals.d.ts +4 -0
  347. package/dist/daemon/routes/approvals.js +40 -0
  348. package/dist/daemon/routes/approvals.js.map +1 -0
  349. package/dist/daemon/routes/assets.d.ts +3 -0
  350. package/dist/daemon/routes/assets.js +64 -0
  351. package/dist/daemon/routes/assets.js.map +1 -0
  352. package/dist/daemon/routes/board.d.ts +4 -0
  353. package/dist/daemon/routes/board.js +147 -0
  354. package/dist/daemon/routes/board.js.map +1 -0
  355. package/dist/daemon/routes/brain.d.ts +4 -0
  356. package/dist/daemon/routes/brain.js +270 -0
  357. package/dist/daemon/routes/brain.js.map +1 -0
  358. package/dist/daemon/routes/chats.d.ts +4 -0
  359. package/dist/daemon/routes/chats.js +249 -0
  360. package/dist/daemon/routes/chats.js.map +1 -0
  361. package/dist/daemon/routes/clients.d.ts +4 -0
  362. package/dist/daemon/routes/clients.js +49 -0
  363. package/dist/daemon/routes/clients.js.map +1 -0
  364. package/dist/daemon/routes/cloud.d.ts +4 -0
  365. package/dist/daemon/routes/cloud.js +46 -0
  366. package/dist/daemon/routes/cloud.js.map +1 -0
  367. package/dist/daemon/routes/config.d.ts +4 -0
  368. package/dist/daemon/routes/config.js +35 -0
  369. package/dist/daemon/routes/config.js.map +1 -0
  370. package/dist/daemon/routes/context.d.ts +51 -0
  371. package/dist/daemon/routes/context.js +2 -0
  372. package/dist/daemon/routes/context.js.map +1 -0
  373. package/dist/daemon/routes/crews.d.ts +4 -0
  374. package/dist/daemon/routes/crews.js +149 -0
  375. package/dist/daemon/routes/crews.js.map +1 -0
  376. package/dist/daemon/routes/files.d.ts +3 -0
  377. package/dist/daemon/routes/files.js +253 -0
  378. package/dist/daemon/routes/files.js.map +1 -0
  379. package/dist/daemon/routes/git.d.ts +4 -0
  380. package/dist/daemon/routes/git.js +96 -0
  381. package/dist/daemon/routes/git.js.map +1 -0
  382. package/dist/daemon/routes/history.d.ts +4 -0
  383. package/dist/daemon/routes/history.js +101 -0
  384. package/dist/daemon/routes/history.js.map +1 -0
  385. package/dist/daemon/routes/integrations-project.d.ts +3 -0
  386. package/dist/daemon/routes/integrations-project.js +103 -0
  387. package/dist/daemon/routes/integrations-project.js.map +1 -0
  388. package/dist/daemon/routes/integrations.d.ts +3 -0
  389. package/dist/daemon/routes/integrations.js +111 -0
  390. package/dist/daemon/routes/integrations.js.map +1 -0
  391. package/dist/daemon/routes/logs.d.ts +3 -0
  392. package/dist/daemon/routes/logs.js +40 -0
  393. package/dist/daemon/routes/logs.js.map +1 -0
  394. package/dist/daemon/routes/messages.d.ts +4 -0
  395. package/dist/daemon/routes/messages.js +60 -0
  396. package/dist/daemon/routes/messages.js.map +1 -0
  397. package/dist/daemon/routes/observability.d.ts +4 -0
  398. package/dist/daemon/routes/observability.js +247 -0
  399. package/dist/daemon/routes/observability.js.map +1 -0
  400. package/dist/daemon/routes/onboard.d.ts +12 -0
  401. package/dist/daemon/routes/onboard.js +67 -0
  402. package/dist/daemon/routes/onboard.js.map +1 -0
  403. package/dist/daemon/routes/orchestra.d.ts +4 -0
  404. package/dist/daemon/routes/orchestra.js +110 -0
  405. package/dist/daemon/routes/orchestra.js.map +1 -0
  406. package/dist/daemon/routes/pairing.d.ts +4 -0
  407. package/dist/daemon/routes/pairing.js +155 -0
  408. package/dist/daemon/routes/pairing.js.map +1 -0
  409. package/dist/daemon/routes/preview.d.ts +24 -0
  410. package/dist/daemon/routes/preview.js +122 -0
  411. package/dist/daemon/routes/preview.js.map +1 -0
  412. package/dist/daemon/routes/project-actions.d.ts +4 -0
  413. package/dist/daemon/routes/project-actions.js +53 -0
  414. package/dist/daemon/routes/project-actions.js.map +1 -0
  415. package/dist/daemon/routes/project-team.d.ts +4 -0
  416. package/dist/daemon/routes/project-team.js +201 -0
  417. package/dist/daemon/routes/project-team.js.map +1 -0
  418. package/dist/daemon/routes/projects.d.ts +14 -0
  419. package/dist/daemon/routes/projects.js +138 -0
  420. package/dist/daemon/routes/projects.js.map +1 -0
  421. package/dist/daemon/routes/prompts.d.ts +4 -0
  422. package/dist/daemon/routes/prompts.js +44 -0
  423. package/dist/daemon/routes/prompts.js.map +1 -0
  424. package/dist/daemon/routes/proof.d.ts +4 -0
  425. package/dist/daemon/routes/proof.js +22 -0
  426. package/dist/daemon/routes/proof.js.map +1 -0
  427. package/dist/daemon/routes/providers.d.ts +3 -0
  428. package/dist/daemon/routes/providers.js +60 -0
  429. package/dist/daemon/routes/providers.js.map +1 -0
  430. package/dist/daemon/routes/public.d.ts +4 -0
  431. package/dist/daemon/routes/public.js +64 -0
  432. package/dist/daemon/routes/public.js.map +1 -0
  433. package/dist/daemon/routes/queue.d.ts +4 -0
  434. package/dist/daemon/routes/queue.js +140 -0
  435. package/dist/daemon/routes/queue.js.map +1 -0
  436. package/dist/daemon/routes/routing.d.ts +4 -0
  437. package/dist/daemon/routes/routing.js +61 -0
  438. package/dist/daemon/routes/routing.js.map +1 -0
  439. package/dist/daemon/routes/runtime-handler.d.ts +2 -0
  440. package/dist/daemon/routes/runtime-handler.js +63 -0
  441. package/dist/daemon/routes/runtime-handler.js.map +1 -0
  442. package/dist/daemon/routes/search.d.ts +4 -0
  443. package/dist/daemon/routes/search.js +17 -0
  444. package/dist/daemon/routes/search.js.map +1 -0
  445. package/dist/daemon/routes/servers.d.ts +4 -0
  446. package/dist/daemon/routes/servers.js +133 -0
  447. package/dist/daemon/routes/servers.js.map +1 -0
  448. package/dist/daemon/routes/setup.d.ts +3 -0
  449. package/dist/daemon/routes/setup.js +50 -0
  450. package/dist/daemon/routes/setup.js.map +1 -0
  451. package/dist/daemon/routes/shift.d.ts +4 -0
  452. package/dist/daemon/routes/shift.js +30 -0
  453. package/dist/daemon/routes/shift.js.map +1 -0
  454. package/dist/daemon/routes/specs.d.ts +4 -0
  455. package/dist/daemon/routes/specs.js +57 -0
  456. package/dist/daemon/routes/specs.js.map +1 -0
  457. package/dist/daemon/routes/task-delivery.d.ts +4 -0
  458. package/dist/daemon/routes/task-delivery.js +38 -0
  459. package/dist/daemon/routes/task-delivery.js.map +1 -0
  460. package/dist/daemon/routes/team.d.ts +4 -0
  461. package/dist/daemon/routes/team.js +193 -0
  462. package/dist/daemon/routes/team.js.map +1 -0
  463. package/dist/daemon/routes/terminals.d.ts +4 -0
  464. package/dist/daemon/routes/terminals.js +87 -0
  465. package/dist/daemon/routes/terminals.js.map +1 -0
  466. package/dist/daemon/routes/tools.d.ts +4 -0
  467. package/dist/daemon/routes/tools.js +180 -0
  468. package/dist/daemon/routes/tools.js.map +1 -0
  469. package/dist/daemon/routes/updates.d.ts +4 -0
  470. package/dist/daemon/routes/updates.js +89 -0
  471. package/dist/daemon/routes/updates.js.map +1 -0
  472. package/dist/daemon/routes/voice.d.ts +4 -0
  473. package/dist/daemon/routes/voice.js +53 -0
  474. package/dist/daemon/routes/voice.js.map +1 -0
  475. package/dist/daemon/runner.d.ts +186 -0
  476. package/dist/daemon/runner.js +612 -0
  477. package/dist/daemon/runner.js.map +1 -0
  478. package/dist/daemon/runtime/accounting.d.ts +130 -0
  479. package/dist/daemon/runtime/accounting.js +303 -0
  480. package/dist/daemon/runtime/accounting.js.map +1 -0
  481. package/dist/daemon/runtime/agents.d.ts +16 -0
  482. package/dist/daemon/runtime/agents.js +71 -0
  483. package/dist/daemon/runtime/agents.js.map +1 -0
  484. package/dist/daemon/runtime/briefings.d.ts +134 -0
  485. package/dist/daemon/runtime/briefings.js +329 -0
  486. package/dist/daemon/runtime/briefings.js.map +1 -0
  487. package/dist/daemon/runtime/native-usage.d.ts +21 -0
  488. package/dist/daemon/runtime/native-usage.js +71 -0
  489. package/dist/daemon/runtime/native-usage.js.map +1 -0
  490. package/dist/daemon/runtime/queue.d.ts +94 -0
  491. package/dist/daemon/runtime/queue.js +222 -0
  492. package/dist/daemon/runtime/queue.js.map +1 -0
  493. package/dist/daemon/runtime/relay.d.ts +93 -0
  494. package/dist/daemon/runtime/relay.js +240 -0
  495. package/dist/daemon/runtime/relay.js.map +1 -0
  496. package/dist/daemon/runtime/turns.d.ts +202 -0
  497. package/dist/daemon/runtime/turns.js +663 -0
  498. package/dist/daemon/runtime/turns.js.map +1 -0
  499. package/dist/daemon/runtime-support.d.ts +76 -0
  500. package/dist/daemon/runtime-support.js +134 -0
  501. package/dist/daemon/runtime-support.js.map +1 -0
  502. package/dist/daemon/runtime.d.ts +739 -70
  503. package/dist/daemon/runtime.js +2751 -470
  504. package/dist/daemon/runtime.js.map +1 -1
  505. package/dist/daemon/server.d.ts +82 -55
  506. package/dist/daemon/server.js +674 -1435
  507. package/dist/daemon/server.js.map +1 -1
  508. package/dist/daemon/shift.d.ts +70 -0
  509. package/dist/daemon/shift.js +170 -0
  510. package/dist/daemon/shift.js.map +1 -0
  511. package/dist/daemon/specs.d.ts +92 -0
  512. package/dist/daemon/specs.js +285 -0
  513. package/dist/daemon/specs.js.map +1 -0
  514. package/dist/daemon/system.d.ts +208 -0
  515. package/dist/daemon/system.js +557 -0
  516. package/dist/daemon/system.js.map +1 -0
  517. package/dist/daemon/tasks.js +1 -1
  518. package/dist/daemon/team-brain.d.ts +136 -0
  519. package/dist/daemon/team-brain.js +659 -0
  520. package/dist/daemon/team-brain.js.map +1 -0
  521. package/dist/daemon/team-coordinator.d.ts +78 -0
  522. package/dist/daemon/team-coordinator.js +408 -0
  523. package/dist/daemon/team-coordinator.js.map +1 -0
  524. package/dist/daemon/team.d.ts +339 -0
  525. package/dist/daemon/team.js +1433 -0
  526. package/dist/daemon/team.js.map +1 -0
  527. package/dist/daemon/terminals.d.ts +22 -3
  528. package/dist/daemon/terminals.js +236 -16
  529. package/dist/daemon/terminals.js.map +1 -1
  530. package/dist/hub/client.d.ts +120 -0
  531. package/dist/hub/client.js +228 -0
  532. package/dist/hub/client.js.map +1 -0
  533. package/dist/hub/server.d.ts +32 -0
  534. package/dist/hub/server.js +180 -0
  535. package/dist/hub/server.js.map +1 -0
  536. package/dist/hub/supabase-client.d.ts +220 -0
  537. package/dist/hub/supabase-client.js +843 -0
  538. package/dist/hub/supabase-client.js.map +1 -0
  539. package/dist/observability/ask.d.ts +91 -0
  540. package/dist/observability/ask.js +179 -0
  541. package/dist/observability/ask.js.map +1 -0
  542. package/dist/observability/clickhouse.d.ts +33 -0
  543. package/dist/observability/clickhouse.js +48 -0
  544. package/dist/observability/clickhouse.js.map +1 -0
  545. package/dist/observability/decisions.d.ts +176 -0
  546. package/dist/observability/decisions.js +377 -0
  547. package/dist/observability/decisions.js.map +1 -0
  548. package/dist/observability/index.d.ts +89 -0
  549. package/dist/observability/index.js +348 -0
  550. package/dist/observability/index.js.map +1 -0
  551. package/dist/observability/insights.d.ts +151 -0
  552. package/dist/observability/insights.js +319 -0
  553. package/dist/observability/insights.js.map +1 -0
  554. package/dist/observability/logs-query.d.ts +82 -0
  555. package/dist/observability/logs-query.js +102 -0
  556. package/dist/observability/logs-query.js.map +1 -0
  557. package/dist/observability/logs.d.ts +72 -0
  558. package/dist/observability/logs.js +295 -0
  559. package/dist/observability/logs.js.map +1 -0
  560. package/dist/observability/metrics.d.ts +86 -0
  561. package/dist/observability/metrics.js +303 -0
  562. package/dist/observability/metrics.js.map +1 -0
  563. package/dist/observability/otlp.d.ts +127 -0
  564. package/dist/observability/otlp.js +242 -0
  565. package/dist/observability/otlp.js.map +1 -0
  566. package/dist/observability/snapshots.d.ts +43 -0
  567. package/dist/observability/snapshots.js +129 -0
  568. package/dist/observability/snapshots.js.map +1 -0
  569. package/dist/observability/triage.d.ts +49 -0
  570. package/dist/observability/triage.js +284 -0
  571. package/dist/observability/triage.js.map +1 -0
  572. package/dist/providers/adapter.d.ts +50 -0
  573. package/dist/providers/adapter.js +38 -0
  574. package/dist/providers/adapter.js.map +1 -0
  575. package/dist/providers/agent.d.ts +110 -0
  576. package/dist/providers/agent.js +487 -0
  577. package/dist/providers/agent.js.map +1 -0
  578. package/dist/providers/approvals.d.ts +24 -0
  579. package/dist/providers/approvals.js +78 -0
  580. package/dist/providers/approvals.js.map +1 -0
  581. package/dist/providers/attachments.d.ts +20 -0
  582. package/dist/providers/attachments.js +51 -0
  583. package/dist/providers/attachments.js.map +1 -0
  584. package/dist/providers/claude/adapter.d.ts +99 -0
  585. package/dist/providers/claude/adapter.js +790 -0
  586. package/dist/providers/claude/adapter.js.map +1 -0
  587. package/dist/providers/codex/adapter.d.ts +113 -0
  588. package/dist/providers/codex/adapter.js +881 -0
  589. package/dist/providers/codex/adapter.js.map +1 -0
  590. package/dist/providers/codex/instructions.d.ts +11 -0
  591. package/dist/providers/codex/instructions.js +154 -0
  592. package/dist/providers/codex/instructions.js.map +1 -0
  593. package/dist/providers/codex/rpc.d.ts +36 -0
  594. package/dist/providers/codex/rpc.js +104 -0
  595. package/dist/providers/codex/rpc.js.map +1 -0
  596. package/dist/providers/contracts.d.ts +339 -0
  597. package/dist/providers/contracts.js +19 -0
  598. package/dist/providers/contracts.js.map +1 -0
  599. package/dist/providers/directory.d.ts +70 -0
  600. package/dist/providers/directory.js +95 -0
  601. package/dist/providers/directory.js.map +1 -0
  602. package/dist/providers/errors.d.ts +47 -0
  603. package/dist/providers/errors.js +24 -0
  604. package/dist/providers/errors.js.map +1 -0
  605. package/dist/providers/index.d.ts +18 -0
  606. package/dist/providers/index.js +19 -0
  607. package/dist/providers/index.js.map +1 -0
  608. package/dist/providers/ingestion.d.ts +80 -0
  609. package/dist/providers/ingestion.js +330 -0
  610. package/dist/providers/ingestion.js.map +1 -0
  611. package/dist/providers/live.d.ts +22 -0
  612. package/dist/providers/live.js +56 -0
  613. package/dist/providers/live.js.map +1 -0
  614. package/dist/providers/process.d.ts +29 -0
  615. package/dist/providers/process.js +71 -0
  616. package/dist/providers/process.js.map +1 -0
  617. package/dist/providers/reaper.d.ts +33 -0
  618. package/dist/providers/reaper.js +65 -0
  619. package/dist/providers/reaper.js.map +1 -0
  620. package/dist/providers/service.d.ts +87 -0
  621. package/dist/providers/service.js +256 -0
  622. package/dist/providers/service.js.map +1 -0
  623. package/dist/sdk.d.ts +1 -1
  624. package/dist/sdk.js +1 -1
  625. package/dist/sdk.js.map +1 -1
  626. package/dist/types.d.ts +385 -6
  627. package/dist/types.js +1 -0
  628. package/dist/types.js.map +1 -1
  629. package/dist/version.d.ts +7 -0
  630. package/dist/version.js +16 -0
  631. package/dist/version.js.map +1 -0
  632. package/dist/web/app.css +4643 -0
  633. package/dist/web/app.js +22640 -0
  634. package/dist/web/app.js.map +7 -0
  635. package/dist/web/shell.html +39 -0
  636. package/docs/README.md +21 -0
  637. package/docs/SETUP.md +10 -0
  638. package/docs/adapters.md +21 -8
  639. package/docs/brain-continuity.md +186 -0
  640. package/docs/cloud.md +108 -0
  641. package/docs/crews.md +62 -0
  642. package/docs/design-system.md +31 -2
  643. package/docs/integration-notes.md +128 -20
  644. package/docs/loompad.md +105 -0
  645. package/docs/observability.md +220 -0
  646. package/docs/play-store.md +123 -0
  647. package/docs/proposals/agent-teams.md +249 -0
  648. package/docs/proposals/brain-continuity-implementation.md +468 -0
  649. package/docs/proposals/brain-session-continuity.md +623 -0
  650. package/docs/proposals/t3code-port.md +81 -0
  651. package/docs/proposals/t3code-reference.md +95 -0
  652. package/docs/refactoring/AGENT-TEAMS-PROGRESS.md +88 -0
  653. package/docs/refactoring/BOUNDARIES.md +120 -0
  654. package/docs/refactoring/BRAIN-AUDIT.md +176 -0
  655. package/docs/refactoring/BRAIN-NOTES.md +390 -0
  656. package/docs/refactoring/BRAIN-TODO.md +214 -0
  657. package/docs/refactoring/NOTES.md +129 -0
  658. package/docs/refactoring/T3-PORT-NOTES.md +165 -0
  659. package/docs/refactoring/T3-PORT-TODO.md +123 -0
  660. package/docs/refactoring/TODO.md +46 -0
  661. package/docs/refactoring/brain-benchmark-2026-09-28.json +48 -0
  662. package/docs/refactoring/brain-benchmark-audit-2026-09-28.json +48 -0
  663. package/docs/release-notes/v0.2.0.md +35 -0
  664. package/docs/release-notes/v0.2.1.md +59 -0
  665. package/docs/release-notes/v0.2.3.md +77 -0
  666. package/docs/release-notes/v0.2.4.md +128 -0
  667. package/docs/release-notes/v0.2.5.md +153 -0
  668. package/docs/release-notes/v0.2.6.md +62 -0
  669. package/docs/release-notes/v1.0.0.md +120 -0
  670. package/docs/release-notes/v1.0.1.md +77 -0
  671. package/docs/release-notes/v1.1.0.md +71 -0
  672. package/docs/release-notes/v1.1.1.md +38 -0
  673. package/docs/teams-architecture.md +697 -0
  674. package/docs/teams.md +171 -0
  675. package/docs/windows.md +85 -0
  676. package/package.json +22 -9
  677. package/dist/adapters/claude-code.d.ts +0 -26
  678. package/dist/adapters/claude-code.js +0 -209
  679. package/dist/adapters/claude-code.js.map +0 -1
  680. package/dist/adapters/codex.d.ts +0 -51
  681. package/dist/adapters/codex.js +0 -291
  682. package/dist/adapters/codex.js.map +0 -1
  683. package/docs/img/board-linear.png +0 -0
  684. package/docs/img/board-projects.png +0 -0
  685. package/docs/img/brain.png +0 -0
  686. package/docs/img/settings.png +0 -0
  687. package/docs/img/workspace.png +0 -0
@@ -1,149 +1,78 @@
1
- /**
2
- * The Loom daemon — one process, many projects, one API for every surface
3
- * (CLI today, iOS app next). REST for commands, WebSocket for the live
4
- * event stream.
5
- */
6
- import { execFile } from "node:child_process";
1
+ import { ClientDelivery } from "./delivery.js";
2
+ import express from "express";
7
3
  import crypto from "node:crypto";
8
4
  import fs from "node:fs";
9
5
  import http from "node:http";
10
- import { createRequire } from "node:module";
6
+ import { configureHttpServer } from "../core/http-server.js";
11
7
  import os from "node:os";
12
8
  import path from "node:path";
13
- import { fileURLToPath } from "node:url";
14
- import express from "express";
15
- import QRCode from "qrcode";
16
- import { WebSocketServer, WebSocket } from "ws";
17
- import { NotHolderError } from "../core/baton.js";
18
- import { retrieve } from "../core/brain-index.js";
19
- import { RouteActiveError } from "../core/routes.js";
20
- import { ADES, buildDefaultRoutes, defaultAgentConfigs, detectAdes } from "../core/ades.js";
9
+ import { WebSocketServer } from "ws";
10
+ import { setApprovalBroker } from "../core/approvals.js";
21
11
  import { logbook } from "../core/logbook.js";
22
- import { searchChats, searchCode } from "../core/search.js";
23
- import { addWorktree as gitAddWorktree, branches as gitBranches, checkout as gitCheckout, commit as gitCommit, discard as gitDiscard, fileDiff as gitFileDiff, GitError, init as gitInit, listWorktrees as gitListWorktrees, log as gitLog, push as gitPush, removeWorktree as gitRemoveWorktree, stage as gitStage, stagedDiff as gitStagedDiff, status as gitStatus, unstage as gitUnstage, } from "../core/git.js";
24
- import { claudeText } from "../core/claude-cli.js";
25
- import { setupReport } from "../core/setup.js";
26
- import { ensureDaemonConfig, findProject, listProjects, readDaemonConfig, readProjectConfig, registerProject, writeDaemonConfig, writeProjectConfig, } from "../core/registry.js";
27
- import { APP_HTML, APP_MANIFEST } from "./app-page.js";
28
- import { GEIST_WOFF2 } from "./geist-font.js";
12
+ import { hostedTarget } from "../core/hosted.js";
13
+ import { ensureDaemonConfig, ensureLoomHome, findProject, listProjects, loomHome, readDaemonConfig, registerProject, unregisterProject, writeDaemonConfig } from "../core/registry.js";
14
+ import { packCredentials, toB64 } from "../core/relay-protocol.js";
15
+ import { CHECK_TTL_MS, latestRelease, newerThan } from "../core/updater.js";
16
+ import { VERSION } from "../version.js";
17
+ import { recordAgentEvent } from "../observability/index.js";
18
+ import { recentAgentErrors } from "../observability/insights.js";
29
19
  import { AuthManager, bearerToken } from "./auth.js";
30
- import { PUSH_KINDS, pushContent, sendExpoPush } from "./push.js";
20
+ import { pushCategory, pushContent, sendExpoPush, wantsPush } from "./push.js";
21
+ import { ensureCredentials, readCloudSettings, RelayBridge, supabaseTarget, supabaseTransport, writeCloudSettings, } from "./relay.js";
22
+ import { registerAgentDiscoveryRoutes } from './routes/agent-discovery.js';
23
+ import { registerAgentSettingsRoutes } from './routes/agent-settings.js';
24
+ import { registerAgentsRoutes } from './routes/agents.js';
25
+ import { registerAlertsRoutes } from './routes/alerts.js';
26
+ import { registerApprovalsRoutes } from './routes/approvals.js';
27
+ import { registerAssetsRoutes } from './routes/assets.js';
28
+ import { registerBoardRoutes } from './routes/board.js';
29
+ import { registerBrainRoutes } from './routes/brain.js';
30
+ import { registerChatsRoutes } from './routes/chats.js';
31
+ import { registerClientsRoutes } from './routes/clients.js';
32
+ import { registerCloudRoutes } from './routes/cloud.js';
33
+ import { registerConfigRoutes } from './routes/config.js';
34
+ import { registerPreviewPublic, registerPreviewRoutes } from "./routes/preview.js";
35
+ import { registerFilesRoutes } from './routes/files.js';
36
+ import { registerGitRoutes } from './routes/git.js';
37
+ import { registerHistoryRoutes } from './routes/history.js';
38
+ import { registerIntegrationsProjectRoutes } from './routes/integrations-project.js';
39
+ import { registerIntegrationsRoutes } from './routes/integrations.js';
40
+ import { registerLogsRoutes } from './routes/logs.js';
41
+ import { registerMessagesRoutes } from './routes/messages.js';
42
+ import { registerObservabilityRoutes } from './routes/observability.js';
43
+ import { registerOrchestraRoutes } from './routes/orchestra.js';
44
+ import { registerPairingRoutes } from './routes/pairing.js';
45
+ import { registerProjectActionsRoutes } from './routes/project-actions.js';
46
+ import { registerProjectTeamRoutes } from './routes/project-team.js';
47
+ import { registerProjectsRoutes } from './routes/projects.js';
48
+ import { registerPromptsRoutes } from './routes/prompts.js';
49
+ import { registerProvidersRoutes } from './routes/providers.js';
50
+ import { registerPublicRoutes } from './routes/public.js';
51
+ import { registerQueueRoutes } from './routes/queue.js';
52
+ import { registerRoutingRoutes } from './routes/routing.js';
53
+ import { createRuntimeHandler } from './routes/runtime-handler.js';
54
+ import { registerSearchRoutes } from './routes/search.js';
55
+ import { registerServersRoutes } from './routes/servers.js';
56
+ import { registerSetupRoutes } from './routes/setup.js';
57
+ import { registerSpecsRoutes } from './routes/specs.js';
58
+ import { registerTaskDeliveryRoutes } from './routes/task-delivery.js';
59
+ import { registerTeamRoutes } from './routes/team.js';
60
+ import { registerTerminalsRoutes } from './routes/terminals.js';
61
+ import { registerToolsRoutes } from './routes/tools.js';
62
+ import { registerUpdatesRoutes } from './routes/updates.js';
63
+ import { registerVoiceRoutes } from './routes/voice.js';
31
64
  import { ProjectRuntime } from "./runtime.js";
32
- import { buildBoard } from "./board.js";
33
- import { ghAuthStatus, ghProjectItems, ghProjects, listTasks, prReview, prView, runGh, } from "./tasks.js";
34
- import { linearCreateIssue, linearTeams, listLinearIssues } from "./linear.js";
35
- import { TerminalManager, TooManySessionsError } from "./terminals.js";
36
- export const DEFAULT_PORT = 7420;
37
- /**
38
- * Fingerprint every built file the daemon can load, as one hash.
39
- *
40
- * The walk is what makes it honest. This used to hash exactly two files —
41
- * server.js and app-page.js — which meant a change anywhere else (an adapter,
42
- * the router, core/registry.ts) left the rev identical. `loom up` said "daemon
43
- * already running", the shell agreed it was current, and a daemon kept serving
44
- * the old code from memory. A correct fix looked like it did nothing, which
45
- * sends you debugging code that is already right.
46
- *
47
- * Content-based on purpose: mtimes are unreliable across runtimes on some
48
- * filesystems (exFAT drives skew them by the local timezone offset). Names are
49
- * hashed alongside contents so a rename or a deletion moves the rev too.
50
- *
51
- * Reading ~39 files (about half a megabyte) costs a couple of milliseconds at
52
- * import, once. A stale daemon costs an afternoon.
53
- *
54
- * The desktop shell has a twin of this in desktop/loom-app.js — it can't import
55
- * this module without pulling express into Electron's main process. They must
56
- * agree byte for byte; test/desktop-app.test.ts compares them against the real
57
- * built output so a drift fails there rather than in the field.
58
- */
59
- export function fingerprintBuild(root) {
60
- const rels = [];
61
- const walk = (dir) => {
62
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
63
- const full = path.join(dir, entry.name);
64
- if (entry.isDirectory())
65
- walk(full);
66
- else if (entry.name.endsWith(".js"))
67
- rels.push(path.relative(root, full));
68
- }
69
- };
70
- walk(root);
71
- if (rels.length === 0)
72
- return null;
73
- rels.sort(); // readdir order is filesystem-dependent; the hash must not be
74
- const hash = crypto.createHash("sha256");
75
- for (const rel of rels) {
76
- hash.update(rel);
77
- hash.update(fs.readFileSync(path.join(root, rel)));
78
- }
79
- return hash.digest("hex").slice(0, 16);
80
- }
81
- /**
82
- * This build's rev. "dev" when there's nothing compiled to hash — running from
83
- * source under tsx, where the tree is .ts and the walk finds no .js at all.
84
- */
85
- export const BUILD_REV = (() => {
86
- try {
87
- // dist/daemon/server.js → dist: everything this process can import.
88
- const me = fileURLToPath(import.meta.url);
89
- return fingerprintBuild(path.dirname(path.dirname(me))) ?? "dev";
90
- }
91
- catch {
92
- return "dev";
93
- }
94
- })();
95
- /**
96
- * Loom's own install root — the directory to ask "am I behind my remote?".
97
- *
98
- * Walks up from this module looking for a .git directory (a source checkout or
99
- * a cloned install). null when Loom was installed some other way (a package),
100
- * in which case "check for updates" honestly says there's no git tree to check.
101
- */
102
- function loomRoot() {
103
- try {
104
- let dir = path.dirname(fileURLToPath(import.meta.url));
105
- for (let i = 0; i < 6; i++) {
106
- if (fs.existsSync(path.join(dir, ".git")))
107
- return dir;
108
- const parent = path.dirname(dir);
109
- if (parent === dir)
110
- break;
111
- dir = parent;
112
- }
113
- }
114
- catch {
115
- /* fall through */
116
- }
117
- return null;
118
- }
119
- const TERM_MARK = "__LOOM_END__";
120
- /**
121
- * Just enough to give a pasted attachment a sensible extension.
122
- */
123
- const MIME_EXT = {
124
- "image/png": "png",
125
- "image/jpeg": "jpg",
126
- "image/gif": "gif",
127
- "image/webp": "webp",
128
- "image/svg+xml": "svg",
129
- "text/markdown": "md",
130
- "text/plain": "txt",
131
- "application/pdf": "pdf",
132
- };
133
- /**
134
- * Paths from an HTTP body, as strings and nothing else.
135
- *
136
- * These reach `git checkout --` and `git clean -fd`, which delete things. A
137
- * body is whatever the caller felt like sending, so anything that isn't a
138
- * string is dropped here rather than stringified into a path somewhere deeper.
139
- * This is the shape check; core/git.ts does the safety check, resolving every
140
- * one of them against the project root.
141
- */
142
- function asPaths(v) {
143
- if (!Array.isArray(v))
144
- return [];
145
- return v.filter((x) => typeof x === "string" && x.length > 0).slice(0, 500);
146
- }
65
+ import { SpecRunner } from "./specs.js";
66
+ import { BUILD_REV, DEFAULT_PORT, tailscaleIp } from './system.js';
67
+ import { TeamLink } from "./team.js";
68
+ import { Onboarding } from "./onboard.js";
69
+ import { addProjectAt } from "./routes/projects.js";
70
+ import { registerCrewsRoutes } from "./routes/crews.js";
71
+ import { registerProofRoutes } from "./routes/proof.js";
72
+ import { registerShiftRoutes } from "./routes/shift.js";
73
+ import { registerOnboardRoutes } from "./routes/onboard.js";
74
+ import { TerminalManager } from "./terminals.js";
75
+ export { BUILD_REV, DEFAULT_PORT, fingerprintBuild, isLoopback, isLoopbackHost, lanIp, listModelsForKind, tailscaleFunnel, tailscaleIp, tailscaleState, tailscaleUp } from './system.js';
147
76
  export class LoomDaemon {
148
77
  app = express();
149
78
  server = null;
@@ -151,6 +80,9 @@ export class LoomDaemon {
151
80
  auth;
152
81
  runtimes = new Map();
153
82
  sockets = new Map();
83
+ delivery = new ClientDelivery(this.sockets, (socket) => socket.terminate());
84
+ /** In-flight self-heal recheck timers, cleared on close. */
85
+ healTimers = new Set();
154
86
  /** Terminal shells — a real pty when node-pty loaded, else plain pipes. */
155
87
  terminals = new TerminalManager({
156
88
  onData: (projectId, term, chunk) => this.broadcastTerm(projectId, { type: "term", term, chunk }),
@@ -160,8 +92,10 @@ export class LoomDaemon {
160
92
  this.broadcastTerm(projectId, { type: "term", term, closed: true });
161
93
  },
162
94
  onTitle: (projectId, term, title) => this.broadcastTerm(projectId, { type: "term", term, title }),
163
- });
95
+ }, 12, path.join(ensureLoomHome(), "scrollback"));
164
96
  unstreamLogs = null;
97
+ /** Playwright runs, one per project at a time. See specs.ts. */
98
+ specRunner = new SpecRunner();
165
99
  host;
166
100
  port;
167
101
  /**
@@ -172,7 +106,100 @@ export class LoomDaemon {
172
106
  * localhost listener ever being disturbed.
173
107
  */
174
108
  extra = new Map();
109
+ /** Tool uses waiting on a human, from agents in "always ask" mode. */
110
+ approvals = new Map();
111
+ /**
112
+ * Put a tool use in front of a person and wait.
113
+ *
114
+ * One implementation for every caller — CLI adapters and model agents all
115
+ * ask from inside this process (core/approvals.ts registers this as the
116
+ * broker). The card, the
117
+ * thread entry, the timeout and the audit trail are the same either way,
118
+ * because "who is asking" should not change what you are shown.
119
+ */
120
+ async askHuman(req) {
121
+ if (req.signal?.aborted)
122
+ return { behavior: "deny", message: "The agent stopped waiting." };
123
+ const rt = await this.runtime(req.project).catch(() => null);
124
+ if (!rt)
125
+ return { behavior: "deny", message: "project not open" };
126
+ const id = crypto.randomBytes(6).toString("hex");
127
+ const tool = req.tool.slice(0, 120);
128
+ const preview = (req.summary ?? JSON.stringify(req.input ?? {})).slice(0, 4000);
129
+ const chat = rt.chatOf(req.agent);
130
+ rt.log.append({
131
+ kind: "approval",
132
+ agentId: req.agent,
133
+ ...(chat ? { chat } : {}),
134
+ payload: { phase: "requested", approvalId: id, tool, input: preview, ...(req.sessionOption ? { sessionOption: true } : {}) },
135
+ });
136
+ return new Promise((resolve) => {
137
+ const abandoned = () => settle({ behavior: "deny", message: "The agent stopped waiting." });
138
+ const settle = (d) => {
139
+ clearTimeout(timer);
140
+ req.signal?.removeEventListener("abort", abandoned);
141
+ if (!this.approvals.delete(id))
142
+ return;
143
+ rt.log.append({
144
+ kind: "approval",
145
+ agentId: req.agent,
146
+ ...(chat ? { chat } : {}),
147
+ payload: { phase: "decided", approvalId: id, tool, behavior: d.behavior, ...(d.scope === "session" ? { scope: "session" } : {}), ...(d.message ? { message: d.message } : {}) },
148
+ });
149
+ resolve(d);
150
+ };
151
+ const timer = setTimeout(() => settle({ behavior: "deny", message: "No answer within 30 minutes — denied." }), 30 * 60_000);
152
+ this.approvals.set(id, {
153
+ id,
154
+ projectId: req.project,
155
+ agent: req.agent,
156
+ tool,
157
+ input: req.input ?? {},
158
+ ...(req.sessionOption ? { sessionOption: true } : {}),
159
+ createdAt: Date.now(),
160
+ settle,
161
+ });
162
+ // An agent whose turn ended has stopped waiting; don't hold the card open.
163
+ req.signal?.addEventListener("abort", abandoned, { once: true });
164
+ });
165
+ }
166
+ /** Loom Cloud relay, when enabled. See daemon/relay.ts. */
167
+ relay = null;
168
+ relayError = null;
169
+ relayTransportFactory;
170
+ /** Loom Teams: this daemon on a Team Hub. See daemon/team.ts. */
171
+ team;
172
+ /** One-link onboarding: joining a team, its repo and its crews from an invite. See daemon/onboard.ts. */
173
+ onboarding;
174
+ gh;
175
175
  constructor(opts = {}) {
176
+ this.team = new TeamLink({
177
+ runtimes: () => [...this.runtimes.values()],
178
+ broadcast: (frame) => {
179
+ this.delivery.publish(frame, { kind: "admin" });
180
+ },
181
+ ...(opts.hubFactory ? { hubFactory: opts.hubFactory } : {}),
182
+ ...(opts.runnerExec ? { runnerExec: opts.runnerExec } : {}),
183
+ ...(opts.gh ? { gh: opts.gh } : {}),
184
+ // Phase 5: a runner opens each goal's fresh clone as its own project, and drops it after.
185
+ openProject: async (dir, name) => this.runtime(registerProject(dir, name).id),
186
+ closeProject: async (rt) => {
187
+ await rt.close().catch(() => { });
188
+ this.runtimes.delete(rt.info.id);
189
+ unregisterProject(rt.info.id);
190
+ },
191
+ });
192
+ this.onboarding = new Onboarding({
193
+ team: this.team,
194
+ projects: () => listProjects(),
195
+ addProject: async (dir, name) => (await addProjectAt(dir, name)).info,
196
+ runtime: (id) => this.runtime(id),
197
+ broadcast: (frame) => this.delivery.publish(frame, { kind: "admin" }),
198
+ ...(opts.gh ? { gh: opts.gh } : {}),
199
+ ...opts.onboard,
200
+ });
201
+ this.gh = opts.gh;
202
+ this.relayTransportFactory = opts.relayTransport;
176
203
  this.host = opts.host ?? "127.0.0.1";
177
204
  this.port = opts.port ?? DEFAULT_PORT;
178
205
  const cfg = ensureDaemonConfig({ host: this.host, port: this.port });
@@ -183,8 +210,55 @@ export class LoomDaemon {
183
210
  // HTTP routes
184
211
  // -------------------------------------------------------------------------
185
212
  routes() {
213
+ const daemon = this;
214
+ const ctx = {
215
+ get terminals() { return daemon.terminals; },
216
+ get auth() { return daemon.auth; },
217
+ askHuman: (...args) => this.askHuman(...args),
218
+ cachedRelease: (...args) => this.cachedRelease(...args),
219
+ get updating() { return daemon.updating; },
220
+ set updating(value) { daemon.updating = value; },
221
+ close: (...args) => this.close(...args),
222
+ exposedIps: (...args) => this.exposedIps(...args),
223
+ get host() { return daemon.host; },
224
+ get port() { return daemon.port; },
225
+ expose: (...args) => this.expose(...args),
226
+ cloudLinkParams: (...args) => this.cloudLinkParams(...args),
227
+ get team() { return daemon.team; },
228
+ get runtimes() { return daemon.runtimes; },
229
+ cloudStatus: (...args) => this.cloudStatus(...args),
230
+ startCloud: (...args) => this.startCloud(...args),
231
+ stopCloud: (...args) => this.stopCloud(...args),
232
+ pushTokens: (...args) => this.pushTokens(...args),
233
+ runtime: (...args) => this.runtime(...args),
234
+ get specRunner() { return daemon.specRunner; },
235
+ startHealLoop: (...args) => this.startHealLoop(...args),
236
+ broadcastTerm: (...args) => this.broadcastTerm(...args),
237
+ get approvals() { return daemon.approvals; },
238
+ };
186
239
  const app = this.app;
187
- app.use(express.json({ limit: "2mb" }));
240
+ app.disable("x-powered-by");
241
+ // Headers every response carries. No full CSP: the app shell is one
242
+ // inline document by design. What it does get: no MIME sniffing, no
243
+ // referrer leaking a token-bearing URL to a site you click through to,
244
+ // and only Loom itself may frame the app (the Browser tab's previews
245
+ // are same-origin, so they keep working).
246
+ app.use((req, res, next) => {
247
+ res.setHeader("X-Content-Type-Options", "nosniff");
248
+ res.setHeader("Referrer-Policy", "no-referrer");
249
+ res.setHeader("Permissions-Policy", "geolocation=(), payment=(), usb=(), serial=(), bluetooth=()");
250
+ if (req.path === "/app" || req.path === "/") {
251
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
252
+ res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
253
+ }
254
+ next();
255
+ });
256
+ // 2 MB for every JSON body but an attachment: a 12 MB file is ~16 MB as
257
+ // base64, and the global cap used to refuse a retina screenshot at 413
258
+ // before the route's own 12 MB check ever ran.
259
+ const smallJson = express.json({ limit: "2mb" });
260
+ const attachmentJson = express.json({ limit: "17mb" });
261
+ app.use((req, res, next) => (/^\/api\/projects\/[^/]+\/attachments$/.test(req.path) ? attachmentJson : smallJson)(req, res, next));
188
262
  // CORS for same-machine browser origins only (the Expo web dev server running
189
263
  // on another localhost port, etc.). Scoped to loopback so it can't be abused
190
264
  // cross-site; the bearer wall below still guards every data route. Preflight
@@ -194,1221 +268,141 @@ export class LoomDaemon {
194
268
  if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
195
269
  res.setHeader("Access-Control-Allow-Origin", origin);
196
270
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
197
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
271
+ // PUT and PATCH belong here: toggling a skill, switching an agent on or
272
+ // off, and updating an MCP server all use them, and a cross-origin
273
+ // client (the Expo web build, a paired browser on another port) had
274
+ // those requests refused at the preflight while the same-origin console
275
+ // worked — which makes it look like the feature is broken only on
276
+ // mobile.
277
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
198
278
  res.setHeader("Vary", "Origin");
199
279
  if (req.method === "OPTIONS")
200
280
  return void res.sendStatus(204);
201
281
  }
202
282
  next();
203
283
  });
204
- // Public: the phone app shell (its API calls are bearer-authed),
205
- // health, and the pairing claim (the pairing token IS the auth).
206
- app.get("/", (_req, res) => res.redirect("/app"));
207
- app.get("/app", (_req, res) => {
208
- // Never cache the shell: a redeployed daemon must serve its own UI.
209
- res.type("html").setHeader("Cache-Control", "no-store").send(APP_HTML);
210
- });
211
- app.get("/app/manifest.webmanifest", (_req, res) => {
212
- res
213
- .type("application/manifest+json")
214
- .setHeader("Cache-Control", "no-store")
215
- .send(JSON.stringify(APP_MANIFEST));
216
- });
217
- // The UI sans (Geist, SIL OFL 1.1) — embedded so the app works offline
218
- // on the tailnet with no CDN. Immutable: cache hard.
219
- app.get("/app/fonts/geist.woff2", (_req, res) => {
220
- res
221
- .type("font/woff2")
222
- .setHeader("Cache-Control", "public, max-age=31536000, immutable")
223
- .send(GEIST_WOFF2);
224
- });
225
- // xterm.js and its addons, served straight from node_modules — the app has
226
- // no build step and must work offline on a tailnet, so no bundler, no CDN.
227
- // These are plain UMD files the browser loads with <script>.
228
- const vendor = {
229
- "xterm.js": ["@xterm/xterm/lib/xterm.js", "application/javascript"],
230
- "xterm.css": ["@xterm/xterm/css/xterm.css", "text/css"],
231
- "addon-fit.js": ["@xterm/addon-fit/lib/addon-fit.js", "application/javascript"],
232
- "addon-web-links.js": [
233
- "@xterm/addon-web-links/lib/addon-web-links.js",
234
- "application/javascript",
235
- ],
236
- };
237
- app.get("/app/vendor/:file", (req, res) => {
238
- const entry = vendor[String(req.params.file)];
239
- if (!entry)
240
- return void res.status(404).end();
241
- try {
242
- res
243
- .type(entry[1])
244
- .setHeader("Cache-Control", "public, max-age=31536000, immutable")
245
- .send(fs.readFileSync(createRequire(import.meta.url).resolve(entry[0])));
246
- }
247
- catch {
248
- res.status(404).end();
249
- }
250
- });
251
- app.get("/api/health", (_req, res) => {
252
- res.json({
253
- ok: true,
254
- name: "loom",
255
- version: "0.1.0",
256
- rev: BUILD_REV,
257
- terminal: this.terminals.mode,
258
- });
259
- });
260
- app.post("/api/pair/claim", (req, res) => {
261
- const { token, name } = (req.body ?? {});
262
- if (!token)
263
- return void res.status(400).json({ error: "missing token" });
264
- const claimed = this.auth.claim(token, name ?? "device");
265
- if (!claimed)
266
- return void res.status(403).json({ error: "invalid or expired pairing token" });
267
- res.json(claimed);
268
- });
269
- /**
270
- * The local admin console bootstraps here — before the bearer wall, gated by
271
- * the socket being loopback. A same-machine caller gets the admin token (it
272
- * lives in a config file they can already read), which is what lets the web
273
- * app served on localhost mint pairing codes and open phone access. Everyone
274
- * else — a phone on the tailnet, anything past localhost — is turned away and
275
- * pairs like any other device. Admin-ness stays a property of the *token*,
276
- * so a paired client is never an admin no matter where it connects from.
277
- */
278
- app.get("/api/bootstrap", (req, res) => {
279
- // Both must hold: the TCP peer is loopback (can't be spoofed by a header),
280
- // AND the Host is a loopback literal (defeats DNS rebinding, where the
281
- // socket is loopback but the browser sends the attacker's hostname).
282
- if (!isLoopback(req.socket.remoteAddress) || !isLoopbackHost(req.headers.host)) {
283
- return void res.status(403).json({ error: "not a local request" });
284
- }
285
- res.json({ token: this.auth.adminToken(), admin: true });
286
- });
284
+ registerAssetsRoutes(app);
285
+ registerPublicRoutes(app, ctx);
286
+ registerPreviewPublic(app);
287
287
  // Everything else requires a bearer token.
288
288
  app.use((req, res, next) => {
289
+ // Inbound alert webhooks can't carry the admin bearer token — an
290
+ // Alertmanager-style sender has no Loom token to present — so they
291
+ // authenticate with their own LOOM_WEBHOOK_SECRET instead. Set that
292
+ // secret whenever the daemon binds past localhost.
293
+ if (req.path.startsWith("/api/webhooks/")) {
294
+ next();
295
+ return;
296
+ }
289
297
  const token = bearerToken(req.headers.authorization);
290
298
  if (!this.auth.isAuthorized(token)) {
291
299
  res.status(401).json({ error: "unauthorized" });
292
300
  return;
293
301
  }
302
+ // Admin is the admin token, and only the admin token. A same-machine
303
+ // caller becomes admin by *fetching* that token from /api/bootstrap
304
+ // (above) — which is what the local console does — never by virtue of
305
+ // the socket it arrived on. Trusting loopback here would silently
306
+ // promote every paired client that happens to connect over it, and a
307
+ // revoked phone would keep working for as long as it stayed local.
294
308
  req.isAdmin = this.auth.isAdmin(token);
309
+ req.projectScope =
310
+ this.auth.allowedProjects(token);
295
311
  next();
296
312
  });
297
- /**
298
- * What this machine still needs — the same answer `loom doctor` gives.
299
- *
300
- * Behind the auth wall on purpose: it enumerates which agents you have
301
- * installed and which GUI apps are open, a small inventory of your machine
302
- * and none of a stranger's business — which matters the moment the daemon
303
- * binds past localhost (--host, Tailscale).
304
- *
305
- * Probing GUI bridges means a couple of HTTP round trips to their debug
306
- * ports, so this is a request you make when you open Settings, not something
307
- * the app polls.
308
- */
309
- app.get("/api/setup", (_req, res) => {
310
- void setupReport()
311
- .then((report) => res.json(report))
312
- .catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));
313
+ // Cheap liveness: HEAD answers with no body, for probes that only ask "is
314
+ // it up" — a monitor hitting GET /api/health every second serialises the
315
+ // whole health payload to throw it away.
316
+ app.head("/api/health", (_req, res) => {
317
+ res.status(200).end();
313
318
  });
314
- /**
315
- * `loom doctor`, over HTTP — the env checks always, plus one project's
316
- * checks when a ?project is given. Dynamically imported so doctor.js (which
317
- * pulls BUILD_REV back out of this file) doesn't create an import cycle at
318
- * module-init time.
319
- */
320
- app.get("/api/doctor", (req, res) => {
321
- void (async () => {
322
- try {
323
- const { envChecks, projectChecks } = await import("../cli/doctor.js");
324
- const checks = await envChecks();
325
- const projId = req.query.project;
326
- if (projId) {
327
- const info = findProject(projId);
328
- if (info)
329
- checks.push(...projectChecks(info.dir));
330
- }
331
- res.json({ checks });
332
- }
333
- catch (err) {
334
- res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
335
- }
336
- })();
337
- });
338
- /**
339
- * Is this Loom current? Version + build rev, and — when Loom itself is a git
340
- * checkout — how many commits its own tree is behind its remote. Honest about
341
- * the two different "updates" that matter: a newer daemon build waiting to be
342
- * restarted (rev), and newer code waiting to be pulled (behind).
343
- */
344
- app.get("/api/updates", (_req, res) => {
345
- void (async () => {
346
- const root = loomRoot();
347
- let git = null;
348
- if (root) {
349
- const { remoteBehind } = await import("../core/git.js");
350
- git = await remoteBehind(root).catch(() => null);
351
- }
352
- res.json({ version: "0.1.0", rev: BUILD_REV, root, git });
353
- })();
354
- });
355
- /**
356
- * Is `gh` logged in, and as whom — machine-wide, so no project needed. The
357
- * whole GitHub half of Loom (board PRs, Projects, review) rides on this; the
358
- * status bar shows it and offers Connect when it's false.
359
- */
360
- app.get("/api/github/status", (_req, res) => {
361
- void ghAuthStatus()
362
- .then((s) => res.json(s))
363
- .catch((err) => res.status(500).json({ error: err instanceof Error ? err.message : String(err) }));
364
- });
365
- /**
366
- * LoomPad connectivity — proxies the voice backend's /health so the web app
367
- * can show a live "LoomPad connected" pill without a cross-origin fetch. The
368
- * backend (orchestrator-pad) does STT -> agent -> TTS for the physical pad;
369
- * when it's up, the pad gets its spoken replies. Best-effort: an unreachable
370
- * backend just returns { up:false } (the pill goes grey), never an error.
371
- */
372
- app.get("/api/loompad/health", (_req, res) => {
373
- const base = (process.env.LOOMPAD_BACKEND_URL || "http://127.0.0.1:8080").replace(/\/+$/, "");
374
- const ctl = new AbortController();
375
- const timer = setTimeout(() => ctl.abort(), 2500);
376
- void fetch(base + "/health", { signal: ctl.signal })
377
- .then(async (r) => {
378
- clearTimeout(timer);
379
- if (!r.ok)
380
- return void res.json({ up: false, backend: base, status: r.status });
381
- const body = (await r.json().catch(() => ({})));
382
- res.json({ up: true, backend: base, ...body });
383
- })
384
- .catch(() => {
385
- clearTimeout(timer);
386
- res.json({ up: false, backend: base });
387
- });
388
- });
389
- /**
390
- * The two networks a phone could use to reach this daemon — the LAN and the
391
- * tailnet — with, for each, the address and whether the phone can actually
392
- * get here on it *right now*. It can't when we're bound to localhost, which
393
- * is the default; `reachable:false` is the modal's cue to offer "enable
394
- * phone access" (expose) before showing a QR that wouldn't resolve.
395
- */
396
- app.get("/api/pair/networks", (req, res) => {
397
- if (!req.isAdmin) {
398
- return void res.status(403).json({ error: "admin only" });
399
- }
400
- void (async () => {
401
- const exposed = this.exposedIps();
402
- const reach = (ip) => Boolean(ip) && (this.host === "0.0.0.0" || this.host === ip || exposed.includes(ip));
403
- const lan = lanIp();
404
- let ts = null;
405
- try {
406
- ts = await tailscaleIp();
407
- }
408
- catch {
409
- ts = null;
410
- }
411
- res.json({
412
- port: this.port,
413
- boundHost: this.host,
414
- exposed,
415
- localnet: { ip: lan, reachable: reach(lan) },
416
- tailnet: ts
417
- ? { ip: ts, available: true, reachable: reach(ts) }
418
- : {
419
- ip: null,
420
- available: false,
421
- reachable: false,
422
- reason: "Tailscale isn't logged in on this machine (run `tailscale up`)",
423
- },
424
- });
425
- })();
426
- });
427
- /**
428
- * Make a phone-reachable address go live — a phone can't reach a
429
- * localhost-only daemon. We add a second listener on the requested LAN or
430
- * tailnet IP (never touching localhost), so this is safe to await and report
431
- * on directly. Explicit and user-driven (you clicked "connect a phone"), and
432
- * behind the token wall the whole time.
433
- */
434
- app.post("/api/pair/expose", (req, res) => {
435
- if (!req.isAdmin) {
436
- return void res.status(403).json({ error: "admin only" });
437
- }
438
- void (async () => {
439
- const wanted = typeof req.body?.host === "string" ? req.body.host.trim() : "";
440
- let ts = null;
441
- try {
442
- ts = await tailscaleIp();
443
- }
444
- catch {
445
- ts = null;
446
- }
447
- // Only ever bind an address that is genuinely ours (LAN or tailnet).
448
- const allowed = new Set([lanIp(), ts].filter(Boolean));
449
- if (!wanted || !allowed.has(wanted)) {
450
- return void res.status(400).json({ error: "not a local or tailnet address of this machine" });
451
- }
452
- try {
453
- await this.expose(wanted);
454
- res.json({ ok: true, ip: wanted, port: this.port, exposed: this.exposedIps() });
455
- }
456
- catch (err) {
457
- logbook.error("daemon", `could not open phone access on ${wanted}`, err);
458
- res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
459
- }
460
- })();
461
- });
462
- app.post("/api/pair/new", (req, res) => {
463
- if (!req.isAdmin) {
464
- return void res.status(403).json({ error: "admin only" });
465
- }
466
- void (async () => {
467
- // The QR must point at the address the phone will actually use, so the
468
- // caller may ask for the LAN or tailnet host — but only those. An
469
- // arbitrary host from the client never reaches the link.
470
- const wanted = typeof req.body?.host === "string" ? req.body.host.trim() : "";
471
- let ts = null;
472
- try {
473
- ts = await tailscaleIp();
474
- }
475
- catch {
476
- ts = null;
477
- }
478
- const allowed = new Set([this.host, lanIp(), ts].filter(Boolean));
479
- const host = wanted && allowed.has(wanted) ? wanted : this.host;
480
- const { token, expiresAt } = this.auth.newPairingToken();
481
- const url = `http://${host}:${this.port}`;
482
- // Deep link: scanning it with any camera opens the app, which claims the
483
- // single-use token from the URL fragment and pairs itself.
484
- const link = `${url}/app#pair=${token}`;
485
- let qrSvg;
486
- try {
487
- qrSvg = await QRCode.toString(link, {
488
- type: "svg",
489
- margin: 1,
490
- errorCorrectionLevel: "M",
491
- });
492
- }
493
- catch (err) {
494
- // The link still works even if the QR doesn't render — degrade, don't fail.
495
- logbook.warn("pair", "QR render failed — the copy link still works", err);
496
- }
497
- res.json({ token, expiresAt, url, link, ...(qrSvg ? { qrSvg } : {}) });
498
- })();
499
- });
500
- app.get("/api/pair/clients", (_req, res) => {
501
- res.json({ clients: this.auth.clients() });
502
- });
503
- app.delete("/api/pair/clients/:clientId", (req, res) => {
504
- if (!req.isAdmin) {
505
- return void res.status(403).json({ error: "admin only" });
506
- }
507
- const revoked = this.auth.revoke(String(req.params.clientId));
508
- if (!revoked)
509
- return void res.status(404).json({ error: "unknown client" });
510
- res.json({ revoked: true });
511
- });
512
- // A paired device registers (or clears) its Expo push token.
513
- app.post("/api/push/register", (req, res) => {
514
- const me = this.auth.clientFor(bearerToken(req.headers.authorization));
515
- if (!me)
516
- return void res.status(403).json({ error: "device tokens only — pair first" });
517
- const { token, platform } = (req.body ?? {});
518
- if (!token?.trim())
519
- return void res.status(400).json({ error: "missing token" });
520
- this.auth.setPushToken(me.id, token.trim(), platform);
521
- res.json({ registered: true });
522
- });
523
- app.delete("/api/push/register", (req, res) => {
524
- const me = this.auth.clientFor(bearerToken(req.headers.authorization));
525
- if (!me)
526
- return void res.status(403).json({ error: "device tokens only" });
527
- this.auth.setPushToken(me.id, null);
528
- res.json({ registered: false });
529
- });
530
- // Admin: fire a test push at every registered device.
531
- app.post("/api/push/test", (req, res) => {
532
- if (!req.isAdmin) {
533
- return void res.status(403).json({ error: "admin only" });
534
- }
535
- const tokens = this.pushTokens();
536
- void sendExpoPush(tokens, {
537
- title: "Loom",
538
- body: "test notification — pairing works ✓",
539
- });
540
- res.json({ sent: tokens.length });
541
- });
542
- app.get("/api/projects", (_req, res) => {
543
- void (async () => {
544
- const projects = [];
545
- for (const info of listProjects()) {
546
- try {
547
- const rt = await this.runtime(info.id);
548
- projects.push(await rt.status());
549
- }
550
- catch (err) {
551
- projects.push({
552
- id: info.id,
553
- name: info.name,
554
- dir: info.dir,
555
- holder: null,
556
- agents: [],
557
- lastEvent: null,
558
- needsInput: false,
559
- error: String(err instanceof Error ? err.message : err),
560
- });
561
- }
562
- }
563
- res.json({ projects });
564
- })();
565
- });
566
- app.post("/api/projects", (req, res) => {
567
- void (async () => {
568
- const { dir, name } = (req.body ?? {});
569
- if (!dir)
570
- return void res.status(400).json({ error: "missing dir" });
571
- const resolved = path.resolve(dir);
572
- if (!fs.existsSync(resolved)) {
573
- return void res.status(400).json({ error: `no such directory: ${resolved}` });
574
- }
575
- let config = readProjectConfig(resolved);
576
- if (!config) {
577
- // Every ADE Loom can drive, probed in parallel — see core/ades.ts.
578
- // This used to name claude and opencode by hand, which is how the list
579
- // of what Loom actually drives drifted from the list of logos it ships.
580
- const availability = await detectAdes();
581
- const agents = defaultAgentConfigs(availability);
582
- const routes = buildDefaultRoutes(agents);
583
- config = {
584
- name: name ?? path.basename(resolved),
585
- agents,
586
- ...(routes ? { routes } : {}),
587
- };
588
- writeProjectConfig(resolved, config);
589
- }
590
- const info = registerProject(resolved, config.name);
591
- res.json({ project: info, config });
592
- })();
593
- });
594
- const withRuntime = (handler) => {
595
- return (req, res) => {
596
- void (async () => {
597
- try {
598
- const rt = await this.runtime(String(req.params.id));
599
- await handler(rt, req, res);
600
- }
601
- catch (err) {
602
- if (err instanceof NotHolderError) {
603
- res.status(409).json({
604
- error: "not_holder",
605
- holder: err.holder,
606
- agentId: err.agentId,
607
- message: err.message,
608
- });
609
- return;
610
- }
611
- if (err instanceof RouteActiveError) {
612
- res.status(409).json({ error: "route_active", message: err.message });
613
- return;
614
- }
615
- // A 500 used to be a sentence for one caller and nothing else: no
616
- // stack, no record, gone the moment the fetch resolved. Now the
617
- // Console gets it with the stack and the route that produced it.
618
- logbook.error("api", `${req.method} ${req.path} failed: ${err instanceof Error ? err.message : String(err)}`, err, String(req.params.id ?? ""));
619
- res.status(500).json({ error: String(err instanceof Error ? err.message : err) });
620
- }
621
- })();
622
- };
623
- };
624
- app.get("/api/projects/:id", withRuntime(async (rt, _req, res) => {
625
- res.json({ project: await rt.status() });
626
- }));
627
- app.get("/api/projects/:id/events", withRuntime(async (rt, req, res) => {
628
- const since = req.query.since ? Number(req.query.since) : undefined;
629
- const limit = req.query.limit ? Number(req.query.limit) : 200;
630
- // no ?chat= means the whole project — old clients keep seeing the
631
- // whole thread, which is what they've always shown
632
- const chat = req.query.chat ? String(req.query.chat) : undefined;
633
- res.json({ events: rt.log.list({ since, limit, ...(chat ? { chat } : {}) }) });
634
- }));
635
- app.post("/api/projects/:id/messages", withRuntime(async (rt, req, res) => {
636
- const { text, agentId, chat } = (req.body ?? {});
637
- if (!text?.trim())
638
- return void res.status(400).json({ error: "missing text" });
639
- const result = await rt.sendMessage(text, agentId, chat ? { chat } : {});
640
- res.json(result);
641
- }));
642
- /**
643
- * Drive a GUI agent: type into Antigravity's or Kiro's own chat and read
644
- * back what appeared.
645
- *
646
- * Separate from /messages because it is a different act. /messages hands a
647
- * turn to something that can hold the baton; this types into an app you're
648
- * signed into and waits for its panel to settle. The bridge never takes the
649
- * lock, so an adapter mid-turn is untouched.
650
- *
651
- * It blocks for as long as the app takes to answer — minutes, for a real
652
- * task. That's why it's its own route: nothing else here is allowed to be
653
- * this slow, and the client needs to know to wait.
654
- */
655
- app.post("/api/projects/:id/bridge/:agentId/ask", withRuntime(async (rt, req, res) => {
656
- const { text, chat } = (req.body ?? {});
657
- const agentId = String(req.params.agentId);
658
- if (!text?.trim())
659
- return void res.status(400).json({ error: "missing text" });
660
- try {
661
- const result = await rt.askBridge(agentId, text, chat ? { chat } : {});
662
- res.json(result);
663
- }
664
- catch (err) {
665
- // 409, not 500: "log into Antigravity" is a state you can fix, not a
666
- // bug in the daemon, and the message is the whole value of the reply.
667
- res.status(409).json({ error: err instanceof Error ? err.message : String(err) });
668
- }
669
- }));
670
- app.post("/api/projects/:id/handoff", withRuntime(async (rt, req, res) => {
671
- const { to } = (req.body ?? {});
672
- if (!to)
673
- return void res.status(400).json({ error: "missing to" });
674
- const result = await rt.handoff(to);
675
- res.json({ ...result, to });
676
- }));
677
- // Chats — several conversations inside one project. They share the brain,
678
- // the baton and the working tree; only the talking is separate.
679
- app.get("/api/projects/:id/chats", withRuntime(async (rt, _req, res) => {
680
- res.json({ chats: rt.chats() });
681
- }));
682
- app.post("/api/projects/:id/chats", withRuntime(async (rt, req, res) => {
683
- const { title } = (req.body ?? {});
684
- res.json({ chat: rt.createChat(String(title ?? "")) });
685
- }));
686
- app.post("/api/projects/:id/chats/:chatId/rename", withRuntime(async (rt, req, res) => {
687
- const { title } = (req.body ?? {});
688
- if (!title?.trim())
689
- return void res.status(400).json({ error: "missing title" });
690
- const chat = rt.renameChat(String(req.params.chatId), title);
691
- if (!chat)
692
- return void res.status(400).json({ error: "cannot rename that chat" });
693
- res.json({ chat });
694
- }));
695
- app.delete("/api/projects/:id/chats/:chatId", withRuntime(async (rt, req, res) => {
696
- if (!rt.deleteChat(String(req.params.chatId))) {
697
- return void res.status(400).json({ error: "cannot delete that chat" });
698
- }
699
- res.json({ deleted: true });
700
- }));
701
- // Rename an agent's role. It's free text — your project decides what jobs
702
- // exist, not us. Writes .loom/config.json, which is the source of truth.
703
- app.post("/api/projects/:id/agents/:agentId/role", withRuntime(async (rt, req, res) => {
704
- const { role } = (req.body ?? {});
705
- if (typeof role !== "string")
706
- return void res.status(400).json({ error: "missing role" });
707
- const clean = role.trim().slice(0, 40);
708
- if (!clean)
709
- return void res.status(400).json({ error: "role cannot be empty" });
710
- const updated = rt.setAgentRole(String(req.params.agentId), clean);
711
- if (!updated)
712
- return void res.status(404).json({ error: "unknown agent" });
713
- res.json(updated);
714
- }));
715
- // The Settings screen reads its editable knobs here — brain extractor,
716
- // projection mode, default agent — with the roster the picker chooses from.
717
- app.get("/api/projects/:id/config", withRuntime(async (rt, _req, res) => {
718
- res.json(rt.settings());
719
- }));
720
- // The Settings screen's editable knobs: the brain extractor, the projection
721
- // mode, the default agent. Everything is read live from config, so a merge
722
- // here lands on the next turn/handoff with no restart. Partial — send only
723
- // what changed. Returns the full config so the screen can re-render.
724
- app.patch("/api/projects/:id/config", withRuntime(async (rt, req, res) => {
725
- const body = (req.body ?? {});
726
- try {
727
- const cfg = rt.patchConfig({
728
- brain: body.brain,
729
- projection: body.projection,
730
- defaultAgent: body.defaultAgent,
731
- });
732
- res.json({
733
- brain: cfg.brain ?? {},
734
- projection: cfg.projection ?? {},
735
- defaultAgent: cfg.defaultAgent ?? "",
736
- });
737
- }
738
- catch (err) {
739
- res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
740
- }
741
- }));
742
- // ---- search -----------------------------------------------------------
743
- // Finding a file by name was the whole of search, which is its least useful
744
- // half: you remember a line, not a filename. And the thread — where a
745
- // project's actual reasoning lives — wasn't searchable at all.
746
- app.get("/api/projects/:id/grep", withRuntime(async (rt, req, res) => {
747
- res.json(await searchCode(rt.info.dir, String(req.query.q ?? "")));
748
- }));
749
- app.get("/api/projects/:id/chats/search", withRuntime(async (rt, req, res) => {
750
- res.json(searchChats(rt.log, String(req.query.q ?? ""), {
751
- ...(req.query.chat ? { chat: String(req.query.chat) } : {}),
752
- }));
753
- }));
754
- // ---- source control ---------------------------------------------------
755
- // Reading the working tree has been possible since the Explorer landed;
756
- // doing anything about it has not. These are the writes, and they're the
757
- // only endpoints in Loom that can destroy work — hence the path checks in
758
- // core/git.ts and the noise in the log when you discard.
759
- app.get("/api/projects/:id/git/status", withRuntime(async (rt, _req, res) => {
760
- res.json(await gitStatus(rt.info.dir));
761
- }));
762
- const gitWrite = (fn) => withRuntime(async (rt, req, res) => {
763
- try {
764
- res.json(await fn(rt.info.dir, (req.body ?? {})));
765
- }
766
- catch (err) {
767
- // git's own words, not ours: "nothing to commit, working tree clean"
768
- // beats anything we'd invent about an exit code.
769
- const message = err instanceof Error ? err.message : String(err);
770
- logbook.warn("git", message, err instanceof GitError ? err.stderr : err, rt.info.id);
771
- res.status(400).json({ error: message });
772
- }
773
- });
774
- app.post("/api/projects/:id/git/stage", gitWrite((dir, b) => gitStage(dir, asPaths(b.paths))));
775
- app.post("/api/projects/:id/git/unstage", gitWrite((dir, b) => gitUnstage(dir, asPaths(b.paths))));
776
- app.post("/api/projects/:id/git/discard", gitWrite((dir, b) => gitDiscard(dir, asPaths(b.paths), asPaths(b.untracked))));
777
- app.post("/api/projects/:id/git/commit", gitWrite((dir, b) => gitCommit(dir, String(b.message ?? ""))));
778
- // init / push / checkout — all write, all through the same error surface.
779
- app.post("/api/projects/:id/git/init", gitWrite((dir) => gitInit(dir)));
780
- app.post("/api/projects/:id/git/push", gitWrite((dir) => gitPush(dir)));
781
- app.post("/api/projects/:id/git/checkout", gitWrite((dir, b) => gitCheckout(dir, String(b.ref ?? ""))));
782
- // read-only: the commit log, one file's diff, and the branch list
783
- app.get("/api/projects/:id/git/log", withRuntime(async (rt, req, res) => {
784
- const limit = Number(req.query.limit) || 30;
785
- res.json({ commits: await gitLog(rt.info.dir, limit) });
786
- }));
787
- app.get("/api/projects/:id/git/diff", withRuntime(async (rt, req, res) => {
788
- const p = String(req.query.path ?? "");
789
- if (!p)
790
- return void res.status(400).json({ error: "missing path" });
791
- try {
792
- res.json({ path: p, patch: await gitFileDiff(rt.info.dir, p) });
793
- }
794
- catch (err) {
795
- res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
796
- }
797
- }));
798
- app.get("/api/projects/:id/git/branches", withRuntime(async (rt, _req, res) => {
799
- res.json(await gitBranches(rt.info.dir));
800
- }));
801
- // Draft a commit message from the staged diff, via the logged-in Claude CLI
802
- // — the "Generate" affordance. No key; a no-op-ish 400 when Claude isn't
803
- // there, so the field just stays empty and the user types their own.
804
- app.post("/api/projects/:id/git/suggest-message", withRuntime(async (rt, _req, res) => {
805
- const diff = await gitStagedDiff(rt.info.dir).catch(() => "");
806
- if (!diff.trim())
807
- return void res.status(400).json({ error: "nothing to describe — stage or edit some files first" });
808
- try {
809
- const prompt = "Write a single-line Conventional Commit subject (type(scope): summary, imperative mood, <72 chars) " +
810
- "for this diff. Reply with ONLY the subject line, no quotes, no body.\n\n" +
811
- diff;
812
- const out = (await claudeText(prompt, { model: "haiku", timeoutMs: 30_000 })).trim();
813
- const message = out.split("\n")[0]?.replace(/^["'`]|["'`]$/g, "").trim().slice(0, 120) ?? "";
814
- if (!message)
815
- return void res.status(502).json({ error: "Claude returned nothing — type a message instead" });
816
- res.json({ message });
817
- }
818
- catch {
819
- // claudeText's raw "claude exited N" helps no one at the commit box.
820
- res.status(502).json({ error: "couldn't reach Claude to draft a message — type one instead" });
821
- }
822
- }));
823
- // ---- the Console ------------------------------------------------------
824
- // Everything that went wrong, for the tab next to the terminal. Until this
825
- // existed an error's only home was ~/.loom/daemon.log, which you have to
826
- // know about, find, and tail — so in practice errors reached nobody.
827
- app.get("/api/logs", (req, res) => {
828
- const since = req.query.since === undefined ? undefined : Number(req.query.since);
829
- const level = req.query.level;
319
+ // What exactly is running: build rev, node, uptime, platform. /api/health
320
+ // carries rev for staleness checks; this is the fuller "loom health"
321
+ // answer, and it's behind the wall because build details are inventory.
322
+ app.get("/api/version", (_req, res) => {
830
323
  res.json({
831
- logs: logbook.list({
832
- ...(Number.isFinite(since) ? { since } : {}),
833
- ...(level ? { level } : {}),
834
- ...(req.query.project ? { project: String(req.query.project) } : {}),
835
- }),
836
- });
837
- });
838
- app.delete("/api/logs", (_req, res) => {
839
- logbook.clear();
840
- res.json({ ok: true });
841
- });
842
- /**
843
- * The window reporting its own errors — a failed fetch, a thrown render, an
844
- * unhandled rejection. Client-side failures used to die in the browser
845
- * console where no one was looking; now they land in the same Console tab as
846
- * the daemon's, streamed to every window and kept in the ring buffer.
847
- */
848
- app.post("/api/logs", (req, res) => {
849
- const b = (req.body ?? {});
850
- const level = b.level === "error" || b.level === "warn" ? b.level : "info";
851
- const message = String(b.message ?? "").slice(0, 500);
852
- if (!message)
853
- return void res.status(400).json({ error: "missing message" });
854
- const scope = (b.scope ? String(b.scope) : "app").slice(0, 40);
855
- const rec = logbook.add(level, scope, message, b.detail, b.project ? String(b.project) : undefined);
856
- res.json({ ok: true, id: rec.id });
857
- });
858
- // Which agents Loom can drive on this machine, and which are already in
859
- // this project. The UI needs both to offer you the difference.
860
- app.get("/api/projects/:id/agents/available", withRuntime(async (rt, _req, res) => {
861
- const availability = await detectAdes();
862
- const inProject = new Set(rt.config.agents.map((a) => a.kind));
863
- res.json({
864
- ades: ADES.map((a) => ({
865
- kind: a.kind,
866
- label: a.label,
867
- tier: a.tier,
868
- // Bridges are never "installed" — they're an app you launch with a
869
- // debug port, so presence is a live question, not a lookup.
870
- installed: a.tier === "adapter" ? Boolean(availability[a.kind]) : null,
871
- inProject: inProject.has(a.kind),
872
- models: a.models ?? [],
873
- })),
874
- });
875
- }));
876
- // Add an agent to a project. A roster used to be frozen at creation: install
877
- // a new ADE and your existing projects never heard of it.
878
- app.post("/api/projects/:id/agents", withRuntime(async (rt, req, res) => {
879
- const { kind, id, role } = (req.body ?? {});
880
- if (!kind?.trim())
881
- return void res.status(400).json({ error: "missing kind" });
882
- try {
883
- res.json(rt.addAgent(kind.trim(), { id, role }));
884
- }
885
- catch (err) {
886
- res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
887
- }
888
- }));
889
- app.delete("/api/projects/:id/agents/:agentId", withRuntime(async (rt, req, res) => {
890
- try {
891
- res.json(rt.removeAgent(String(req.params.agentId)));
892
- }
893
- catch (err) {
894
- res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
895
- }
896
- }));
897
- // Point an agent at a different model. Empty string clears the override.
898
- app.post("/api/projects/:id/agents/:agentId/model", withRuntime(async (rt, req, res) => {
899
- const { model } = (req.body ?? {});
900
- try {
901
- const cfg = rt.setAgentModel(String(req.params.agentId), model ?? "");
902
- res.json({ agent: cfg });
903
- }
904
- catch (err) {
905
- res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
906
- }
907
- }));
908
- app.post("/api/projects/:id/interrupt", withRuntime(async (rt, _req, res) => {
909
- res.json(await rt.interrupt());
910
- }));
911
- app.post("/api/projects/:id/decisions", withRuntime(async (rt, req, res) => {
912
- const { text } = (req.body ?? {});
913
- if (!text?.trim())
914
- return void res.status(400).json({ error: "missing text" });
915
- const event = rt.log.append({ kind: "decision", payload: { text } });
916
- // Also a memory. The decision event stays because the projection and
917
- // forty other things read it; the memory is the addressable copy — the
918
- // one that can be retrieved by what it's about, corrected, and
919
- // forgotten. Seeding the brain from the surface people already use
920
- // beats asking them to fill a second box.
921
- rt.brain.add({
922
- kind: "decision",
923
- text,
924
- provenance: { agentId: "user", eventId: event.id, ts: event.ts },
925
- });
926
- res.json({ event });
927
- }));
928
- // --- the brain ---------------------------------------------------------
929
- app.get("/api/projects/:id/brain", withRuntime(async (rt, req, res) => {
930
- const q = req.query;
931
- const memories = rt.brain.list({
932
- ...(q.kind ? { kind: q.kind } : {}),
933
- ...(q.chat ? { chat: q.chat } : {}),
934
- ...(q.includeExpired === "1" ? { includeExpired: true } : {}),
935
- ...(q.limit ? { limit: Math.min(500, Number(q.limit) || 100) } : {}),
936
- });
937
- res.json({ memories, stats: rt.brain.stats() });
938
- }));
939
- app.get("/api/projects/:id/brain/search", withRuntime(async (rt, req, res) => {
940
- const q = req.query;
941
- const files = q.files ? q.files.split(",").filter(Boolean) : [];
942
- if (!q.q?.trim() && !files.length) {
943
- return void res.status(400).json({ error: "missing q or files" });
944
- }
945
- const hits = retrieve(rt.brain, {
946
- ...(q.q ? { query: q.q } : {}),
947
- ...(files.length ? { files } : {}),
948
- ...(q.chat ? { chat: q.chat } : {}),
949
- ...(q.agent ? { agent: q.agent } : {}),
950
- limit: Math.min(50, Number(q.limit) || 12),
951
- explain: q.explain === "1",
952
- });
953
- res.json({ hits });
954
- }));
955
- app.post("/api/projects/:id/brain", withRuntime(async (rt, req, res) => {
956
- const body = (req.body ?? {});
957
- if (!body.text?.trim())
958
- return void res.status(400).json({ error: "missing text" });
959
- try {
960
- const { memory, created } = rt.brain.add({
961
- kind: body.kind ?? "fact",
962
- text: body.text,
963
- ...(body.entities ? { entities: body.entities } : {}),
964
- ...(body.chat ? { scope: { chat: body.chat } } : {}),
965
- ...(body.confidence !== undefined ? { confidence: body.confidence } : {}),
966
- provenance: { agentId: "user", eventId: rt.log.lastId(), ts: Date.now() },
967
- });
968
- res.json({ memory, created });
969
- }
970
- catch (err) {
971
- res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
972
- }
973
- }));
974
- app.patch("/api/projects/:id/brain/:mid", withRuntime(async (rt, req, res) => {
975
- try {
976
- res.json({ memory: rt.brain.update(String(req.params.mid), (req.body ?? {}), "user") });
977
- }
978
- catch (err) {
979
- const msg = err instanceof Error ? err.message : String(err);
980
- res.status(/no such memory/.test(msg) ? 404 : 400).json({ error: msg });
981
- }
982
- }));
983
- app.delete("/api/projects/:id/brain/:mid", withRuntime(async (rt, req, res) => {
984
- const reason = String(req.query.reason ?? "").trim();
985
- if (!reason)
986
- return void res.status(400).json({ error: "forgetting needs a reason" });
987
- const forgot = rt.brain.forget(String(req.params.mid), reason, "user");
988
- if (!forgot)
989
- return void res.status(404).json({ error: "no such memory" });
990
- res.json({ forgot: true });
991
- }));
992
- app.get("/api/projects/:id/brain/:mid/history", withRuntime(async (rt, req, res) => {
993
- res.json({ history: rt.brain.history(String(req.params.mid)) });
994
- }));
995
- app.post("/api/projects/:id/route", withRuntime(async (rt, req, res) => {
996
- const { task, spec, router, maxHops } = (req.body ?? {});
997
- if (!task?.trim())
998
- return void res.status(400).json({ error: "missing task" });
999
- const route = await rt.startRoute({
1000
- task,
1001
- ...(spec !== undefined ? { spec } : {}),
1002
- ...(router ? { router } : {}),
1003
- ...(maxHops ? { maxHops: Number(maxHops) } : {}),
1004
- });
1005
- res.json({ route });
1006
- }));
1007
- app.get("/api/projects/:id/route", withRuntime(async (rt, _req, res) => {
1008
- res.json({ route: rt.routeState() });
1009
- }));
1010
- app.get("/api/projects/:id/costs", withRuntime(async (rt, _req, res) => {
1011
- res.json({ costs: rt.costSummary() });
1012
- }));
1013
- app.get("/api/projects/:id/tree", withRuntime(async (rt, _req, res) => {
1014
- res.json({ tree: await rt.workingTree() });
1015
- }));
1016
- app.get("/api/projects/:id/memory", withRuntime(async (rt, _req, res) => {
1017
- res.json({ memory: rt.unifiedMemory() });
1018
- }));
1019
- app.post("/api/projects/:id/memory/import", withRuntime(async (rt, _req, res) => {
1020
- res.json(rt.importMemories());
1021
- }));
1022
- app.delete("/api/projects/:id/route", withRuntime(async (rt, _req, res) => {
1023
- res.json({ route: await rt.abortRoute() });
1024
- }));
1025
- // Terminal: one long-lived shell per tab. A real pty when node-pty is
1026
- // available (echo, job control, vim), otherwise a pipe-backed shell — see
1027
- // terminals.ts. Output streams over the project WebSocket; input arrives
1028
- // there too, because a tty needs a round-trip per keystroke. Bearer auth +
1029
- // the tailnet are the trust boundary, same as the agents the daemon runs.
1030
- app.post("/api/projects/:id/term/open", (req, res) => {
1031
- const info = findProject(String(req.params.id));
1032
- if (!info)
1033
- return void res.status(404).json({ error: "unknown project" });
1034
- const { term, cols, rows } = (req.body ?? {});
1035
- const termId = String(term ?? "t1");
1036
- const existing = this.terminals.get(info.id, termId);
1037
- if (existing) {
1038
- // A reload rejoins the session it left, and gets replayed what it missed.
1039
- return void res.json({
1040
- term: termId,
1041
- cwd: existing.cwd,
1042
- mode: existing.mode,
1043
- reused: true,
1044
- scrollback: existing.scrollback(),
1045
- });
1046
- }
1047
- try {
1048
- const sess = this.terminals.open(info.id, termId, info.dir, cols ?? 80, rows ?? 24);
1049
- res.json({ term: termId, cwd: sess.cwd, mode: sess.mode, reused: false, scrollback: "" });
1050
- }
1051
- catch (err) {
1052
- // only the cap is a 429 — a shell that won't spawn is our problem, not
1053
- // the client's rate
1054
- res.status(err instanceof TooManySessionsError ? 429 : 500).json({
1055
- error: err.message,
1056
- });
1057
- }
1058
- });
1059
- app.post("/api/projects/:id/term/input", (req, res) => {
1060
- const info = findProject(String(req.params.id));
1061
- if (!info)
1062
- return void res.status(404).json({ error: "unknown project" });
1063
- const { term, data } = (req.body ?? {});
1064
- const termId = String(term ?? "t1");
1065
- try {
1066
- // this opens a session when none exists, so it can fail the same ways
1067
- // /term/open can — uncaught, Express answers a JSON client with HTML
1068
- const sess = this.terminals.get(info.id, termId) ?? this.terminals.open(info.id, termId, info.dir);
1069
- sess.write(String(data ?? ""));
1070
- res.json({ ok: true });
1071
- }
1072
- catch (err) {
1073
- res.status(err instanceof TooManySessionsError ? 429 : 500).json({
1074
- error: err.message,
1075
- });
1076
- }
1077
- });
1078
- app.post("/api/projects/:id/term/signal", (req, res) => {
1079
- const info = findProject(String(req.params.id));
1080
- if (!info)
1081
- return void res.status(404).json({ error: "unknown project" });
1082
- const sess = this.terminals.get(info.id, String((req.body ?? {}).term ?? "t1"));
1083
- if (!sess)
1084
- return void res.json({ signalled: false });
1085
- sess.interrupt();
1086
- res.json({ signalled: true });
1087
- });
1088
- app.post("/api/projects/:id/term/resize", (req, res) => {
1089
- const info = findProject(String(req.params.id));
1090
- if (!info)
1091
- return void res.status(404).json({ error: "unknown project" });
1092
- const { term, cols, rows } = (req.body ?? {});
1093
- const sess = this.terminals.get(info.id, String(term ?? "t1"));
1094
- if (!sess)
1095
- return void res.json({ resized: false });
1096
- sess.resize(Number(cols) || 80, Number(rows) || 24);
1097
- res.json({ resized: true });
1098
- });
1099
- app.post("/api/projects/:id/term/close", (req, res) => {
1100
- const info = findProject(String(req.params.id));
1101
- if (!info)
1102
- return void res.status(404).json({ error: "unknown project" });
1103
- this.terminals.close(info.id, String((req.body ?? {}).term ?? "t1"));
1104
- res.json({ closed: true });
1105
- });
1106
- // The board: live agents (from us) + pull requests (from gh), sorted into
1107
- // working → needs you → in review → ready. See board.ts.
1108
- app.get("/api/projects/:id/board", async (req, res) => {
1109
- const info = findProject(String(req.params.id));
1110
- if (!info)
1111
- return void res.status(404).json({ error: "unknown project" });
1112
- try {
1113
- const rt = await this.runtime(info.id);
1114
- const status = await rt.status();
1115
- const blocked = status.blockedAgent ? [status.blockedAgent] : [];
1116
- const search = req.query.search ? String(req.query.search) : undefined;
1117
- res.json(await buildBoard(info.dir, status.agents, blocked, {
1118
- tasks: rt.boardTasks(),
1119
- ...(search ? { search } : {}),
1120
- }));
1121
- }
1122
- catch (err) {
1123
- res.status(500).json({ error: err.message });
1124
- }
1125
- });
1126
- // Cards you write yourself. Unlike an agent or a PR, these are ours, so a
1127
- // drag really moves them — the column IS the state.
1128
- app.post("/api/projects/:id/board/tasks", withRuntime(async (rt, req, res) => {
1129
- const { title, column, agent } = (req.body ?? {});
1130
- if (!title?.trim())
1131
- return void res.status(400).json({ error: "missing title" });
1132
- res.json({ task: rt.createTask({ title, ...(column ? { column } : {}), ...(agent ? { agent } : {}) }) });
1133
- }));
1134
- app.post("/api/projects/:id/board/tasks/:taskId", withRuntime(async (rt, req, res) => {
1135
- const { title, column, agent } = (req.body ?? {});
1136
- const task = rt.updateTask(String(req.params.taskId), {
1137
- ...(title !== undefined ? { title } : {}),
1138
- ...(column !== undefined ? { column } : {}),
1139
- ...(agent !== undefined ? { agent } : {}),
1140
- });
1141
- if (!task)
1142
- return void res.status(404).json({ error: "unknown task" });
1143
- res.json({ task });
1144
- }));
1145
- app.delete("/api/projects/:id/board/tasks/:taskId", withRuntime(async (rt, req, res) => {
1146
- if (!rt.deleteTask(String(req.params.taskId))) {
1147
- return void res.status(404).json({ error: "unknown task" });
1148
- }
1149
- res.json({ deleted: true });
1150
- }));
1151
- // Issues / PRs for the project's GitHub remote, read through the user's
1152
- // own gh CLI (see tasks.ts) — Loom holds no token of its own.
1153
- app.get("/api/projects/:id/tasks", async (req, res) => {
1154
- const info = findProject(String(req.params.id));
1155
- if (!info)
1156
- return void res.status(404).json({ error: "unknown project" });
1157
- const kind = String(req.query.kind ?? "issue") === "pr" ? "pr" : "issue";
1158
- res.json(await listTasks(info.dir, {
1159
- kind,
1160
- ...(req.query.search ? { search: String(req.query.search) } : {}),
1161
- }));
1162
- });
1163
- // Small async wrapper: resolve the project or 404, run the handler, and turn
1164
- // any throw into a 500 with its message. The GitHub/Linear/worktree reads
1165
- // below all share this shape.
1166
- const projectRoute = (fn) => (req, res) => {
1167
- const info = findProject(String(req.params.id));
1168
- if (!info)
1169
- return void res.status(404).json({ error: "unknown project" });
1170
- void fn(info.dir, req, res).catch((err) => res.status(500).json({ error: err instanceof Error ? err.message : String(err) }));
1171
- };
1172
- // ---- GitHub Projects (v2) — the owner's boards, browsed in-app ----------
1173
- app.get("/api/projects/:id/gh/projects", projectRoute(async (dir, _req, res) => {
1174
- res.json(await ghProjects(dir));
1175
- }));
1176
- app.get("/api/projects/:id/gh/projects/:num/items", projectRoute(async (dir, req, res) => {
1177
- res.json(await ghProjectItems(dir, Number(req.params.num)));
1178
- }));
1179
- // ---- Pull-request review — diff + approve / request-changes / comment ----
1180
- app.get("/api/projects/:id/prs/:num", projectRoute(async (dir, req, res) => {
1181
- res.json(await prView(dir, Number(req.params.num)));
1182
- }));
1183
- app.post("/api/projects/:id/prs/:num/review", projectRoute(async (dir, req, res) => {
1184
- const { action, body } = (req.body ?? {});
1185
- const allowed = ["approve", "request-changes", "comment"];
1186
- if (!allowed.includes(action)) {
1187
- return void res.status(400).json({ error: "action must be approve, request-changes, or comment" });
1188
- }
1189
- const result = await prReview(dir, Number(req.params.num), action, body ?? "");
1190
- if ("available" in result)
1191
- return void res.status(400).json({ error: result.detail });
1192
- res.json(result);
1193
- }));
1194
- // ---- Worktrees — open a checked-out branch from any task ----------------
1195
- app.get("/api/projects/:id/worktrees", projectRoute(async (dir, _req, res) => {
1196
- res.json({ worktrees: await gitListWorktrees(dir) });
1197
- }));
1198
- app.post("/api/projects/:id/worktrees", projectRoute(async (dir, req, res) => {
1199
- const b = (req.body ?? {});
1200
- if (b.pr) {
1201
- const n = Number(b.pr);
1202
- const wt = await gitAddWorktree(dir, { slug: "pr-" + n, detached: true });
1203
- try {
1204
- // gh handles fork PRs (adds the remote, fetches, makes the branch)
1205
- await runGh(["pr", "checkout", String(n)], wt.path);
1206
- }
1207
- catch (err) {
1208
- // don't strand an empty detached worktree if the checkout fails
1209
- await gitRemoveWorktree(dir, wt.path, true).catch(() => { });
1210
- throw err;
1211
- }
1212
- return void res.json({ path: wt.path, source: `PR #${n}` });
1213
- }
1214
- if (b.newBranch) {
1215
- const wt = await gitAddWorktree(dir, {
1216
- slug: b.newBranch,
1217
- newBranch: String(b.newBranch),
1218
- ...(b.base ? { base: String(b.base) } : {}),
1219
- });
1220
- return void res.json({ path: wt.path, branch: wt.branch });
1221
- }
1222
- if (b.branch) {
1223
- const wt = await gitAddWorktree(dir, { slug: String(b.branch), branch: String(b.branch) });
1224
- return void res.json({ path: wt.path, branch: wt.branch });
1225
- }
1226
- if (b.issue) {
1227
- const slug = "issue-" + Number(b.issue);
1228
- const wt = await gitAddWorktree(dir, { slug, newBranch: slug });
1229
- return void res.json({ path: wt.path, branch: wt.branch, source: `issue #${Number(b.issue)}` });
1230
- }
1231
- res.status(400).json({ error: "say which: pr, issue, branch, or newBranch" });
1232
- }));
1233
- app.delete("/api/projects/:id/worktrees", projectRoute(async (dir, req, res) => {
1234
- const wtPath = String((req.body ?? {}).path ?? req.query.path ?? "");
1235
- if (!wtPath)
1236
- return void res.status(400).json({ error: "which worktree? pass its path" });
1237
- await gitRemoveWorktree(dir, wtPath, Boolean((req.body ?? {}).force));
1238
- res.json({ removed: wtPath });
1239
- }));
1240
- // ---- Linear — teams + create issue, through the user's own key ----------
1241
- app.get("/api/projects/:id/linear/teams", projectRoute(async (_dir, _req, res) => {
1242
- res.json(await linearTeams());
1243
- }));
1244
- app.get("/api/projects/:id/linear/issues", projectRoute(async (_dir, req, res) => {
1245
- res.json(await listLinearIssues(req.query.team ? String(req.query.team) : undefined));
1246
- }));
1247
- app.post("/api/projects/:id/linear/issues", projectRoute(async (_dir, req, res) => {
1248
- const { teamId, title, description } = (req.body ?? {});
1249
- const result = await linearCreateIssue({
1250
- teamId: teamId ?? "",
1251
- title: title ?? "",
1252
- ...(description ? { description } : {}),
1253
- });
1254
- if (result.available)
1255
- return void res.json(result);
1256
- res.status(400).json({ error: result.detail });
1257
- }));
1258
- // Explorer: list a directory, read a file, search filenames. All strictly
1259
- // sandboxed to the project directory (no traversal outside it).
1260
- const contains = (base, target) => target === base || target.startsWith(base + path.sep);
1261
- /**
1262
- * Resolve a project-relative path, or null if it escapes the project.
1263
- * Two checks, because they catch different attacks: the lexical one stops
1264
- * `../` traversal (and works for paths that don't exist yet), and the
1265
- * realpath one stops a symlink *inside* the project from pointing out of
1266
- * it — path.resolve happily resolves through links.
1267
- */
1268
- const projectPath = (id, rel) => {
1269
- const info = findProject(id);
1270
- if (!info)
1271
- return null;
1272
- let base;
1273
- try {
1274
- base = fs.realpathSync(path.resolve(info.dir));
1275
- }
1276
- catch {
1277
- return null;
1278
- }
1279
- const target = path.resolve(base, rel ?? ".");
1280
- if (!contains(base, target))
1281
- return null;
1282
- try {
1283
- if (!contains(base, fs.realpathSync(target)))
1284
- return null;
1285
- }
1286
- catch {
1287
- // doesn't exist — the lexical check above is the whole answer
1288
- }
1289
- return target;
1290
- };
1291
- const HIDE_DIRS = new Set([".git", "node_modules", "dist", "build", ".next", ".cache", "coverage"]);
1292
- app.get("/api/projects/:id/files", (req, res) => {
1293
- const dir = projectPath(String(req.params.id), req.query.dir ? String(req.query.dir) : ".");
1294
- if (!dir)
1295
- return void res.status(404).json({ error: "not found" });
1296
- const base = projectPath(String(req.params.id), ".");
1297
- fs.readdir(dir, { withFileTypes: true }, (err, ents) => {
1298
- if (err)
1299
- return void res.status(400).json({ error: err.message });
1300
- const entries = ents
1301
- .filter((e) => e.name !== ".git")
1302
- .map((e) => ({
1303
- name: e.name,
1304
- path: path.relative(base, path.join(dir, e.name)),
1305
- dir: e.isDirectory(),
1306
- }))
1307
- .sort((a, b) => a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1)
1308
- .slice(0, 500);
1309
- res.json({ dir: path.relative(base, dir), entries });
1310
- });
1311
- });
1312
- app.get("/api/projects/:id/file", (req, res) => {
1313
- const file = projectPath(String(req.params.id), req.query.path ? String(req.query.path) : "");
1314
- if (!file)
1315
- return void res.status(404).json({ error: "not found" });
1316
- fs.stat(file, (err, st) => {
1317
- if (err)
1318
- return void res.status(400).json({ error: err.message });
1319
- if (st.isDirectory())
1320
- return void res.status(400).json({ error: "is a directory" });
1321
- const MAX = 400_000;
1322
- const truncated = st.size > MAX;
1323
- const stream = fs.createReadStream(file, { start: 0, end: Math.min(st.size, MAX) - 1, encoding: "utf8" });
1324
- let content = "";
1325
- stream.on("data", (c) => (content += c));
1326
- stream.on("error", (e) => res.status(400).json({ error: e.message }));
1327
- stream.on("end", () => {
1328
- const base = projectPath(String(req.params.id), ".");
1329
- res.json({ path: path.relative(base, file), content, truncated, size: st.size });
1330
- });
324
+ rev: BUILD_REV,
325
+ node: process.version,
326
+ platform: `${process.platform}-${process.arch}`,
327
+ uptimeSec: Math.round(process.uptime()),
328
+ pid: process.pid,
1331
329
  });
1332
330
  });
1333
- app.get("/api/projects/:id/find", (req, res) => {
1334
- const base = projectPath(String(req.params.id), ".");
1335
- if (!base)
1336
- return void res.status(404).json({ error: "not found" });
1337
- const q = String(req.query.q ?? "").trim().toLowerCase();
1338
- if (!q)
1339
- return void res.json({ matches: [] });
1340
- const matches = [];
1341
- let visited = 0;
1342
- const walk = (dir) => {
1343
- if (matches.length >= 200 || visited >= 20_000)
1344
- return;
1345
- let ents;
1346
- try {
1347
- ents = fs.readdirSync(dir, { withFileTypes: true });
1348
- }
1349
- catch {
1350
- return;
1351
- }
1352
- for (const e of ents) {
1353
- if (matches.length >= 200 || visited >= 20_000)
1354
- return;
1355
- visited++;
1356
- if (e.isDirectory()) {
1357
- if (HIDE_DIRS.has(e.name))
1358
- continue;
1359
- walk(path.join(dir, e.name));
1360
- }
1361
- else if (e.name.toLowerCase().includes(q)) {
1362
- matches.push(path.relative(base, path.join(dir, e.name)));
1363
- }
1364
- }
1365
- };
1366
- walk(base);
1367
- res.json({ matches });
1368
- });
1369
- /**
1370
- * Stash a pasted image or dropped file, and hand back its path.
1371
- *
1372
- * The CLIs Loom drives take text and nothing else — SendInput is { text,
1373
- * briefing }, no image channel. So the only honest way to "attach" an image
1374
- * is to write it somewhere the agent can read and reference the path in the
1375
- * message. Claude Code and Codex both read image files by path; for the
1376
- * others it's at least a real artifact on disk rather than a lie in the UI.
1377
- *
1378
- * Under .loom/attachments/ so it's inside the project (the agent's cwd) but
1379
- * out of the way. Name is derived from a content hash, never from the
1380
- * client's — a caller doesn't get to choose where in the tree this lands.
1381
- */
1382
- app.post("/api/projects/:id/attachments", (req, res) => {
1383
- const base = projectPath(String(req.params.id), ".");
1384
- if (!base)
1385
- return void res.status(404).json({ error: "not found" });
1386
- const { name, dataUrl } = (req.body ?? {});
1387
- const m = /^data:([\w/+.-]+);base64,(.+)$/s.exec(dataUrl ?? "");
1388
- if (!m)
1389
- return void res.status(400).json({ error: "expected a base64 data URL" });
1390
- const mime = m[1] ?? "application/octet-stream";
1391
- const buf = Buffer.from(m[2] ?? "", "base64");
1392
- const MAX = 12 * 1024 * 1024;
1393
- if (buf.length > MAX)
1394
- return void res.status(413).json({ error: "attachment over 12MB" });
1395
- // Extension from the declared type or the client's name, whichever we
1396
- // trust more — but only ever the extension, never the path.
1397
- const extFromName = typeof name === "string" ? path.extname(name).replace(/[^.\w]/g, "").slice(0, 8) : "";
1398
- const ext = extFromName || "." + (MIME_EXT[mime] ?? "bin");
1399
- const hash = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 12);
1400
- const rel = path.join(".loom", "attachments", hash + ext);
1401
- const abs = projectPath(String(req.params.id), rel);
1402
- if (!abs)
1403
- return void res.status(400).json({ error: "bad attachment path" });
1404
- try {
1405
- fs.mkdirSync(path.dirname(abs), { recursive: true });
1406
- fs.writeFileSync(abs, buf);
1407
- }
1408
- catch (err) {
1409
- return void res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
1410
- }
1411
- res.json({ path: rel, bytes: buf.length, mime });
331
+ // The scope wall: one guard over every project route, so a scoped token
332
+ // cannot reach a project it wasn't paired for. Matching by resolved id —
333
+ // the param may be a name, and a scope you could dodge by spelling the
334
+ // project differently would be theatre.
335
+ app.use("/api/projects/:id", (req, res, next) => {
336
+ const scope = req.projectScope;
337
+ if (!scope)
338
+ return void next();
339
+ const resolved = findProject(String(req.params.id))?.id ?? String(req.params.id);
340
+ if (scope.includes(resolved))
341
+ return void next();
342
+ res.status(403).json({ error: "this token is not scoped to that project" });
343
+ });
344
+ registerSetupRoutes(app);
345
+ registerProvidersRoutes(app);
346
+ registerUpdatesRoutes(app, ctx);
347
+ registerIntegrationsRoutes(app);
348
+ registerPairingRoutes(app, ctx);
349
+ registerTeamRoutes(app, ctx);
350
+ registerCloudRoutes(app, ctx);
351
+ registerClientsRoutes(app, ctx);
352
+ registerProjectsRoutes(app, ctx);
353
+ const withRuntime = createRuntimeHandler(ctx);
354
+ registerObservabilityRoutes(app, withRuntime);
355
+ registerAlertsRoutes(app, ctx);
356
+ registerMessagesRoutes(app, withRuntime);
357
+ registerServersRoutes(app, withRuntime);
358
+ registerTaskDeliveryRoutes(app, withRuntime);
359
+ registerQueueRoutes(app, withRuntime);
360
+ registerChatsRoutes(app, withRuntime);
361
+ registerAgentSettingsRoutes(app, withRuntime);
362
+ registerToolsRoutes(app, withRuntime);
363
+ registerConfigRoutes(app, withRuntime);
364
+ registerSearchRoutes(app, withRuntime);
365
+ registerGitRoutes(app, withRuntime);
366
+ registerLogsRoutes(app);
367
+ registerAgentDiscoveryRoutes(app, withRuntime);
368
+ registerSpecsRoutes(app, ctx, withRuntime);
369
+ registerProjectActionsRoutes(app, withRuntime);
370
+ registerVoiceRoutes(app, withRuntime);
371
+ registerHistoryRoutes(app, withRuntime);
372
+ registerPromptsRoutes(app, ctx);
373
+ registerProjectTeamRoutes(app, ctx, withRuntime);
374
+ registerApprovalsRoutes(app, ctx, withRuntime);
375
+ registerOrchestraRoutes(app, withRuntime);
376
+ registerCrewsRoutes(app, withRuntime);
377
+ registerProofRoutes(app, withRuntime);
378
+ registerShiftRoutes(app, withRuntime);
379
+ registerOnboardRoutes(app, { team: this.team, onboarding: this.onboarding, ...(this.gh ? { gh: this.gh } : {}) }, withRuntime);
380
+ registerAgentsRoutes(app, withRuntime);
381
+ registerBrainRoutes(app, withRuntime);
382
+ registerRoutingRoutes(app, withRuntime);
383
+ registerTerminalsRoutes(app, ctx);
384
+ registerBoardRoutes(app, ctx, withRuntime);
385
+ registerIntegrationsProjectRoutes(app);
386
+ registerFilesRoutes(app);
387
+ registerPreviewRoutes(app);
388
+ // Anything under /api nobody answered: JSON, like every other API reply,
389
+ // not Express's HTML page.
390
+ app.use("/api", (_req, res) => {
391
+ res.status(404).json({ error: "no such endpoint" });
392
+ });
393
+ // Errors that escaped a route, and bodies that never parsed. Express's
394
+ // default answers these with an HTML page carrying a stack trace.
395
+ app.use((err, _req, res, next) => {
396
+ if (res.headersSent)
397
+ return void next(err);
398
+ const e = (err ?? {});
399
+ if (e.type === "entity.too.large")
400
+ return void res.status(413).json({ error: "that request is too large (the limit is 2 MB)" });
401
+ if (e.type === "entity.parse.failed")
402
+ return void res.status(400).json({ error: "that request body isn't valid JSON" });
403
+ logbook.error("daemon", `request failed: ${e.message ?? String(err)}`, err);
404
+ const status = typeof e.status === "number" && e.status >= 400 && e.status < 600 ? e.status : 500;
405
+ res.status(status).json({ error: status >= 500 ? "something went wrong in the daemon — the Console has the details" : e.message || "bad request" });
1412
406
  });
1413
407
  }
1414
408
  // -------------------------------------------------------------------------
@@ -1422,6 +416,7 @@ export class LoomDaemon {
1422
416
  if (existing) {
1423
417
  // Hot-reload edited .loom/config.json once the project is quiet.
1424
418
  if (existing.configStale() && !existing.anyBusy()) {
419
+ this.team.detachRuntime(info.id);
1425
420
  await existing.close();
1426
421
  this.runtimes.delete(info.id);
1427
422
  }
@@ -1430,19 +425,78 @@ export class LoomDaemon {
1430
425
  }
1431
426
  }
1432
427
  const rt = await ProjectRuntime.open(info);
1433
- rt.log.onEvent((e) => this.broadcast(info.id, e));
428
+ rt.onServerEvent((f) => {
429
+ this.broadcastFrame({ type: "server", projectId: info.id, ...f }, info.id);
430
+ });
431
+ rt.onQueueChange((q) => {
432
+ const head = q.items[0];
433
+ const waitingFor = head && !q.paused ? rt.queueBlocker(head) : null;
434
+ this.broadcastFrame({
435
+ type: "queue",
436
+ projectId: info.id,
437
+ queue: q.items,
438
+ paused: q.paused,
439
+ ...(q.reason ? { reason: q.reason } : {}),
440
+ ...(waitingFor ? { waitingFor } : {}),
441
+ }, info.id);
442
+ });
443
+ rt.onLiveDelta((d) => {
444
+ this.broadcastFrame({ type: "phase" in d ? "item" : "delta", projectId: info.id, ...d }, info.id);
445
+ });
446
+ // Replies as they're written. Deltas are coalesced per agent for a frame
447
+ // (~40ms), so a fast model is a few dozen socket frames a second at most,
448
+ // not one per token. Never logged: the finished message is the record.
449
+ const live = new Map();
450
+ let liveTimer = null;
451
+ const flushLive = () => {
452
+ liveTimer = null;
453
+ for (const [agentId, f] of live) {
454
+ this.broadcastFrame({
455
+ type: "stream", projectId: info.id, agentId, chat: f.chat, text: f.text,
456
+ ...(f.reasoning ? { reasoning: true } : {}),
457
+ ...(f.off !== undefined ? { off: f.off } : {}),
458
+ }, info.id);
459
+ }
460
+ live.clear();
461
+ };
462
+ rt.onStream((f) => {
463
+ const key = f.agentId;
464
+ const have = live.get(key);
465
+ // A switch between thinking and answering (or thread) is a new piece.
466
+ if (have && (have.reasoning !== Boolean(f.reasoning) || have.chat !== f.chat))
467
+ flushLive();
468
+ const cur = live.get(key);
469
+ if (cur)
470
+ cur.text += f.text;
471
+ else
472
+ live.set(key, { chat: f.chat, text: f.text, reasoning: Boolean(f.reasoning), ...(f.off !== undefined ? { off: f.off } : {}) });
473
+ if (!liveTimer)
474
+ liveTimer = setTimeout(flushLive, 40);
475
+ });
476
+ rt.log.onEvent((e) => {
477
+ // A finished message supersedes its typing: send what's buffered first
478
+ // so the last few characters can't land after the message they belong to.
479
+ if (e.kind === "message" && e.agentId && live.has(e.agentId)) {
480
+ if (liveTimer)
481
+ clearTimeout(liveTimer);
482
+ flushLive();
483
+ }
484
+ this.broadcast(info.id, e);
485
+ // The single central hook for live events — agent turns as well as
486
+ // API-driven handoffs, routes and memory folds. Rehydration reads through
487
+ // log.list(), not append(), so replaying history never re-exports it.
488
+ recordAgentEvent(e, { project: info.name });
489
+ });
1434
490
  this.runtimes.set(info.id, rt);
491
+ this.team.attachRuntime(rt);
1435
492
  return rt;
1436
493
  }
494
+ /** One frame to everyone watching this project (or everyone, with no project). */
495
+ broadcastFrame(payload, projectId) {
496
+ this.delivery.publish(payload, { kind: "project", projectId });
497
+ }
1437
498
  broadcast(projectId, event) {
1438
- const frame = JSON.stringify({ type: "event", projectId, event });
1439
- for (const [ws, sub] of this.sockets) {
1440
- if (ws.readyState !== WebSocket.OPEN)
1441
- continue;
1442
- if (sub.project && sub.project !== projectId)
1443
- continue;
1444
- ws.send(frame);
1445
- }
499
+ this.broadcastFrame({ type: "event", projectId, event }, projectId);
1446
500
  // An agent's error is a thread event AND a log line. The thread shows it to
1447
501
  // whoever is reading that conversation; the Console shows it to whoever is
1448
502
  // wondering why nothing happened. Those are often the same person and never
@@ -1461,44 +515,63 @@ export class LoomDaemon {
1461
515
  */
1462
516
  streamLogs() {
1463
517
  return logbook.subscribe((record) => {
1464
- const frame = JSON.stringify({ type: "log", record });
1465
- for (const [ws] of this.sockets) {
1466
- if (ws.readyState === WebSocket.OPEN)
1467
- ws.send(frame);
1468
- }
518
+ this.delivery.publish({ type: "log", record }, { kind: "log", projectId: record.project });
1469
519
  });
1470
520
  }
1471
521
  /** Fan a terminal frame out to every socket watching this project. */
1472
522
  broadcastTerm(projectId, frame) {
1473
- const payload = JSON.stringify(frame);
1474
- for (const [ws, sub] of this.sockets) {
1475
- if (ws.readyState !== WebSocket.OPEN)
1476
- continue;
1477
- if (sub.project && sub.project !== projectId)
1478
- continue;
1479
- ws.send(payload);
1480
- }
523
+ this.delivery.publish(frame, { kind: "project", projectId });
1481
524
  }
1482
- pushTokens() {
525
+ pushTokens(category) {
1483
526
  const cfg = readDaemonConfig();
1484
527
  return (cfg?.clients ?? [])
528
+ .filter((c) => !category || wantsPush(c.pushKinds, category))
1485
529
  .map((c) => c.pushToken)
1486
530
  .filter((t) => Boolean(t));
1487
531
  }
1488
532
  /** Fire-and-notify to phones. Route hops stay quiet; the outcome pushes. */
1489
533
  maybePush(projectId, event) {
1490
- if (!PUSH_KINDS.has(event.kind))
534
+ const category = pushCategory(event);
535
+ if (!category)
1491
536
  return;
1492
- if (event.kind === "run_complete" && this.runtimes.get(projectId)?.routes.isActive()) {
1493
- return; // a pipeline in flight buzzes once at the end, not per hop
537
+ const rt = this.runtimes.get(projectId);
538
+ let reply;
539
+ if (event.kind === "run_complete") {
540
+ if (rt?.routes.isActive())
541
+ return; // a pipeline in flight buzzes once at the end, not per hop
542
+ if (event.payload.error)
543
+ return; // the error event says it
544
+ if (rt) {
545
+ // a goal's or crew's own task turns stay quiet: the goal pushes when it's done
546
+ try {
547
+ const chat = event.chat ?? "main";
548
+ const g = rt.chats().find((c) => c.id === chat)?.group;
549
+ if (g && g.kind !== "import")
550
+ return;
551
+ }
552
+ catch { /* push anyway */ }
553
+ try {
554
+ reply = rt.log.list({ kinds: ["message"], chat: event.chat ?? "main", before: event.id, limit: 1 })
555
+ .find((m) => !m.agentId || m.agentId === event.agentId)?.payload.text;
556
+ }
557
+ catch { /* a push without the snippet */ }
558
+ }
1494
559
  }
1495
- const tokens = this.pushTokens();
560
+ const tokens = this.pushTokens(category);
1496
561
  if (!tokens.length)
1497
562
  return;
1498
563
  const name = listProjects().find((p) => p.id === projectId)?.name ?? "project";
1499
564
  void sendExpoPush(tokens, {
1500
- ...pushContent(name, event),
1501
- data: { projectId, kind: event.kind },
565
+ ...pushContent(name, event, { reply }),
566
+ // the phone opens this project (and goal, or chat) when the alert is tapped
567
+ data: {
568
+ projectId,
569
+ kind: event.kind,
570
+ category,
571
+ ...(typeof event.payload.runId === "string" ? { runId: event.payload.runId } : {}),
572
+ ...(typeof event.payload.crewId === "string" ? { crewId: event.payload.crewId } : {}),
573
+ ...(event.chat ? { chat: event.chat } : {}),
574
+ },
1502
575
  });
1503
576
  }
1504
577
  // -------------------------------------------------------------------------
@@ -1514,6 +587,7 @@ export class LoomDaemon {
1514
587
  // resolve this as a phantom success — a daemon that prints "listening" and
1515
588
  // exits 0 while another process actually holds the port.
1516
589
  const server = this.app.listen(this.port, this.host);
590
+ configureHttpServer(server);
1517
591
  this.server = server;
1518
592
  let settled = false;
1519
593
  server.once("error", (err) => {
@@ -1533,11 +607,58 @@ export class LoomDaemon {
1533
607
  if (addr && typeof addr === "object")
1534
608
  this.port = addr.port; // ephemeral port support
1535
609
  this.wss = this.attachWs(this.server);
610
+ // Agents ask from inside the daemon (core/approvals.ts).
611
+ setApprovalBroker((req) => this.askHuman(req));
1536
612
  // Fan every log record out to connected clients (the Console tab).
1537
613
  this.unstreamLogs = this.streamLogs();
1538
614
  this.writeConfig();
615
+ if (readCloudSettings().enabled)
616
+ void this.startCloud().catch(() => { });
617
+ void this.team.start().catch(() => { });
618
+ this.watchReleases();
1539
619
  return { host: this.host, port: this.port };
1540
620
  }
621
+ /**
622
+ * A new Loom release tells each phone once ("updates" push). The first look
623
+ * waits a minute so a restart doesn't hit GitHub at once, then every few
624
+ * hours; the version already announced is remembered across restarts.
625
+ */
626
+ watchReleases() {
627
+ if (process.env.LOOM_NO_UPDATE_CHECK || process.env.VITEST)
628
+ return;
629
+ const look = async () => {
630
+ const release = await this.cachedRelease(false);
631
+ if (!release || !newerThan(release.version, VERSION))
632
+ return;
633
+ const told = path.join(loomHome(), "update-announced");
634
+ let last = "";
635
+ try {
636
+ last = fs.readFileSync(told, "utf8").trim();
637
+ }
638
+ catch { /* never told */ }
639
+ if (last === release.version)
640
+ return;
641
+ const tokens = this.pushTokens("updates");
642
+ try {
643
+ fs.writeFileSync(told, release.version);
644
+ }
645
+ catch { /* tell again next time */ }
646
+ if (!tokens.length)
647
+ return;
648
+ void sendExpoPush(tokens, {
649
+ title: `Loom ${release.version} is out`,
650
+ body: `This computer runs ${VERSION}. Update it from Loom's Settings, or run: loom update`,
651
+ data: { kind: "update", category: "updates", version: release.version, url: release.url },
652
+ });
653
+ };
654
+ const first = setTimeout(() => void look().catch(() => { }), 60_000);
655
+ const every = setInterval(() => void look().catch(() => { }), CHECK_TTL_MS);
656
+ first.unref?.();
657
+ every.unref?.();
658
+ this.healTimers.add(first);
659
+ this.releaseTimer = every;
660
+ }
661
+ releaseTimer = null;
1541
662
  /**
1542
663
  * Attach a WebSocket server (path /ws) to an HTTP server and wire the
1543
664
  * connection handler. Returned so extra phone-access listeners can track and
@@ -1545,6 +666,25 @@ export class LoomDaemon {
1545
666
  */
1546
667
  attachWs(server) {
1547
668
  const wss = new WebSocketServer({ server, path: "/ws" });
669
+ const heartbeat = setInterval(() => {
670
+ for (const ws of wss.clients) {
671
+ const live = ws;
672
+ if (live.loomAlive === false) {
673
+ try {
674
+ ws.terminate();
675
+ }
676
+ catch { /* gone */ }
677
+ continue;
678
+ }
679
+ live.loomAlive = false;
680
+ try {
681
+ ws.ping();
682
+ }
683
+ catch { /* closing */ }
684
+ }
685
+ }, 30_000);
686
+ heartbeat.unref();
687
+ wss.on("close", () => clearInterval(heartbeat));
1548
688
  wss.on("connection", (ws, req) => {
1549
689
  const url = new URL(req.url ?? "/ws", `http://${this.host}:${this.port}`);
1550
690
  // Prefer the token in the `Sec-WebSocket-Protocol` header (a header isn't
@@ -1567,7 +707,11 @@ export class LoomDaemon {
1567
707
  // Ensure the runtime is live so its events flow.
1568
708
  void this.runtime(project).catch(() => { });
1569
709
  }
1570
- this.sockets.set(ws, { ...(resolvedProject ? { project: resolvedProject } : {}) });
710
+ const wsScope = this.auth.allowedProjects(token);
711
+ this.sockets.set(ws, {
712
+ ...(resolvedProject ? { project: resolvedProject } : {}),
713
+ ...(wsScope ? { scope: wsScope } : {}),
714
+ });
1571
715
  ws.send(JSON.stringify({
1572
716
  type: "hello",
1573
717
  projects: listProjects().map((p) => p.id),
@@ -1595,6 +739,10 @@ export class LoomDaemon {
1595
739
  sess.resize(Number(msg.cols) || 80, Number(msg.rows) || 24);
1596
740
  }
1597
741
  });
742
+ // heartbeat: a socket that misses a pong is half-open (a sleeping laptop,
743
+ // a phone off its network) and is dropped instead of buffering for minutes
744
+ ws.loomAlive = true;
745
+ ws.on("pong", () => { ws.loomAlive = true; });
1598
746
  ws.on("close", () => this.sockets.delete(ws));
1599
747
  });
1600
748
  return wss;
@@ -1619,6 +767,7 @@ export class LoomDaemon {
1619
767
  if (!ip || ip === this.host || this.extra.has(ip))
1620
768
  return;
1621
769
  const server = http.createServer(this.app);
770
+ configureHttpServer(server);
1622
771
  await new Promise((resolve, reject) => {
1623
772
  server.listen(this.port, ip, () => resolve());
1624
773
  server.on("error", reject);
@@ -1631,10 +780,162 @@ export class LoomDaemon {
1631
780
  exposedIps() {
1632
781
  return [...this.extra.keys()];
1633
782
  }
783
+ /**
784
+ * The self-heal recheck loop: after a firing alert fails the baton over, wait
785
+ * LOOM_HEAL_RECHECK_MS, ask the telemetry store (or the local log) whether the
786
+ * agent has errored since it was quarantined, and if not, hand the baton back
787
+ * — retrying up to LOOM_HEAL_MAX_RETRIES times before giving up. Best-effort
788
+ * and unref'd: a daemon restart simply forgets the loop.
789
+ */
790
+ startHealLoop(rt, agent, alert, since) {
791
+ if (process.env.LOOM_HEAL_DISABLED === "1")
792
+ return;
793
+ const recheckMs = Math.max(1, Number(process.env.LOOM_HEAL_RECHECK_MS) || 60_000);
794
+ const maxRetries = Math.max(1, Number(process.env.LOOM_HEAL_MAX_RETRIES) || 3);
795
+ let attempt = 0;
796
+ const tick = async () => {
797
+ attempt += 1;
798
+ if (!rt.quarantined()[agent])
799
+ return; // lifted already (a resolved alert, say)
800
+ const recovered = await this.agentRecovered(rt, agent, since).catch(() => false);
801
+ if (recovered) {
802
+ rt.unquarantine(agent);
803
+ const retried = rt.baton.holder() !== agent;
804
+ // A hand-back that's refused (the stand-in is mid-turn, say) is part
805
+ // of what happened; recording "recovered" without it reads as done.
806
+ let handBack;
807
+ if (retried)
808
+ await rt.handoff(agent).catch((err) => { handBack = err instanceof Error ? err.message : String(err); });
809
+ rt.log.append({ kind: "status", agentId: agent, payload: { state: "alert_recovery", alert, retried, attempt, via: "recheck", ...(handBack ? { handBackFailed: handBack } : {}) } });
810
+ return;
811
+ }
812
+ if (attempt >= maxRetries) {
813
+ rt.log.append({ kind: "status", agentId: agent, payload: { state: "alert_heal_exhausted", alert, attempts: attempt } });
814
+ return; // stays quarantined for a human
815
+ }
816
+ schedule();
817
+ };
818
+ const schedule = () => {
819
+ const t = setTimeout(() => { this.healTimers.delete(t); void tick(); }, recheckMs);
820
+ if (typeof t.unref === "function")
821
+ t.unref();
822
+ this.healTimers.add(t);
823
+ };
824
+ schedule();
825
+ }
826
+ /** Recovered = no error spans since it was quarantined (backend first, local log fallback). */
827
+ async agentRecovered(rt, agent, sinceMs) {
828
+ try {
829
+ return (await recentAgentErrors(rt.info.name, agent, sinceMs)) === 0;
830
+ }
831
+ catch {
832
+ const errs = rt.log.list({ limit: 400 }).filter((e) => e.ts > sinceMs && e.agentId === agent &&
833
+ (e.kind === "error" || (e.kind === "run_complete" && e.payload.error)));
834
+ return errs.length === 0;
835
+ }
836
+ }
837
+ // -------------------------------------------------------------------------
838
+ // Loom Cloud
839
+ // -------------------------------------------------------------------------
840
+ /** `&relay=<channel.key>&sb=<url>&sbk=<anon key>` when the relay is on, else "". */
841
+ cloudLinkParams() {
842
+ const s = readCloudSettings();
843
+ const target = supabaseTarget(s);
844
+ if (!s.enabled || !s.channel || !s.key || !target)
845
+ return "";
846
+ const b64 = (v) => toB64(new TextEncoder().encode(v));
847
+ return `&relay=${packCredentials({ channel: s.channel, key: s.key })}&sb=${b64(target.url)}&sbk=${b64(target.anonKey)}`;
848
+ }
849
+ cloudStatus() {
850
+ const s = readCloudSettings();
851
+ const target = supabaseTarget(s);
852
+ return {
853
+ configured: Boolean(target || this.relayTransportFactory),
854
+ enabled: s.enabled,
855
+ connected: Boolean(this.relay),
856
+ clients: this.relay?.clientCount() ?? 0,
857
+ stats: this.relay?.stats ?? null,
858
+ supabaseUrl: target?.url ?? null,
859
+ hostedUrl: hostedTarget().supabaseUrl,
860
+ error: this.relayError,
861
+ };
862
+ }
863
+ async startCloud() {
864
+ const s = readCloudSettings();
865
+ const creds = ensureCredentials(s);
866
+ s.enabled = true;
867
+ writeCloudSettings(s);
868
+ await this.relay?.close().catch(() => { });
869
+ this.relay = null;
870
+ this.relayError = null;
871
+ try {
872
+ let transport;
873
+ if (this.relayTransportFactory)
874
+ transport = await this.relayTransportFactory(creds.channel);
875
+ else {
876
+ const target = supabaseTarget(s);
877
+ if (!target)
878
+ throw new Error("no Supabase project — set LOOM_SUPABASE_URL and LOOM_SUPABASE_ANON_KEY");
879
+ transport = await supabaseTransport(target.url, target.anonKey, creds.channel);
880
+ }
881
+ await Promise.race([
882
+ transport.ready(),
883
+ new Promise((_, rej) => setTimeout(() => rej(new Error("timed out joining the relay channel")), 15_000)),
884
+ ]);
885
+ this.relay = new RelayBridge({
886
+ transport,
887
+ key: creds.key,
888
+ localBase: () => `http://127.0.0.1:${this.port}`,
889
+ identity: () => ({ version: BUILD_REV, name: os.hostname() }),
890
+ });
891
+ logbook.info("cloud", "Loom Cloud relay connected");
892
+ }
893
+ catch (err) {
894
+ this.relayError = err.message;
895
+ logbook.warn("cloud", "Loom Cloud relay failed to start", this.relayError);
896
+ throw err;
897
+ }
898
+ }
899
+ async stopCloud(opts = {}) {
900
+ await this.relay?.close().catch(() => { });
901
+ this.relay = null;
902
+ const s = readCloudSettings();
903
+ s.enabled = false;
904
+ if (opts.rotate) {
905
+ delete s.channel;
906
+ delete s.key;
907
+ }
908
+ writeCloudSettings(s);
909
+ }
910
+ /** The last release check, kept so a poll can't hammer GitHub's API. */
911
+ releaseSeen = null;
912
+ updating = false;
913
+ async cachedRelease(refresh) {
914
+ if (!refresh && this.releaseSeen && Date.now() - this.releaseSeen.at < CHECK_TTL_MS)
915
+ return this.releaseSeen.release;
916
+ const release = await latestRelease();
917
+ this.releaseSeen = { at: Date.now(), release };
918
+ return release;
919
+ }
1634
920
  async close() {
921
+ // A broker pointing at a closed daemon would leave the next adapter
922
+ // waiting on a card nobody will ever see.
923
+ setApprovalBroker(null);
924
+ for (const a of [...this.approvals.values()]) {
925
+ a.settle({ behavior: "deny", message: "the daemon is shutting down" });
926
+ }
927
+ await this.team.stop().catch(() => { });
928
+ await this.relay?.close().catch(() => { });
929
+ this.relay = null;
930
+ for (const t of this.healTimers)
931
+ clearTimeout(t);
932
+ if (this.releaseTimer)
933
+ clearInterval(this.releaseTimer);
934
+ this.healTimers.clear();
1635
935
  this.unstreamLogs?.();
1636
936
  this.unstreamLogs = null;
1637
- this.terminals.closeAll();
937
+ this.specRunner.closeAll();
938
+ await this.terminals.closeAll();
1638
939
  for (const rt of this.runtimes.values())
1639
940
  await rt.close();
1640
941
  this.runtimes.clear();
@@ -1643,11 +944,18 @@ export class LoomDaemon {
1643
944
  await new Promise((resolve) => server.close(() => resolve()));
1644
945
  }
1645
946
  this.extra.clear();
947
+ // Close the live sockets too. wss.close() stops new connections but leaves
948
+ // open ones open, and server.close() waits for every keep-alive socket a
949
+ // browser holds — so a restart used to leave the old daemon alive forever,
950
+ // its port freed but the process (and everything it held) still running.
951
+ for (const ws of this.wss?.clients ?? [])
952
+ ws.terminate();
1646
953
  this.wss?.close();
1647
954
  await new Promise((resolve) => {
1648
955
  if (!this.server)
1649
956
  return resolve();
1650
957
  this.server.close(() => resolve());
958
+ this.server.closeAllConnections?.();
1651
959
  });
1652
960
  const cfg = readDaemonConfig();
1653
961
  if (cfg && cfg.pid === process.pid) {
@@ -1656,73 +964,4 @@ export class LoomDaemon {
1656
964
  }
1657
965
  }
1658
966
  }
1659
- /** Resolve this machine's Tailscale IPv4 — the tailnet is the trust boundary. */
1660
- export function tailscaleIp() {
1661
- return new Promise((resolve, reject) => {
1662
- execFile("tailscale", ["ip", "-4"], (err, stdout) => {
1663
- if (err) {
1664
- reject(new Error("could not resolve a Tailscale IP (is tailscale installed and up?) — refusing to bind beyond localhost"));
1665
- return;
1666
- }
1667
- const ip = stdout.trim().split("\n")[0];
1668
- if (!ip)
1669
- return void reject(new Error("tailscale returned no IPv4"));
1670
- resolve(ip);
1671
- });
1672
- });
1673
- }
1674
- /**
1675
- * This machine's LAN IPv4 — the address a phone on the same Wi-Fi uses. We skip
1676
- * loopback, link-local (169.254), and Tailscale's own 100.64/10 CGNAT range so
1677
- * "local network" and "tailnet" stay distinct choices. Returns null when the
1678
- * only addresses are loopback (e.g. no network) — the caller says so honestly
1679
- * rather than minting an unreachable QR.
1680
- */
1681
- export function lanIp() {
1682
- const ifaces = os.networkInterfaces();
1683
- const candidates = [];
1684
- for (const addrs of Object.values(ifaces)) {
1685
- for (const a of addrs ?? []) {
1686
- if (a.family !== "IPv4" || a.internal)
1687
- continue;
1688
- if (a.address.startsWith("169.254."))
1689
- continue; // link-local, not routable
1690
- if (a.address.startsWith("100."))
1691
- continue; // Tailscale CGNAT — that's the tailnet
1692
- candidates.push(a.address);
1693
- }
1694
- }
1695
- // Prefer the common private ranges (a real LAN) over anything exotic.
1696
- const priv = candidates.find((ip) => ip.startsWith("192.168.") || ip.startsWith("10.") || /^172\.(1[6-9]|2\d|3[01])\./.test(ip));
1697
- return priv ?? candidates[0] ?? null;
1698
- }
1699
- /** Is this connection from the same machine? (127.0.0.1, ::1, or v4-mapped v6.) */
1700
- export function isLoopback(remoteAddress) {
1701
- if (!remoteAddress)
1702
- return false;
1703
- return (remoteAddress === "127.0.0.1" ||
1704
- remoteAddress === "::1" ||
1705
- remoteAddress === "::ffff:127.0.0.1");
1706
- }
1707
- /**
1708
- * Is the request's `Host` header a loopback literal? The anti-DNS-rebinding
1709
- * check: a rebinding attacker loads a page from their own domain, so the browser
1710
- * sends `Host: attacker.example` even after the name rebinds to 127.0.0.1 — while
1711
- * the genuine local console is always reached at `127.0.0.1`/`localhost`. Pairing
1712
- * the socket check with this closes the "any website → localhost token oracle"
1713
- * path. The port is ignored; only the host is checked.
1714
- */
1715
- export function isLoopbackHost(hostHeader) {
1716
- if (!hostHeader)
1717
- return false;
1718
- let host = hostHeader.trim().toLowerCase();
1719
- if (host.startsWith("[")) {
1720
- const end = host.indexOf("]");
1721
- host = end > 0 ? host.slice(1, end) : host.slice(1);
1722
- }
1723
- else if ((host.match(/:/g) || []).length === 1) {
1724
- host = host.slice(0, host.indexOf(":")); // strip the port off host:port
1725
- }
1726
- return host === "127.0.0.1" || host === "localhost" || host === "::1";
1727
- }
1728
967
  //# sourceMappingURL=server.js.map