@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
package/dist/cli/index.js CHANGED
@@ -5,20 +5,40 @@
5
5
  * Weaves Claude Code, OpenCode (and bridges like Antigravity) into a single
6
6
  * shared thread per project: one conversation, one baton, shared memory.
7
7
  */
8
- import { spawn } from "node:child_process";
8
+ import { spawn } from "../core/process.js";
9
9
  import fs from "node:fs";
10
+ import { freeUsage } from "../core/free-pool.js";
10
11
  import path from "node:path";
12
+ import os from "node:os";
11
13
  import readline from "node:readline";
12
14
  import { fileURLToPath } from "node:url";
13
15
  import { Command } from "commander";
14
16
  import pc from "picocolors";
15
17
  import qrcode from "qrcode-terminal";
18
+ // package.json says node >=22.5, but npm doesn't enforce engines at run time.
19
+ // Below that there is no node:sqlite, so every project's history would read
20
+ // as empty — say so up front instead of looking like the data is gone.
21
+ {
22
+ const [maj, min] = process.versions.node.split(".").map(Number);
23
+ if (maj < 22 || (maj === 22 && min < 5)) {
24
+ console.error(`loom needs Node 22.5 or newer — this is Node ${process.versions.node} (${process.execPath}).\n` +
25
+ `If you use nvm, \`nvm use 22\` (or newer) — or put a newer node first on your PATH.`);
26
+ process.exit(1);
27
+ }
28
+ }
16
29
  import { DaemonClient, DaemonError, daemonRunning, ensureDaemon, stopDaemon, } from "../daemon/client.js";
17
30
  import { installCrashGuards } from "../daemon/guards.js";
18
31
  import { LoomDaemon, DEFAULT_PORT } from "../daemon/server.js";
19
- import { ensureLoomHome } from "../core/registry.js";
32
+ import { ensureLoomHome, loomHome } from "../core/registry.js";
33
+ import { VERSION } from "../version.js";
34
+ import { makeBackup } from "../core/backup.js";
35
+ import { threadMarkdown } from "../core/thread-md.js";
36
+ import { EventLog } from "../core/eventlog.js";
37
+ import { allModels, fetchModels, forgetProvider, listProviders, resolveProvider, setProvider, } from "../core/providers.js";
38
+ import { serviceFile, tokenFindings } from "../core/runner-setup.js";
39
+ import { readRunnerConfig, readRunnerToken, scrubEnv, writeRunnerToken } from "../daemon/runner.js";
20
40
  import { NoProjectError, currentProjectDir, resolveCurrentProject } from "./common.js";
21
- import { fmtUsd, formatAgentRow, formatEvent, formatProjectRow } from "./ui.js";
41
+ import { fmtUsd, formatAgentRosterRow, formatAgentRow, formatEvent, formatProjectRow } from "./ui.js";
22
42
  const LOG_MAX_BYTES = 5 * 1024 * 1024;
23
43
  /**
24
44
  * Where a background daemon's output goes: ~/.loom/daemon.log, appended.
@@ -43,7 +63,7 @@ const program = new Command();
43
63
  program
44
64
  .name("loom")
45
65
  .description("one CLI for all your coding agents — shared thread, shared memory, one baton")
46
- .version("0.1.0");
66
+ .version(VERSION);
47
67
  // ---------------------------------------------------------------------------
48
68
  // Helpers
49
69
  // ---------------------------------------------------------------------------
@@ -51,6 +71,33 @@ function fail(message) {
51
71
  console.error(pc.red(`✗ ${message}`));
52
72
  process.exit(1);
53
73
  }
74
+ /** A hosted-hub session: a local browser (loopback), or --paste for machines without one (D74). */
75
+ async function hostedSession(supabaseUrl, publishableKey, paste) {
76
+ const { hostedSignIn, openInBrowser, pasteSignIn } = await import("../hub/supabase-client.js");
77
+ if (paste) {
78
+ return pasteSignIn({
79
+ supabaseUrl,
80
+ publishableKey,
81
+ ask: async (url) => {
82
+ console.log(`${pc.bold("sign in with GitHub")} — open this on any device:\n\n ${url}\n`);
83
+ console.log(pc.dim("you'll land on a page that won't load; copy its whole address and paste it here."));
84
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
85
+ const line = await new Promise((resolve) => rl.question("address: ", resolve));
86
+ rl.close();
87
+ return line;
88
+ },
89
+ });
90
+ }
91
+ console.log(pc.dim("signing in to the hosted Loom Team Hub with GitHub…"));
92
+ return hostedSignIn({
93
+ supabaseUrl,
94
+ publishableKey,
95
+ openBrowser: (url) => {
96
+ console.log(`${pc.dim(" if your browser didn't open:")} ${url}`);
97
+ openInBrowser(url);
98
+ },
99
+ });
100
+ }
54
101
  async function currentProject(client) {
55
102
  try {
56
103
  return await resolveCurrentProject(client);
@@ -103,6 +150,18 @@ program
103
150
  console.error(pc.red(`✗ invalid --port "${opts.port}"`));
104
151
  process.exit(1);
105
152
  }
153
+ // A runner box's agents don't inherit its secrets (Loom Teams D70).
154
+ if (readRunnerConfig().enabled) {
155
+ const { env, removed } = scrubEnv(process.env);
156
+ for (const k of Object.keys(process.env))
157
+ if (!(k in env))
158
+ delete process.env[k];
159
+ if (removed.length)
160
+ console.log(pc.dim(`runner: kept ${removed.length} secret-looking variable${removed.length === 1 ? "" : "s"} away from agents`));
161
+ }
162
+ const token = readRunnerToken();
163
+ if (readRunnerConfig().enabled && token && !process.env.GH_TOKEN)
164
+ process.env.GH_TOKEN = token;
106
165
  const daemon = new LoomDaemon({ host: opts.host, port });
107
166
  let bound;
108
167
  try {
@@ -124,7 +183,10 @@ program
124
183
  console.log(pc.dim("bound to the tailnet — pair your phone with `loom pair`"));
125
184
  }
126
185
  const shutdown = () => {
127
- void daemon.close().then(() => process.exit(0));
186
+ // A shutdown that can't finish in a few seconds is stuck on something it
187
+ // shouldn't wait for; leave rather than linger as a headless daemon.
188
+ setTimeout(() => process.exit(0), 5000).unref();
189
+ void daemon.close().then(() => process.exit(0), () => process.exit(1));
128
190
  };
129
191
  process.on("SIGINT", shutdown);
130
192
  process.on("SIGTERM", shutdown);
@@ -155,90 +217,2080 @@ program
155
217
  while (Date.now() < gone && (await daemonRunning())) {
156
218
  await new Promise((r) => setTimeout(r, 200));
157
219
  }
158
- }
159
- const self = fileURLToPath(import.meta.url);
160
- const args = [self, "daemon", ...(opts.tailnet ? ["--tailnet"] : [])];
161
- // Keep the daemon's output. It used to be stdio:"ignore", which meant a
162
- // background daemon threw away every byte it ever wrote — a crash left you
163
- // with nothing to read. Appended, and rolled once it gets big, so it can't
164
- // quietly eat the disk either.
165
- const log = openDaemonLog();
166
- const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", log, log] });
167
- child.unref();
168
- const deadline = Date.now() + 15_000;
169
- while (Date.now() < deadline) {
170
- await new Promise((r) => setTimeout(r, 300));
171
- const cfg = await daemonRunning();
172
- if (cfg) {
173
- console.log(pc.green(`✓ daemon up on http://${cfg.host}:${cfg.port}`));
220
+ }
221
+ const self = fileURLToPath(import.meta.url);
222
+ const args = [self, "daemon", ...(opts.tailnet ? ["--tailnet"] : [])];
223
+ // Keep the daemon's output. It used to be stdio:"ignore", which meant a
224
+ // background daemon threw away every byte it ever wrote — a crash left you
225
+ // with nothing to read. Appended, and rolled once it gets big, so it can't
226
+ // quietly eat the disk either.
227
+ const log = openDaemonLog();
228
+ const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", log, log] });
229
+ child.unref();
230
+ const deadline = Date.now() + 15_000;
231
+ while (Date.now() < deadline) {
232
+ await new Promise((r) => setTimeout(r, 300));
233
+ const cfg = await daemonRunning();
234
+ if (cfg) {
235
+ console.log(pc.green(`✓ daemon up on http://${cfg.host}:${cfg.port}`));
236
+ return;
237
+ }
238
+ }
239
+ fail("daemon did not become healthy — try `loom daemon` in the foreground");
240
+ });
241
+ program
242
+ .command("down")
243
+ .description("stop the background daemon")
244
+ .action(async () => {
245
+ if (await stopDaemon())
246
+ console.log(pc.green("✓ daemon stopped"));
247
+ else
248
+ console.log(pc.dim("no running daemon found"));
249
+ });
250
+ program
251
+ .command("status")
252
+ .description("daemon health and project board")
253
+ .action(async () => {
254
+ const cfg = await daemonRunning();
255
+ if (!cfg) {
256
+ console.log(pc.dim("daemon: not running (loom up)"));
257
+ return;
258
+ }
259
+ console.log(pc.green(`daemon: http://${cfg.host}:${cfg.port}`));
260
+ const client = new DaemonClient(cfg);
261
+ const { projects } = await client.listProjects();
262
+ if (!projects.length)
263
+ console.log(pc.dim("no projects yet — loom init"));
264
+ for (const p of projects)
265
+ console.log(formatProjectRow(p));
266
+ });
267
+ // ---------------------------------------------------------------------------
268
+ // init / projects / agents
269
+ // ---------------------------------------------------------------------------
270
+ program
271
+ .command("init")
272
+ .description("make the current directory a Loom project")
273
+ .option("--name <name>", "project name (default: directory name)")
274
+ .action(async (opts) => {
275
+ const client = await ensureDaemon();
276
+ const dir = process.cwd();
277
+ const res = await client.addProject(dir, opts.name);
278
+ const { project } = await client.project(res.project.id);
279
+ console.log(pc.green(`✓ project "${project.name}" (${project.id})`));
280
+ for (const a of project.agents)
281
+ console.log(formatAgentRow(a));
282
+ console.log(pc.dim("\nedit .loom/config.json to add/remove agents or change roles"));
283
+ console.log(pc.dim("then: loom chat"));
284
+ });
285
+ program
286
+ .command("projects")
287
+ .description("board of all projects")
288
+ .option("--forget <idOrName>", "stop tracking a project (its .loom/ stays on disk)")
289
+ .action(async (opts) => {
290
+ const client = await ensureDaemon();
291
+ if (opts.forget) {
292
+ const { projects } = await client.listProjects();
293
+ const hit = projects.find((p) => p.id === opts.forget || p.name === opts.forget);
294
+ if (!hit) {
295
+ console.error(pc.red(`no project "${opts.forget}"`));
296
+ process.exitCode = 1;
297
+ return;
298
+ }
299
+ const r = await client.forgetProject(hit.id);
300
+ console.log(`forgot ${pc.bold(hit.name)} — ${pc.dim(hit.dir)}`);
301
+ // Say what was kept. "Removed" against a tool that also owns your event
302
+ // log reads as "deleted", and someone who believes that will not go
303
+ // looking for the history that is still sitting there.
304
+ console.log(pc.dim(`its ${r.keptOnDisk} is untouched — add the directory again to restore it`));
305
+ return;
306
+ }
307
+ const { projects } = await client.listProjects();
308
+ if (!projects.length)
309
+ console.log(pc.dim("no projects yet — loom init"));
310
+ for (const p of projects)
311
+ console.log(formatProjectRow(p));
312
+ });
313
+ program
314
+ .command("agents")
315
+ .description("list agent ids, kinds, roles, models, and baton eligibility")
316
+ .option("--json", "print the agent roster as JSON")
317
+ .action(async (opts) => {
318
+ const client = await ensureDaemon();
319
+ const project = await currentProject(client);
320
+ if (opts.json) {
321
+ console.log(JSON.stringify(project.agents));
322
+ return;
323
+ }
324
+ if (!project.agents.length) {
325
+ console.log(pc.dim("no agents configured — edit .loom/config.json or run loom init"));
326
+ return;
327
+ }
328
+ for (const a of project.agents)
329
+ console.log(formatAgentRosterRow(a));
330
+ });
331
+ program
332
+ .command("providers")
333
+ .description("where model agents send their turns, and whether each has a key")
334
+ .action(() => {
335
+ const rows = listProviders();
336
+ for (const p of rows) {
337
+ const mark = p.configured ? pc.green("\u2713") : pc.dim("\u00b7");
338
+ const key = p.configured ? pc.dim(p.hint ? `${p.hint} (${p.source})` : "no key needed") : pc.dim("not configured");
339
+ console.log(`${mark} ${pc.bold(p.id.padEnd(13))} ${pc.dim(p.baseUrl.padEnd(34))} ${key}`);
340
+ if (p.note)
341
+ console.log(pc.dim(` ${p.note}`));
342
+ }
343
+ const fu = freeUsage();
344
+ console.log(`\n${pc.bold("free models")} ${fu.used} request${fu.used === 1 ? "" : "s"} from this machine today (UTC)` +
345
+ (fu.cappedUntil ? pc.yellow(` · daily cap hit, resets ${new Date(fu.cappedUntil).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`) : "") +
346
+ pc.dim("\n OpenRouter allows 50 free requests a day per account (1000 with $10 of credits), shared by every :free model." +
347
+ "\n Spread the work: give model agents `pool:free` and each turn takes the next free model — `loom agents:add model --model openrouter/pool:free`"));
348
+ console.log(pc.dim("\nkeys live in ~/.loom/providers.json (0600) or the environment \u2014 never in a project"));
349
+ });
350
+ program
351
+ .command("providers:set <id>")
352
+ .description("set a provider's key, base URL or headers (a key is never printed back)")
353
+ .option("--key <key>", "the API key; prefer the environment on a shared machine")
354
+ .option("--base-url <url>", "for a custom provider, or to override a known one")
355
+ .option("--label <label>", "what to call it")
356
+ .option("--header <kv...>", "extra header, as name=value")
357
+ .action((id, opts) => {
358
+ const headers = {};
359
+ for (const kv of opts.header ?? []) {
360
+ const at = kv.indexOf("=");
361
+ if (at < 1)
362
+ fail(`--header wants name=value, not "${kv}"`);
363
+ headers[kv.slice(0, at).trim()] = kv.slice(at + 1).trim();
364
+ }
365
+ try {
366
+ setProvider(id, {
367
+ ...(opts.key ? { key: opts.key } : {}),
368
+ ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
369
+ ...(opts.label ? { label: opts.label } : {}),
370
+ ...(Object.keys(headers).length ? { headers } : {}),
371
+ });
372
+ }
373
+ catch (err) {
374
+ fail(err instanceof Error ? err.message : String(err));
375
+ }
376
+ const row = listProviders().find((p) => p.id === id.toLowerCase());
377
+ console.log(`${pc.green("\u2713")} ${id}: ${row?.baseUrl ?? ""} ${pc.dim(row?.hint ?? "")}`);
378
+ });
379
+ program
380
+ .command("providers:rm <id>")
381
+ .description("forget a provider's key and settings")
382
+ .action((id) => {
383
+ console.log(forgetProvider(id) ? `${pc.red("-")} ${id} forgotten` : pc.dim(`nothing stored for ${id}`));
384
+ });
385
+ program
386
+ .command("ask <words...>")
387
+ .description("ask several models the same thing at once, a thread each")
388
+ .option("--models <list>", "comma-separated model ids (provider/model, or just the model)")
389
+ .option("--free", "every free model the providers have, up to --limit")
390
+ .option("--limit <n>", "how many models --free may use", "3")
391
+ .option("--title <title>", "what to call the threads")
392
+ .option("--no-briefing", "don't send the project's memory brief with it")
393
+ .action(async (words, opts) => {
394
+ const client = await ensureDaemon();
395
+ const project = await currentProject(client);
396
+ let models = (opts.models ?? "").split(",").map((m) => m.trim()).filter(Boolean);
397
+ if (opts.free) {
398
+ // The point of --free: spend nothing, and let the providers say what
399
+ // that means today rather than a list going stale in this file.
400
+ const { models: all } = await allModels();
401
+ const limit = Math.max(1, Number(opts.limit) || 3);
402
+ models = all.filter((m) => m.free).slice(0, limit).map((m) => `${m.provider}/${m.id}`);
403
+ if (!models.length)
404
+ fail("no free models — loom models --refresh, or name them with --models");
405
+ }
406
+ if (!models.length)
407
+ fail('which models? e.g. loom ask --models "a,b" "your question"');
408
+ try {
409
+ const { asked } = await client.askModels(project.id, words.join(" "), models, {
410
+ ...(opts.title ? { title: opts.title } : {}),
411
+ ...(opts.briefing === false ? { briefing: false } : {}),
412
+ });
413
+ console.log(pc.cyan(`\u279c asked ${asked.length} model${asked.length === 1 ? "" : "s"}:`));
414
+ for (const a of asked)
415
+ console.log(` ${pc.bold(a.model)} ${pc.dim(a.chat)}`);
416
+ console.log(pc.dim("answers stream into their own threads \u2014 loom watch, or open the app"));
417
+ }
418
+ catch (err) {
419
+ fail(err instanceof Error ? err.message : String(err));
420
+ }
421
+ });
422
+ program
423
+ .command("routes:save <name> <steps...>")
424
+ .description('define a named route, e.g. loom routes:save ship planner executor reviewer\n' +
425
+ " a step may carry a condition on the previous turn: 'reviewer?lines>200'\n" +
426
+ " (changed>N, changed<N, lines>N, lines<N, touched:<glob>, !touched:<glob>)")
427
+ .action(async (name, steps) => {
428
+ const client = await ensureDaemon();
429
+ const project = await currentProject(client);
430
+ try {
431
+ await client.saveRoute(project.id, name, steps);
432
+ console.log(`${pc.green("✓")} ${name}: ${steps.join(" → ")}`);
433
+ }
434
+ catch (err) {
435
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
436
+ process.exitCode = 1;
437
+ }
438
+ });
439
+ program
440
+ .command("routes:rm <name>")
441
+ .description("remove a named route")
442
+ .action(async (name) => {
443
+ const client = await ensureDaemon();
444
+ const project = await currentProject(client);
445
+ try {
446
+ await client.deleteRoute(project.id, name);
447
+ console.log(`${pc.red("-")} ${name} removed`);
448
+ }
449
+ catch (err) {
450
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
451
+ process.exitCode = 1;
452
+ }
453
+ });
454
+ program
455
+ .command("retry <agentId>")
456
+ .description("re-run the last failed turn on a different agent, failure attached as context")
457
+ .action(async (agentId) => {
458
+ const client = await ensureDaemon();
459
+ const project = await currentProject(client);
460
+ try {
461
+ const { retried } = await client.retryTurn(project.id, agentId);
462
+ console.log(`${pc.green("↻")} ${pc.bold(agentId)} retrying: ${pc.dim(retried.slice(0, 80))}`);
463
+ }
464
+ catch (err) {
465
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
466
+ process.exitCode = 1;
467
+ }
468
+ });
469
+ program
470
+ .command("version")
471
+ .description("CLI build vs the daemon's — the mismatch loom up --restart fixes")
472
+ .action(async () => {
473
+ const { BUILD_REV } = await import("../daemon/server.js");
474
+ console.log(`cli ${BUILD_REV}`);
475
+ try {
476
+ const client = await ensureDaemon();
477
+ const v = await client.version();
478
+ const stale = v.rev !== BUILD_REV;
479
+ console.log(`daemon ${v.rev} ${pc.dim(`node ${v.node} · up ${Math.round(v.uptimeSec / 60)}m`)}`);
480
+ if (stale)
481
+ console.log(pc.yellow("daemon is running an older build — loom up --restart"));
482
+ }
483
+ catch {
484
+ console.log(pc.dim("daemon not running"));
485
+ }
486
+ });
487
+ program
488
+ .command("brain:forget <memoryId>")
489
+ .description("forget one memory unit (its history stays in the log)")
490
+ .action(async (memoryId) => {
491
+ const client = await ensureDaemon();
492
+ const project = await currentProject(client);
493
+ try {
494
+ await client.forgetMemory(project.id, memoryId);
495
+ console.log(`${pc.red("-")} ${memoryId} forgotten`);
496
+ }
497
+ catch (err) {
498
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
499
+ process.exitCode = 1;
500
+ }
501
+ });
502
+ program
503
+ .command("open")
504
+ .description("open this machine's Loom app in the browser")
505
+ .action(async () => {
506
+ const client = await ensureDaemon();
507
+ const url = `${client.baseUrl}/app`;
508
+ console.log(url);
509
+ const { spawn: sp } = await import("node:child_process");
510
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd.exe" : "xdg-open";
511
+ const child = sp(opener, process.platform === "win32" ? ["/d", "/c", "start", "", url] : [url], { detached: true, windowsHide: true, stdio: "ignore" });
512
+ child.on("error", () => console.error(`Open ${url} in your browser.`));
513
+ child.unref();
514
+ });
515
+ program
516
+ .command("rename <name...>")
517
+ .description("rename this project (the id never moves, so nothing orphans)")
518
+ .action(async (words) => {
519
+ const client = await ensureDaemon();
520
+ const project = await currentProject(client);
521
+ const name = words.join(" ");
522
+ await client.renameProject(project.id, name);
523
+ console.log(`${pc.green("✓")} ${project.name} → ${pc.bold(name)}`);
524
+ });
525
+ program
526
+ .command("find <query...>")
527
+ .description("search this project's thread — messages, decisions, questions")
528
+ .action(async (words) => {
529
+ const client = await ensureDaemon();
530
+ const project = await currentProject(client);
531
+ const { hits } = await client.searchEvents(project.id, words.join(" "));
532
+ if (!hits.length)
533
+ return void console.log(pc.dim("nothing matches"));
534
+ for (const e of hits) {
535
+ const t = new Date(e.ts).toISOString().slice(0, 16).replace("T", " ");
536
+ const who = e.agentId ? pc.cyan(e.agentId) : pc.dim("you");
537
+ console.log(`${pc.dim(t)} ${who} ${String(e.payload.text ?? e.payload.question ?? "").slice(0, 110)}`);
538
+ }
539
+ });
540
+ program
541
+ .command("specs")
542
+ .description("the project's Playwright specs, and whether one is running")
543
+ .action(async () => {
544
+ const client = await ensureDaemon();
545
+ const project = await currentProject(client);
546
+ const { specs, running } = await client.listSpecs(project.id);
547
+ if (!specs.length)
548
+ return void console.log(pc.dim("no *.spec.* / *.e2e.* files here"));
549
+ for (const s of specs) {
550
+ const live = running?.file === s.path ? pc.green(" ← running") : "";
551
+ console.log(`${s.path}${live}`);
552
+ }
553
+ });
554
+ program
555
+ .command("specs:run <file>")
556
+ .description("run one Playwright spec through the daemon")
557
+ .option("--wait", "block until the verdict lands, and exit 1 on failure")
558
+ .action(async (file, opts) => {
559
+ const client = await ensureDaemon();
560
+ const project = await currentProject(client);
561
+ try {
562
+ const { run } = await client.runSpec(project.id, file);
563
+ if (!opts.wait) {
564
+ console.log(`${pc.cyan("▶")} ${run.file} ${pc.dim(`run ${run.id} — reporter streams to the app; result lands in the Console`)}`);
565
+ return;
566
+ }
567
+ // Poll the runner state; the verdict is in the Console either way, but
568
+ // --wait exists so scripts and CI get an exit code, not a suggestion.
569
+ console.log(`${pc.cyan("▶")} ${run.file} ${pc.dim("waiting for the verdict…")}`);
570
+ for (;;) {
571
+ await new Promise((r) => setTimeout(r, 1000));
572
+ const { running } = await client.listSpecs(project.id);
573
+ if (!running || running.id !== run.id)
574
+ break;
575
+ }
576
+ const { logs } = await client.logs({ level: undefined });
577
+ const verdict = [...logs].reverse().find((l) => l.scope === "specs" && l.message.includes(run.file));
578
+ if (verdict?.message.includes("passed")) {
579
+ console.log(`${pc.green("✓")} ${verdict.message}`);
580
+ }
581
+ else {
582
+ console.error(pc.red(`✗ ${verdict?.message ?? "no verdict recorded"}`));
583
+ if (verdict?.detail)
584
+ console.error(pc.dim(verdict.detail.split("\n").slice(-12).join("\n")));
585
+ process.exitCode = 1;
586
+ }
587
+ }
588
+ catch (err) {
589
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
590
+ process.exitCode = 1;
591
+ }
592
+ });
593
+ program
594
+ .command("stale")
595
+ .description("sessions that look hung — busy far longer than any plausible turn")
596
+ .action(async () => {
597
+ const client = await ensureDaemon();
598
+ const project = await currentProject(client);
599
+ const { stale } = await client.staleSessions(project.id);
600
+ if (!stale.length)
601
+ return void console.log(pc.dim("nothing looks hung"));
602
+ for (const s of stale) {
603
+ console.log(`${pc.red(s.agentId)} busy ${Math.round(s.busyMs / 60000)}m ${pc.dim(`— loom reap ${s.agentId}`)}`);
604
+ }
605
+ });
606
+ program
607
+ .command("reap <agentId>")
608
+ .description("put a hung session out of its misery and respawn it fresh")
609
+ .action(async (agentId) => {
610
+ const client = await ensureDaemon();
611
+ const project = await currentProject(client);
612
+ try {
613
+ await client.reapSession(project.id, agentId);
614
+ console.log(`${pc.green("✓")} ${agentId} respawned — baton released if it held it`);
615
+ }
616
+ catch (err) {
617
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
618
+ process.exitCode = 1;
619
+ }
620
+ });
621
+ program
622
+ .command("brain:migrate [directory]")
623
+ .description("offline, backed-up JSONL → SQLite history import; conflicting IDs abort")
624
+ .action(async (directory) => {
625
+ if (await daemonRunning())
626
+ throw new Error("stop the Loom daemon before migrating history");
627
+ const dir = path.resolve(directory ?? process.cwd());
628
+ const result = await EventLog.migrateJsonl(path.join(dir, ".loom"));
629
+ console.log(`Imported ${result.imported}; already known ${result.known}. Original JSONL retained. Backup: ${result.backup}`);
630
+ });
631
+ program
632
+ .command("brain:search <query...>")
633
+ .description("retrieval, exactly as a briefing would see it — scores and all")
634
+ .option("--explain", "show the arithmetic per hit (bm25, entity, fuzzy, dense, recency)")
635
+ .action(async (words, opts) => {
636
+ const client = await ensureDaemon();
637
+ const project = await currentProject(client);
638
+ const { hits } = await client.searchBrain(project.id, words.join(" "), {
639
+ explain: Boolean(opts.explain),
640
+ });
641
+ if (!hits.length)
642
+ return void console.log(pc.dim("nothing retrieved — the briefing would carry no memories for this"));
643
+ for (const h of hits) {
644
+ console.log(`${pc.bold((h.score.toFixed(2)))} ${pc.magenta(h.memory.kind.padEnd(10))} ${h.memory.text.slice(0, 100)}`);
645
+ if (opts.explain && h.detail)
646
+ console.log(pc.dim(` ${JSON.stringify(h.detail)}`));
647
+ }
648
+ });
649
+ program
650
+ .command("brain:conflicts")
651
+ .description("units that likely contradict each other, for you to resolve")
652
+ .action(async () => {
653
+ const client = await ensureDaemon();
654
+ const project = await currentProject(client);
655
+ const { conflicts } = await client.brainConflicts(project.id);
656
+ if (!conflicts.length)
657
+ return void console.log(pc.dim("no likely contradictions"));
658
+ for (const c of conflicts) {
659
+ console.log(`${pc.yellow(c.signal)} ${pc.dim(`(${Math.round(c.similarity * 100)}% same topic)`)}`);
660
+ console.log(` A: ${c.a.text.slice(0, 90)}`);
661
+ console.log(` B: ${c.b.text.slice(0, 90)}`);
662
+ }
663
+ });
664
+ program
665
+ .command("mcp:health")
666
+ .description("each MCP server's live state, from the background poll")
667
+ .action(async () => {
668
+ const client = await ensureDaemon();
669
+ const project = await currentProject(client);
670
+ const { health } = await client.mcpHealth(project.id);
671
+ const rows = Object.entries(health);
672
+ if (!rows.length)
673
+ return void console.log(pc.dim("no MCP servers configured (or none probed yet)"));
674
+ for (const [name, h] of rows) {
675
+ const age = Math.round((Date.now() - h.probedAt) / 1000);
676
+ console.log(`${name.padEnd(20)} ${h.up ? pc.green("up") : pc.red(`down ×${h.failures}`)} ${pc.dim(`${age}s ago`)}`);
677
+ }
678
+ });
679
+ /** "KEY=VAL" / "Header: value" pairs from repeated flags. */
680
+ function kv(list, sep) {
681
+ const out = {};
682
+ for (const item of list ?? []) {
683
+ const m = sep.exec(item);
684
+ if (!m || m.index === 0)
685
+ throw new Error(`"${item}" isn't a pair`);
686
+ out[item.slice(0, m.index).trim()] = item.slice(m.index + m[0].length).trim();
687
+ }
688
+ return out;
689
+ }
690
+ const collect = (v, prev = []) => [...prev, v];
691
+ program
692
+ .command("prompts [action] [args...]")
693
+ .description("saved prompts, shared with the app: list [query] | save <text> | show <id> | send <id> | pin <id> | unpin <id> | rename <id> <title> | rm <id>")
694
+ .option("-t, --title <title>", "save: a title (else the first words)")
695
+ .option("-a, --agent <id>", "send: address a specific agent")
696
+ .action(async (action, args, opts) => {
697
+ const client = await ensureDaemon();
698
+ const a = (action ?? "list").toLowerCase();
699
+ const rest = (args ?? []).join(" ").trim();
700
+ const find = async (ref) => {
701
+ const { saved } = await client.prompts();
702
+ const hit = saved.find((p) => p.id === ref) ?? saved.find((p) => p.id.startsWith(ref)) ??
703
+ saved.find((p) => p.title.toLowerCase() === ref.toLowerCase());
704
+ if (!hit)
705
+ throw new Error(`no saved prompt "${ref}" — loom prompts lists them`);
706
+ return hit;
707
+ };
708
+ if (a === "list" || a === "ls") {
709
+ const { saved } = await client.prompts(rest);
710
+ if (!saved.length)
711
+ return void console.log(pc.dim(rest ? `no saved prompt matches "${rest}"` : "no saved prompts yet — loom prompts save \"…\" (or ⌘⇧V in the app)"));
712
+ for (const p of saved) {
713
+ console.log(`${pc.dim(p.id.slice(0, 8))} ${p.pinned ? pc.yellow("★ ") : ""}${pc.bold(p.title)} ${pc.dim(`${p.uses}×`)}`);
714
+ console.log(` ${pc.dim(p.text.replace(/\s+/g, " ").slice(0, 90))}`);
715
+ }
716
+ return;
717
+ }
718
+ if (a === "save" || a === "add") {
719
+ const text = rest || (process.stdin.isTTY ? "" : fs.readFileSync(0, "utf8"));
720
+ if (!text.trim())
721
+ throw new Error("what should it say? loom prompts save \"review this diff for…\" (or pipe it in)");
722
+ const { prompt } = await client.savePrompt({ text, ...(opts.title ? { title: opts.title } : {}) });
723
+ return void console.log(`${pc.green("✓")} saved ${pc.bold(prompt.title)} ${pc.dim(prompt.id.slice(0, 8))}`);
724
+ }
725
+ if (!["show", "cat", "send", "use", "pin", "unpin", "rename", "rm", "delete"].includes(a)) {
726
+ throw new Error(`unknown action "${a}" — list | save | show | send | pin | unpin | rename | rm`);
727
+ }
728
+ if (!rest)
729
+ throw new Error(`which prompt? loom prompts ${a} <id or title>`);
730
+ const [ref, ...more] = rest.split(/\s+/);
731
+ const p = await find(a === "rename" ? ref : rest);
732
+ if (a === "show" || a === "cat")
733
+ return void process.stdout.write(p.text + "\n");
734
+ if (a === "send" || a === "use") {
735
+ const project = await currentProject(client);
736
+ const now = new Date();
737
+ const text = p.text
738
+ .replace(/\{\{date\}\}/g, now.toLocaleDateString())
739
+ .replace(/\{\{time\}\}/g, now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))
740
+ .replace(/\{\{project\}\}/g, project.name)
741
+ .replace(/\{\{(?:selection|branch|chat|agent|last_reply|file)\}\}/g, "");
742
+ const result = await sendWithHandoffConfirm(client, project.id, text, opts.agent);
743
+ await client.updatePrompt(p.id, { used: true }).catch(() => { });
744
+ if (result)
745
+ console.log(pc.dim(`→ sent "${p.title}" to ${result.agentId} (loom log --follow to watch)`));
746
+ return;
747
+ }
748
+ if (a === "pin" || a === "unpin") {
749
+ await client.updatePrompt(p.id, { pinned: a === "pin" });
750
+ return void console.log(`${pc.green("✓")} ${a === "pin" ? "pinned" : "unpinned"} ${p.title}`);
751
+ }
752
+ if (a === "rename") {
753
+ const title = more.join(" ").trim();
754
+ if (!title)
755
+ throw new Error("loom prompts rename <id> <new title>");
756
+ await client.updatePrompt(p.id, { title });
757
+ return void console.log(`${pc.green("✓")} renamed to ${pc.bold(title)}`);
758
+ }
759
+ if (a === "rm" || a === "delete") {
760
+ await client.deletePrompt(p.id);
761
+ return void console.log(`${pc.green("✓")} removed ${p.title}`);
762
+ }
763
+ throw new Error(`unknown action "${a}" — list | save | show | send | pin | unpin | rename | rm`);
764
+ });
765
+ program
766
+ .command("skills [action] [args...]")
767
+ .description("skills for this project: list [query] | on <id> | off <id> | install <git-url|dir>[#name|#all] | rm <id>")
768
+ .option("--all", "list: every skill found on this machine, not just the ones on")
769
+ .option("--force", "install: replace a skill with the same name")
770
+ .action(async (action, args, opts) => {
771
+ const client = await ensureDaemon();
772
+ const project = await currentProject(client);
773
+ const a = (action ?? "list").toLowerCase();
774
+ const rest = (args ?? []).join(" ").trim();
775
+ if (a === "list" || a === "ls") {
776
+ const { skills } = await client.skillsCatalog(project.id);
777
+ const q = rest.toLowerCase();
778
+ const rows = skills.filter((k) => (opts.all || q || k.enabled) && (!q || `${k.id} ${k.name ?? ""} ${k.description ?? ""}`.toLowerCase().includes(q)));
779
+ const on = skills.filter((k) => k.enabled).length;
780
+ console.log(pc.dim(`${on} on · ${skills.length} found${opts.all || q ? "" : " — loom skills list --all shows them all"}`));
781
+ for (const k of rows) {
782
+ console.log(`${k.enabled ? pc.green("●") : pc.dim("○")} ${pc.bold(k.id)}${k.installed ? pc.dim(" (this project)") : k.origin ? pc.dim(` (${k.origin})`) : ""}`);
783
+ if (k.description)
784
+ console.log(` ${pc.dim(k.description.replace(/\s+/g, " ").slice(0, 96))}`);
785
+ }
786
+ return;
787
+ }
788
+ if (!["on", "off", "install", "add", "rm", "remove"].includes(a))
789
+ throw new Error(`unknown action "${a}" — list | on | off | install | rm`);
790
+ if (!rest)
791
+ throw new Error(`loom skills ${a} <${a === "install" ? "git url or folder" : "skill id"}>`);
792
+ if (a === "on" || a === "off") {
793
+ await client.setSkill(project.id, rest, a === "on");
794
+ return void console.log(`${pc.green("✓")} ${rest} ${a === "on" ? "on" : "off"} for ${project.name}`);
795
+ }
796
+ if (a === "install" || a === "add") {
797
+ const local = !/^(https?:|git@|ssh:)/.test(rest) && fs.existsSync(rest.replace(/#.*$/, ""));
798
+ const { skill } = await client.installSkill(project.id, local
799
+ ? { dir: path.resolve(rest), ...(opts.force ? { force: true } : {}) }
800
+ : { gitUrl: rest, ...(opts.force ? { force: true } : {}) });
801
+ const names = [skill.id, ...(skill.also ?? [])];
802
+ for (const n of names)
803
+ await client.setSkill(project.id, n, true).catch(() => { });
804
+ return void console.log(`${pc.green("✓")} installed and turned on ${names.map((n) => pc.bold(n)).join(", ")}`);
805
+ }
806
+ if (a === "rm" || a === "remove") {
807
+ await client.removeSkill(project.id, rest);
808
+ return void console.log(`${pc.green("✓")} removed ${rest} from ${project.name}`);
809
+ }
810
+ throw new Error(`unknown action "${a}" — list | on | off | install | rm`);
811
+ });
812
+ program
813
+ .command("mcp [action] [args...]")
814
+ .description("MCP servers for this project: list | add <name> (--url <url> | --command <cmd> [args…]) | on <name> | off <name> | rm <name>")
815
+ .option("--url <url>", "add: a remote server's endpoint")
816
+ .option("--command <cmd>", "add: a local server to run (args follow the name)")
817
+ .option("-H, --header <header>", "add: 'Name: value', repeatable", collect)
818
+ .option("-e, --env <pair>", "add: KEY=VALUE for a local server, repeatable", collect)
819
+ // the server's own flags (npx -y …) ride along as its args; put them after -- if they clash with ours
820
+ .allowUnknownOption()
821
+ .action(async (action, args, opts) => {
822
+ const client = await ensureDaemon();
823
+ const project = await currentProject(client);
824
+ const a = (action ?? "list").toLowerCase();
825
+ const [name, ...more] = args ?? [];
826
+ if (a === "list" || a === "ls") {
827
+ const { mcps } = await client.mcps(project.id);
828
+ const rows = mcps.filter((m) => m.url || m.command);
829
+ if (!rows.length)
830
+ return void console.log(pc.dim("no MCP servers yet — loom mcp add github --url https://api.githubcopilot.com/mcp/ (or Skills & MCP in the app)"));
831
+ for (const m of rows) {
832
+ const state = m.enabledForSession === false ? pc.dim("off") : m.command ? pc.cyan("local") : m.connected ? pc.green("up") : pc.red("unreachable");
833
+ console.log(`${m.name.padEnd(20)} ${state.padEnd(20)} ${pc.dim(m.url || [m.command, ...(m.args ?? [])].join(" "))}`);
834
+ }
835
+ console.log(pc.dim("Claude Code and Codex load these; other agents don't speak MCP."));
836
+ return;
837
+ }
838
+ if (!["add", "install", "on", "off", "rm", "remove"].includes(a))
839
+ throw new Error(`unknown action "${a}" — list | add | on | off | rm`);
840
+ if (!name)
841
+ throw new Error(`loom mcp ${a} <name>`);
842
+ if (a === "add" || a === "install") {
843
+ if (!opts.url && !opts.command)
844
+ throw new Error("add needs --url <endpoint> or --command <program>");
845
+ const { installed } = await client.installMcp(project.id, {
846
+ name,
847
+ ...(opts.url ? { url: opts.url } : {}),
848
+ ...(opts.command ? { command: opts.command, ...(more.length ? { args: more } : {}) } : {}),
849
+ ...(opts.header?.length ? { headers: kv(opts.header, /:\s*/) } : {}),
850
+ ...(opts.env?.length ? { env: kv(opts.env, /=/) } : {}),
851
+ });
852
+ const note = opts.command ? "it starts when an agent needs it" : installed?.connected ? "it answered" : "it didn't answer yet — check the URL, or it may need sign-in headers (-H)";
853
+ return void console.log(`${pc.green("✓")} added ${pc.bold(name)} — ${note}`);
854
+ }
855
+ if (a === "on" || a === "off") {
856
+ const { mcps } = await client.mcps(project.id, false);
857
+ const row = mcps.find((m) => m.name === name);
858
+ if (!row)
859
+ throw new Error(`no MCP server "${name}" — loom mcp lists them`);
860
+ const { connected: _c, ...keep } = row;
861
+ await client.patchMcp(project.id, { ...keep, enabledForSession: a === "on" });
862
+ return void console.log(`${pc.green("✓")} ${name} ${a}`);
863
+ }
864
+ if (a === "rm" || a === "remove") {
865
+ await client.removeMcp(project.id, name);
866
+ return void console.log(`${pc.green("✓")} removed ${name}`);
867
+ }
868
+ throw new Error(`unknown action "${a}" — list | add | on | off | rm`);
869
+ });
870
+ program
871
+ .command("snapshot [file]")
872
+ .description("checkpoint this project's brain + board + config to a file")
873
+ .action(async (file) => {
874
+ const client = await ensureDaemon();
875
+ const project = await currentProject(client);
876
+ const doc = await client.snapshot(project.id);
877
+ const out = file ?? `loom-snapshot-${project.name}.json`;
878
+ const fs = await import("node:fs");
879
+ fs.writeFileSync(out, JSON.stringify(doc, null, 2) + "\n");
880
+ const mems = (doc.brain?.memories ?? []).length;
881
+ const tasks = (doc.tasks ?? []).length;
882
+ console.log(`${pc.green("✓")} ${out} ${pc.dim(`· ${mems} memories · ${tasks} cards`)}`);
883
+ });
884
+ program
885
+ .command("restore <file>")
886
+ .description("bring a snapshot back — board and config replace, the brain merges")
887
+ .action(async (file) => {
888
+ const client = await ensureDaemon();
889
+ const project = await currentProject(client);
890
+ const fs = await import("node:fs");
891
+ let doc;
892
+ try {
893
+ doc = JSON.parse(fs.readFileSync(file, "utf8"));
894
+ }
895
+ catch (err) {
896
+ console.error(pc.red(`could not read ${file}: ${err instanceof Error ? err.message : err}`));
897
+ process.exitCode = 1;
898
+ return;
899
+ }
900
+ try {
901
+ const out = await client.restore(project.id, doc);
902
+ console.log(`${pc.green("✓")} restored ${pc.dim(`· brain +${out.brain.added} (${out.brain.known} known) · ${out.tasks} cards`)}`);
903
+ }
904
+ catch (err) {
905
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
906
+ process.exitCode = 1;
907
+ }
908
+ });
909
+ /**
910
+ * Rewind (#101). Deliberately a sibling of snapshot/restore above and not a
911
+ * flag on it: that pair moves the brain, the board and the config and leaves
912
+ * files to git. This one is the files, and leaves those alone. One command
913
+ * that did both would be a command nobody could predict.
914
+ */
915
+ program
916
+ .command("rewind [checkpoint]")
917
+ .description("put the working tree back to a checkpoint (no argument lists them)")
918
+ .option("-y, --yes", "don't ask")
919
+ .action(async (checkpoint, opts) => {
920
+ const client = await ensureDaemon();
921
+ const project = await currentProject(client);
922
+ const { checkpoints } = await client.checkpoints(project.id);
923
+ if (!checkpoints.length) {
924
+ console.log(pc.dim("no checkpoints yet — one is taken before every turn, in a git repository"));
925
+ return;
926
+ }
927
+ if (!checkpoint) {
928
+ for (const c of checkpoints.slice(0, 20)) {
929
+ console.log(` ${pc.bold(c.id)} ${pc.dim(new Date(c.at).toLocaleString())} ${c.label}`);
930
+ }
931
+ console.log(pc.dim(`
932
+ loom rewind <id> · ${checkpoints.length} checkpoint${checkpoints.length === 1 ? "" : "s"}`));
933
+ return;
934
+ }
935
+ const target = checkpoints.find((c) => c.id === checkpoint);
936
+ if (!target) {
937
+ console.error(pc.red(`no checkpoint "${checkpoint}" — run \`loom rewind\` to see them`));
938
+ process.exitCode = 1;
939
+ return;
940
+ }
941
+ if (!opts.yes) {
942
+ console.log(`${pc.yellow("!")} this puts the files back to ${pc.bold(target.label)}`);
943
+ console.log(pc.dim(" anything written since is removed; commits, history and ignored files are untouched"));
944
+ console.log(pc.dim(" the rewind is itself saved, so it can be undone"));
945
+ const ok = await confirm("rewind?");
946
+ if (!ok)
947
+ return;
948
+ }
949
+ try {
950
+ const out = await client.rewind(project.id, target.id);
951
+ console.log(`${pc.green("✓")} rewound ${pc.dim(`· ${out.changed.length} file${out.changed.length === 1 ? "" : "s"} · undo with \`loom rewind ${out.undo.id}\``)}`);
952
+ }
953
+ catch (err) {
954
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
955
+ process.exitCode = 1;
956
+ }
957
+ });
958
+ program
959
+ .command("task <title...>")
960
+ .description("put a card on the board")
961
+ .option("--agent <id>", "who it's for")
962
+ .option("--column <col>", "working | needs-you | in-review | ready", "needs-you")
963
+ .option("--blocked-by <ids>", "comma-separated task ids this one waits on")
964
+ .action(async (words, opts) => {
965
+ const client = await ensureDaemon();
966
+ const project = await currentProject(client);
967
+ const { task } = await client.createTask(project.id, words.join(" "), {
968
+ column: opts.column,
969
+ ...(opts.agent ? { agent: opts.agent } : {}),
970
+ ...(opts.blockedBy ? { blockedBy: opts.blockedBy.split(",").map((s) => s.trim()) } : {}),
971
+ });
972
+ console.log(`${pc.green("+")} ${task.id} ${pc.dim(task.column)} ${task.title}`);
973
+ });
974
+ program
975
+ .command("tasks")
976
+ .description("the cards you wrote, by column")
977
+ .option("--json", "print as JSON")
978
+ .action(async (opts) => {
979
+ const client = await ensureDaemon();
980
+ const project = await currentProject(client);
981
+ const { tasks } = await client.listTasks(project.id);
982
+ if (opts.json)
983
+ return void console.log(JSON.stringify(tasks, null, 2));
984
+ if (!tasks.length)
985
+ return void console.log(pc.dim("no cards — loom task <title> makes one"));
986
+ for (const t of tasks) {
987
+ const blocked = t.blockedBy?.length ? pc.red(` ⛔${t.blockedBy.join(",")}`) : "";
988
+ console.log(`${t.id} ${t.column.padEnd(9)} ${t.agent ? pc.cyan(t.agent.padEnd(14)) : "".padEnd(14)} ${t.title}${blocked}`);
989
+ }
990
+ });
991
+ program
992
+ .command("budgets")
993
+ .description("each agent's daily USD cap and today's real spend against it")
994
+ .action(async () => {
995
+ const client = await ensureDaemon();
996
+ const project = await currentProject(client);
997
+ const { status } = await client.budgets(project.id);
998
+ const rows = Object.entries(status);
999
+ if (!rows.length) {
1000
+ console.log(pc.dim("no caps set — loom budget <agentId> <usd> to add one"));
1001
+ return;
1002
+ }
1003
+ for (const [agentId, s] of rows) {
1004
+ const bar = s.over ? pc.red("OVER — paused") : pc.dim(`${Math.round((s.spentTodayUsd / s.budgetUsd) * 100)}%`);
1005
+ console.log(`${agentId.padEnd(18)} $${s.spentTodayUsd.toFixed(2)} / $${s.budgetUsd.toFixed(2)} ${bar}`);
1006
+ }
1007
+ });
1008
+ program
1009
+ .command("budget <agentId> [usd]")
1010
+ .description("cap an agent's daily spend in USD — agentId 'all' caps every adapter (0 clears)")
1011
+ .action(async (agentId, usd) => {
1012
+ const client = await ensureDaemon();
1013
+ const project = await currentProject(client);
1014
+ const value = usd === undefined ? 0 : Number(usd);
1015
+ if (Number.isNaN(value) || value < 0) {
1016
+ console.error(pc.red(`"${usd}" is not a dollar amount`));
1017
+ process.exitCode = 1;
1018
+ return;
1019
+ }
1020
+ if (agentId === "all") {
1021
+ const adapters = project.agents.filter((a) => a.tier === "adapter");
1022
+ for (const a of adapters)
1023
+ await client.setBudget(project.id, a.id, value);
1024
+ console.log(value > 0
1025
+ ? `${pc.green("✓")} ${adapters.length} adapters capped at $${value.toFixed(2)}/day each`
1026
+ : `${pc.green("✓")} ${adapters.length} adapters uncapped`);
1027
+ return;
1028
+ }
1029
+ await client.setBudget(project.id, agentId, value);
1030
+ console.log(value > 0
1031
+ ? `${pc.green("✓")} ${agentId} capped at $${value.toFixed(2)}/day — over the cap it pauses, hard`
1032
+ : `${pc.green("✓")} ${agentId} uncapped`);
1033
+ });
1034
+ program
1035
+ .command("watch")
1036
+ .description("tail this project's events live — turns, handoffs, memory, subtasks")
1037
+ .option("--all", "watch every project, prefixed with the project id")
1038
+ .option("--json", "one JSON event per line, for piping")
1039
+ .action(async (opts) => {
1040
+ const client = await ensureDaemon();
1041
+ const project = opts.all ? null : await currentProject(client);
1042
+ /**
1043
+ * One line per event, shaped for a human at a terminal. Everything else in
1044
+ * the log still flows — this names the kinds worth a glance and lets the
1045
+ * rest pass as a dim one-liner rather than pretending they don't exist.
1046
+ */
1047
+ const line = (e) => {
1048
+ const t = new Date(e.ts).toTimeString().slice(0, 8);
1049
+ const who = e.agentId ? pc.cyan(e.agentId) : pc.dim("you");
1050
+ const p = e.payload;
1051
+ switch (e.kind) {
1052
+ case "message":
1053
+ return `${t} ${who} ${String(p.text ?? "").slice(0, 100)}`;
1054
+ case "handoff":
1055
+ return `${t} ${pc.yellow("baton")} ${String(p.from ?? "—")} → ${String(p.to ?? "—")}`;
1056
+ case "run_complete":
1057
+ return `${t} ${who} ${pc.green("turn done")}`;
1058
+ case "needs_input":
1059
+ return `${t} ${who} ${pc.red("needs you")}: ${String(p.question ?? "").slice(0, 80)}`;
1060
+ case "decision":
1061
+ return `${t} ${who} ${pc.magenta("decision")} ${String(p.text ?? "").slice(0, 90)}`;
1062
+ case "memory_add":
1063
+ return `${t} ${who} ${pc.magenta("learned")} ${String(p.memory?.text ?? "").slice(0, 90)}`;
1064
+ case "subtask_started":
1065
+ return `${t} ${who} ${pc.cyan("subtask")} for ${String(p.parent)}: ${String(p.task ?? "").slice(0, 70)}`;
1066
+ case "subtask_done":
1067
+ return `${t} ${who} ${pc.green("subtask done")}`;
1068
+ case "subtask_failed":
1069
+ return `${t} ${who} ${pc.red("subtask failed")}: ${String(p.message ?? "").slice(0, 80)}`;
1070
+ case "error":
1071
+ return `${t} ${who} ${pc.red("error")} ${String(p.message ?? "").slice(0, 90)}`;
1072
+ case "route_started":
1073
+ case "route_step":
1074
+ case "route_completed":
1075
+ case "route_failed":
1076
+ return `${t} ${pc.yellow(e.kind.replace("route_", "route "))} ${String(p.route ?? p.name ?? "")}`;
1077
+ default:
1078
+ return pc.dim(`${t} ${e.kind}`);
1079
+ }
1080
+ };
1081
+ console.log(pc.dim(project
1082
+ ? `watching ${project.name} — ^C to stop`
1083
+ : "watching every project — ^C to stop"));
1084
+ const close = client.subscribe((pid, e) => {
1085
+ if (opts.json) {
1086
+ console.log(JSON.stringify(opts.all ? { project: pid, ...e } : e));
1087
+ return;
1088
+ }
1089
+ const prefix = opts.all ? pc.dim(`[${pid.slice(0, 8)}] `) : "";
1090
+ console.log(prefix + line(e));
1091
+ }, project?.id);
1092
+ // Keep the process alive until ^C; close the socket on the way out so the
1093
+ // daemon isn't left holding a dead subscriber.
1094
+ await new Promise((resolve) => {
1095
+ process.on("SIGINT", () => {
1096
+ close();
1097
+ console.log();
1098
+ resolve();
1099
+ });
1100
+ });
1101
+ });
1102
+ program
1103
+ .command("brain:export [file]")
1104
+ .description("write this project's brain to a file (or stdout) so it can travel")
1105
+ .action(async (file) => {
1106
+ const client = await ensureDaemon();
1107
+ const project = await currentProject(client);
1108
+ const doc = await client.exportBrain(project.id);
1109
+ const json = JSON.stringify(doc, null, 2);
1110
+ if (file) {
1111
+ const fs = await import("node:fs");
1112
+ fs.writeFileSync(file, json + "\n");
1113
+ const n = doc.memories?.length ?? 0;
1114
+ console.log(`${pc.green("✓")} ${n} memor${n === 1 ? "y" : "ies"} → ${file}`);
1115
+ }
1116
+ else {
1117
+ console.log(json);
1118
+ }
1119
+ });
1120
+ program
1121
+ .command("brain:import <file>")
1122
+ .description("bring an exported brain into this project (dedupes what's already known)")
1123
+ .action(async (file) => {
1124
+ const client = await ensureDaemon();
1125
+ const project = await currentProject(client);
1126
+ const fs = await import("node:fs");
1127
+ let doc;
1128
+ try {
1129
+ doc = JSON.parse(fs.readFileSync(file, "utf8"));
1130
+ }
1131
+ catch (err) {
1132
+ console.error(pc.red(`could not read ${file}: ${err instanceof Error ? err.message : err}`));
1133
+ process.exitCode = 1;
1134
+ return;
1135
+ }
1136
+ try {
1137
+ const { added, known } = await client.importBrain(project.id, doc);
1138
+ console.log(`${pc.green("✓")} ${added} learned${known ? pc.dim(` · ${known} already known`) : ""}`);
1139
+ }
1140
+ catch (err) {
1141
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
1142
+ process.exitCode = 1;
1143
+ }
1144
+ });
1145
+ program
1146
+ .command("spawn <task>")
1147
+ .description("fan a subtask out to a child agent — the parent keeps the baton")
1148
+ .option("--agent <id>", "which agent runs the subtask")
1149
+ .option("--parent <id>", "the agent asking (default: whoever holds the baton)")
1150
+ .action(async (task, opts) => {
1151
+ const client = await ensureDaemon();
1152
+ const project = await currentProject(client);
1153
+ const parent = opts.parent ?? project.holder ?? project.agents[0]?.id;
1154
+ if (!parent) {
1155
+ console.error(pc.red("this project has no agents to parent a subtask"));
1156
+ process.exitCode = 1;
1157
+ return;
1158
+ }
1159
+ // Default the child to an agent that isn't the parent — the whole point is
1160
+ // another pair of hands, so borrowing the parent's own would just be a turn.
1161
+ const child = opts.agent ??
1162
+ project.agents.find((a) => a.id !== parent && a.tier === "adapter" && a.enabled !== false)?.id;
1163
+ if (!child) {
1164
+ console.error(pc.red("no other adapter available to run the subtask — add one, or pass --agent"));
1165
+ process.exitCode = 1;
1166
+ return;
1167
+ }
1168
+ try {
1169
+ const out = await client.spawnSubtask(project.id, parent, child, task);
1170
+ console.log(`${pc.cyan("↳")} ${pc.bold(out.agentId)} ${pc.dim(`subtask ${out.id}`)}\n` +
1171
+ pc.dim(` ${parent} keeps the baton; the result lands in the thread under its turn`));
1172
+ }
1173
+ catch (err) {
1174
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
1175
+ process.exitCode = 1;
1176
+ }
1177
+ });
1178
+ // ── orchestra: one orchestrator, many parallel workers (core/orchestra.ts) ──
1179
+ const RUN_DONE = new Set(["completed", "failed", "aborted", "waiting_human"]);
1180
+ const TASK_COLOR = {
1181
+ done: pc.green,
1182
+ running: pc.cyan,
1183
+ pending: pc.dim,
1184
+ failed: pc.red,
1185
+ conflict: pc.yellow,
1186
+ needs_input: pc.yellow,
1187
+ cancelled: pc.dim,
1188
+ };
1189
+ function printRun(run) {
1190
+ const done = run.tasks.filter((t) => t.status === "done").length;
1191
+ console.log(run.race
1192
+ ? `${pc.magenta("🏁")} ${pc.bold(run.id)} ${pc.dim(run.status)} race · ${done}/${run.tasks.length} finished · ${fmtUsd(run.costUsd)}`
1193
+ : `${pc.magenta("🎼")} ${pc.bold(run.id)} ${pc.dim(run.status)} ${done}/${run.tasks.length} tasks · ` +
1194
+ `round ${run.round}/${run.maxRounds} · ${fmtUsd(run.costUsd)} · ${pc.dim(run.branch)}`);
1195
+ console.log(` ${pc.dim("goal")} ${run.goal}`);
1196
+ if (run.race)
1197
+ console.log(` ${pc.dim("entrants")} ${run.workers.join(", ")}`);
1198
+ else
1199
+ console.log(` ${pc.dim("orchestrator")} ${run.orchestrator.agent} ${pc.dim("workers")} ${run.workers.join(", ")}`);
1200
+ for (const t of run.tasks) {
1201
+ const color = TASK_COLOR[t.status] ?? ((x) => x);
1202
+ const deps = t.dependsOn.length ? pc.dim(` after ${t.dependsOn.join(",")}`) : "";
1203
+ const files = t.files?.length ? pc.dim(` · ${t.files.length} file${t.files.length === 1 ? "" : "s"}`) : "";
1204
+ console.log(` ${color(t.status.padEnd(11))} ${pc.bold(t.id)} ${t.title} ${pc.dim(`→ ${t.agent}`)}${deps}${files}`);
1205
+ if (t.hold)
1206
+ console.log(` ${pc.yellow(`held (${t.hold.kind}):`)} ${t.hold.reason.slice(0, 200)}`);
1207
+ if (t.error)
1208
+ console.log(` ${pc.red(t.error.split("\n")[0].slice(0, 160))}`);
1209
+ }
1210
+ if (run.question)
1211
+ console.log(` ${pc.yellow("asks:")} ${run.question} ${pc.dim(`(loom orchestra:reply ${run.id} "…")`)}`);
1212
+ if (run.summary)
1213
+ console.log(` ${pc.green("summary:")} ${run.summary}`);
1214
+ if (run.error)
1215
+ console.log(` ${pc.red("error:")} ${run.error}`);
1216
+ if (run.status === "completed" && !run.applied) {
1217
+ if (run.race) {
1218
+ const ok = run.tasks.filter((t) => t.status === "done").map((t) => t.id);
1219
+ if (ok.length)
1220
+ console.log(pc.dim(` keep one: loom orchestra:apply ${run.id} --task ${ok.join(" | ")}`));
1221
+ }
1222
+ else
1223
+ console.log(pc.dim(` apply it: loom orchestra:apply ${run.id}`));
1224
+ }
1225
+ if (run.race && run.applied?.task)
1226
+ console.log(` ${pc.green("kept:")} ${run.applied.task} → ${run.applied.into}`);
1227
+ }
1228
+ async function watchRun(client, projectId, runId) {
1229
+ let last = "";
1230
+ for (;;) {
1231
+ const { run } = await client.orchestraRun(projectId, runId);
1232
+ const line = `${run.status}|${run.tasks.map((t) => `${t.id}:${t.status}`).join(",")}`;
1233
+ if (line !== last) {
1234
+ last = line;
1235
+ const stamp = new Date().toLocaleTimeString();
1236
+ console.log(pc.dim(stamp) + " " + pc.bold(run.status) + " " +
1237
+ run.tasks.map((t) => (TASK_COLOR[t.status] ?? ((x) => x))(`${t.id}[${t.agent}]`)).join(" "));
1238
+ }
1239
+ if (RUN_DONE.has(run.status)) {
1240
+ console.log("");
1241
+ printRun(run);
1242
+ if (run.status === "failed" || run.status === "aborted")
1243
+ process.exitCode = 1;
1244
+ return;
1245
+ }
1246
+ await new Promise((r) => setTimeout(r, 1500));
1247
+ }
1248
+ }
1249
+ program
1250
+ .command("orchestrate <goal>")
1251
+ .description("one orchestrator plans the goal; many worker agents build it in parallel, each in its own worktree")
1252
+ .option("-o, --orchestrator <agent>", "who plans and reviews (default: claude-code if present)")
1253
+ .option("-w, --workers <agents>", "comma-separated worker agents (default: every agent in the project)")
1254
+ .option("-p, --parallel <n>", "tasks running at once (1-12, default 4)")
1255
+ .option("--rounds <n>", "orchestrator review rounds before giving up (default 10)")
1256
+ .option("--plan", "plan mode: write the plan as markdown specs under plans/<run>/ that any agent can pick up")
1257
+ .option("--max-usd <n>", "stop this goal when it has spent this much, and say so")
1258
+ .option("--race", "race: every worker takes the whole goal in its own worktree; pick one with orchestra:apply <run> --task race-<agent>")
1259
+ .option("--no-watch", "start it and return instead of following it to the end")
1260
+ .action(async (goal, opts) => {
1261
+ const client = await ensureDaemon();
1262
+ const project = await currentProject(client);
1263
+ try {
1264
+ const { run } = await client.startOrchestra(project.id, {
1265
+ goal,
1266
+ ...(opts.orchestrator ? { orchestrator: opts.orchestrator } : {}),
1267
+ ...(opts.workers ? { workers: opts.workers.split(",").map((w) => w.trim()).filter(Boolean) } : {}),
1268
+ ...(opts.parallel ? { maxParallel: Number(opts.parallel) } : {}),
1269
+ ...(opts.rounds ? { maxRounds: Number(opts.rounds) } : {}),
1270
+ ...(opts.plan ? { plan: true } : {}),
1271
+ ...(opts.maxUsd ? { maxUsd: Number(opts.maxUsd) } : {}),
1272
+ ...(opts.race ? { race: true } : {}),
1273
+ });
1274
+ console.log(run.race
1275
+ ? `${pc.magenta("🏁")} ${pc.bold(run.id)} started — ${run.workers.join(", ")} racing on the same goal`
1276
+ : `${pc.magenta("🎼")} ${pc.bold(run.id)} started — ${pc.bold(run.orchestrator.agent)} orchestrating ` +
1277
+ `${run.workers.join(", ")} (${run.maxParallel} in parallel) on ${pc.dim(run.branch)}`);
1278
+ if (opts.watch)
1279
+ await watchRun(client, project.id, run.id);
1280
+ else
1281
+ console.log(pc.dim(` follow it: loom orchestra ${run.id} --watch`));
1282
+ }
1283
+ catch (err) {
1284
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
1285
+ process.exitCode = 1;
1286
+ }
1287
+ });
1288
+ program
1289
+ .command("orchestra [runId]")
1290
+ .description("orchestra runs in this project — or one run in detail")
1291
+ .option("--watch", "follow the run until it finishes")
1292
+ .action(async (runId, opts) => {
1293
+ const client = await ensureDaemon();
1294
+ const project = await currentProject(client);
1295
+ try {
1296
+ if (runId) {
1297
+ if (opts.watch)
1298
+ return void (await watchRun(client, project.id, runId));
1299
+ return void printRun((await client.orchestraRun(project.id, runId)).run);
1300
+ }
1301
+ const { runs } = await client.orchestraRuns(project.id);
1302
+ if (!runs.length)
1303
+ return void console.log(pc.dim('no orchestra runs yet — loom orchestrate "<goal>"'));
1304
+ for (const r of runs.slice(0, 15)) {
1305
+ const done = r.tasks.filter((t) => t.status === "done").length;
1306
+ console.log(`${pc.bold(r.id)} ${r.status.padEnd(13)} ${String(done).padStart(2)}/${String(r.tasks.length).padEnd(2)} ` +
1307
+ `${pc.dim(new Date(r.createdAt).toLocaleString())} ${r.goal.slice(0, 70)}`);
1308
+ }
1309
+ }
1310
+ catch (err) {
1311
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
1312
+ process.exitCode = 1;
1313
+ }
1314
+ });
1315
+ for (const action of ["abort", "apply", "cleanup", "resume"]) {
1316
+ const blurb = {
1317
+ abort: "stop a run — every worker is interrupted",
1318
+ resume: "carry on a run Loom stopped by restarting — its interrupted tasks pick up where they left off",
1319
+ apply: "merge a run's integration branch into your current branch (a race: --task <entrant>)",
1320
+ cleanup: "remove a finished run's worktrees (its branch stays)",
1321
+ }[action];
1322
+ program
1323
+ .command(`orchestra:${action} <runId>`)
1324
+ .description(blurb)
1325
+ .option("--task <id>", "apply: which race entrant to keep (race-<agent>)")
1326
+ .action(async (runId, opts) => {
1327
+ const client = await ensureDaemon();
1328
+ const project = await currentProject(client);
1329
+ try {
1330
+ const out = await client.orchestraAction(project.id, runId, action, action === "apply" && opts.task ? { task: opts.task } : undefined);
1331
+ if (action === "apply")
1332
+ console.log(`${pc.green("✓")} merged ${pc.bold(String(out.merged))} into ${pc.bold(String(out.into))}`);
1333
+ else
1334
+ console.log(`${pc.green("✓")} ${action} ${runId}`);
1335
+ }
1336
+ catch (err) {
1337
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
1338
+ process.exitCode = 1;
1339
+ }
1340
+ });
1341
+ }
1342
+ program
1343
+ .command("orchestra:reply <runId> <text>")
1344
+ .description("answer the orchestrator's question, or steer a running orchestra")
1345
+ .action(async (runId, text) => {
1346
+ const client = await ensureDaemon();
1347
+ const project = await currentProject(client);
1348
+ try {
1349
+ await client.orchestraAction(project.id, runId, "reply", { text });
1350
+ console.log(`${pc.green("✓")} sent to the orchestrator`);
1351
+ }
1352
+ catch (err) {
1353
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
1354
+ process.exitCode = 1;
1355
+ }
1356
+ });
1357
+ // ── Loom Teams (docs/teams-architecture.md, daemon/team.ts) ──
1358
+ program
1359
+ .command("hub")
1360
+ .description("run a self-hosted Team Hub for your team (no Supabase needed)")
1361
+ .option("--port <n>", "port", "7430")
1362
+ .option("--host <ip>", "interface to bind (use your LAN/tailnet IP so teammates can reach it)", "127.0.0.1")
1363
+ .option("--secret <s>", "join secret every member must present (or LOOM_HUB_SECRET)")
1364
+ .action(async (opts) => {
1365
+ const secret = opts.secret || process.env.LOOM_HUB_SECRET;
1366
+ const loopback = opts.host === "127.0.0.1" || opts.host === "localhost";
1367
+ if (!loopback && !secret) {
1368
+ console.error(pc.red("a hub reachable by others needs a join secret: --secret <s> (or LOOM_HUB_SECRET)"));
1369
+ process.exitCode = 1;
1370
+ return;
1371
+ }
1372
+ const { startHubServer } = await import("../hub/server.js");
1373
+ const hub = await startHubServer({ port: Number(opts.port), host: opts.host, ...(secret ? { secret } : {}) });
1374
+ console.log(`${pc.green("●")} Loom Team Hub on ${pc.bold(hub.url)}`);
1375
+ console.log(pc.dim(` members sign in with: loom team signin ${hub.url}${secret ? " --secret <secret>" : ""}`));
1376
+ console.log(pc.dim(" state is in memory: members' daemons re-join on their own after a restart"));
1377
+ const stop = async () => {
1378
+ await hub.close();
1379
+ process.exit(0);
1380
+ };
1381
+ process.on("SIGINT", stop);
1382
+ process.on("SIGTERM", stop);
1383
+ });
1384
+ function printTeam(t) {
1385
+ if (!t.signedIn) {
1386
+ console.log(pc.dim("not on a team hub — `loom team signin <hub-url>`, or join with an invite link"));
1387
+ return;
1388
+ }
1389
+ console.log(`${pc.bold(String(t.github))} on ${pc.dim(String(t.hub))}`);
1390
+ const teams = t.teams ?? [];
1391
+ if (!teams.length)
1392
+ console.log(pc.dim(" no teams yet — `loom team create <name>` or `loom team join <link>`"));
1393
+ for (const tm of teams) {
1394
+ const members = tm.members ?? [];
1395
+ console.log(`\n${pc.magenta("◆")} ${pc.bold(String(tm.name))} ${pc.dim(`(${String(tm.role)} · key v${String(tm.keyVersion)})`)}`);
1396
+ console.log(` members ${members.map((m) => (m.role === "owner" ? `${m.github}★` : m.github)).join(", ")}`);
1397
+ console.log(` repos ${(tm.repos ?? []).join(", ") || pc.dim("none shared yet — `loom team share` in a project")}`);
1398
+ const presence = tm.presence ?? [];
1399
+ if (presence.length)
1400
+ console.log(" live");
1401
+ for (const p of presence) {
1402
+ const intent = (p.intent ?? {});
1403
+ const what = intent.task ? `${intent.task} ${pc.dim(`(${intent.goal ?? ""})`)}` : intent.goal ?? intent.thread ?? "";
1404
+ const touches = p.touches ?? [];
1405
+ console.log(` ${pc.cyan(String(p.github).padEnd(12))} ${String(p.agent).padEnd(22)} ${String(p.state).padEnd(10)} ${what}` +
1406
+ (touches.length ? pc.dim(` ${touches.slice(0, 3).join(" ")}`) : ""));
1407
+ }
1408
+ const leases = (tm.leases ?? []).filter((l) => !l.stale);
1409
+ if (leases.length)
1410
+ console.log(" leases");
1411
+ for (const l of leases) {
1412
+ const intent = (l.intent ?? {});
1413
+ const globs = (l.globs ?? []).slice(0, 3).join(" ");
1414
+ console.log(` ${pc.cyan(String(l.github).padEnd(12))} ${String(l.runId)}/${String(l.taskId)} ${pc.dim(String(l.state).padEnd(8))} ${globs}` +
1415
+ (intent.task ? pc.dim(` ${intent.task}`) : ""));
1416
+ }
1417
+ const feed = (tm.feed ?? []).slice(-8);
1418
+ if (feed.length)
1419
+ console.log(" recent");
1420
+ for (const e of feed) {
1421
+ const c = (e.content ?? {});
1422
+ const meta = (e.meta ?? {});
1423
+ const label = c.goal ?? c.title ?? (meta.number ? `#${String(meta.number)}` : "");
1424
+ const detail = label || String(meta.repo ?? (meta.version ? `v${String(meta.version)}` : ""));
1425
+ // membership events are posted by the hub itself; the person is in meta
1426
+ const who = String(e.github ?? meta.github ?? (String(e.type).startsWith("pr_") || String(e.type).startsWith("check_") ? "github" : "team"));
1427
+ console.log(` ${pc.dim(new Date(Number(e.ts)).toLocaleTimeString())} ${who.padEnd(10)} ${String(e.type).padEnd(14)} ${detail}`);
1428
+ }
1429
+ }
1430
+ }
1431
+ program
1432
+ .command("invite")
1433
+ .description("invite a teammate to this project: one link sets up the team, the repo, their agents and your crews")
1434
+ .option("--team <id>", "which team, when you're in several")
1435
+ .option("--no-grant", "don't give them push access to the repo when they join")
1436
+ .option("--paste", "hosted sign-in without a local browser")
1437
+ .action(async (opts) => {
1438
+ const client = await ensureDaemon();
1439
+ const project = await currentProject(client);
1440
+ const invite = () => client.inviteTeammate(project.id, { ...(opts.team ? { teamId: opts.team } : {}), ...(opts.grant === false ? { grant: false } : {}) });
1441
+ let out;
1442
+ try {
1443
+ out = await invite();
1444
+ }
1445
+ catch (err) {
1446
+ if (!/sign in to a team hub/.test(err.message))
1447
+ throw err;
1448
+ // not on a hub yet: the hosted one, with GitHub, then try again
1449
+ const { hostedHubUrl, hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
1450
+ const supabaseUrl = hostedSupabaseUrl("hosted");
1451
+ const session = await hostedSession(supabaseUrl, publishableKeyFor(supabaseUrl), Boolean(opts.paste));
1452
+ await client.teamAction("signin", { hub: hostedHubUrl(supabaseUrl), token: session.refreshToken });
1453
+ out = await invite();
1454
+ }
1455
+ console.log(`${pc.green("✓")} ${pc.bold(out.repo)} on ${pc.bold(out.team.name)} — send this to your teammate:\n`);
1456
+ console.log(` ${pc.cyan(out.link)}\n`);
1457
+ qrcode.generate(out.link, { small: true });
1458
+ console.log(pc.dim(` one click: GitHub sign-in, the team, the repo, their agents${out.grant ? ", push access" : ""} and your crews`));
1459
+ if (out.grantNote)
1460
+ console.log(pc.yellow(` ${out.grantNote}`));
1461
+ console.log(pc.dim(` works once, until ${new Date(out.expiresAt).toLocaleString()} · it carries the team key — send it like a password`));
1462
+ console.log(pc.dim(" no Loom yet on their side? the link shows the one command that installs it and joins"));
1463
+ });
1464
+ program
1465
+ .command("join <link>")
1466
+ .description("join a team from an invite link: sign in, clone the repo, open it with your agents, set up the crews")
1467
+ .option("--into <dir>", "clone here (default ~/loom-projects/<repo>)")
1468
+ .option("--github <login>", "your GitHub login, for a self-hosted hub")
1469
+ .option("--secret <s>", "a self-hosted hub's join secret")
1470
+ .option("--paste", "hosted sign-in without a local browser")
1471
+ .option("--no-open", "don't open the project in the browser after")
1472
+ .action(async (link, opts) => {
1473
+ const client = await ensureDaemon();
1474
+ const p = await client.previewInvite(link);
1475
+ console.log(`${pc.bold(p.team ?? "a Loom team")}${p.repo ? ` · ${p.repo}` : ""}${p.from ? pc.dim(` — from @${p.from}`) : ""}`);
1476
+ let token;
1477
+ if (!p.signedIn) {
1478
+ const { hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
1479
+ const sb = hostedSupabaseUrl(p.hub);
1480
+ if (sb !== null)
1481
+ token = (await hostedSession(sb, publishableKeyFor(sb), Boolean(opts.paste))).refreshToken;
1482
+ }
1483
+ let { job } = await client.startJoin({
1484
+ link,
1485
+ dir: process.cwd(),
1486
+ ...(opts.into ? { into: opts.into } : {}),
1487
+ ...(opts.github ? { github: opts.github } : {}),
1488
+ ...(opts.secret ? { secret: opts.secret } : {}),
1489
+ ...(token ? { token } : {}),
1490
+ });
1491
+ const said = new Map();
1492
+ const mark = { done: pc.green("✓"), skipped: pc.dim("·"), failed: pc.red("✗"), waiting: pc.yellow("…"), running: pc.cyan("→") };
1493
+ for (;;) {
1494
+ for (const s of job.steps) {
1495
+ const line = `${s.state}|${s.detail ?? ""}`;
1496
+ if (s.state === "pending" || said.get(s.id) === line)
1497
+ continue;
1498
+ said.set(s.id, line);
1499
+ if (s.state === "running" && !s.detail)
1500
+ continue;
1501
+ console.log(` ${mark[s.state] ?? " "} ${s.label}${s.detail ? pc.dim(` — ${s.detail}`) : ""}`);
1502
+ }
1503
+ if (job.state !== "running")
1504
+ break;
1505
+ await new Promise((r) => setTimeout(r, 400));
1506
+ job = (await client.joinStatus(job.id)).job;
1507
+ }
1508
+ if (job.state === "failed") {
1509
+ console.error(pc.red(`couldn't finish: ${job.error}`));
1510
+ console.error(pc.dim(" run the same command again once it's fixed — finished steps are skipped"));
1511
+ process.exitCode = 1;
1512
+ return;
1513
+ }
1514
+ console.log(`\n${pc.green("✓")} you're in${job.project ? ` — ${pc.bold(job.project.name)} at ${job.project.dir}` : ""}`);
1515
+ if (job.project) {
1516
+ console.log(pc.dim(` cd ${job.project.dir} && loom`));
1517
+ if (opts.open !== false) {
1518
+ const url = `${client.baseUrl}/app#p/${job.project.id}`;
1519
+ const { spawn: sp } = await import("node:child_process");
1520
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1521
+ sp(opener, [url], { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
1522
+ }
1523
+ }
1524
+ });
1525
+ program
1526
+ .command("proof")
1527
+ .description("is \"done\" true? check the newest finished turn: its claims, what it ran, and the tests again in a clean checkout")
1528
+ .option("--chat <id>", "which chat (default: main)")
1529
+ .option("--test <command>", "test with this instead of the project's own command")
1530
+ .action(async (opts) => {
1531
+ const client = await ensureDaemon();
1532
+ const project = await currentProject(client);
1533
+ process.stdout.write(pc.dim("checking… (it runs the tests again, so it takes as long as they do)\n"));
1534
+ const r = await client.proof(project.id, { ...(opts.chat ? { chat: opts.chat } : {}), ...(opts.test ? { testCommand: opts.test } : {}) });
1535
+ const mark = r.verdict === "verified" ? pc.green("✓ verified") : r.verdict === "contradicted" ? pc.red("✗ didn't hold") : pc.yellow("? unverified");
1536
+ console.log(`${mark} ${r.headline}`);
1537
+ for (const c of r.claims ?? [])
1538
+ console.log(` ${c.status === "supported" ? pc.green("●") : c.status === "contradicted" ? pc.red("●") : pc.dim("●")} “${c.text}” ${pc.dim(`— ${c.why}`)}`);
1539
+ if (r.rerun) {
1540
+ console.log(pc.dim(` ${r.rerun.command} · exit ${r.rerun.exitCode ?? "?"} · ${(r.rerun.ms / 1000).toFixed(1)}s${r.rerun.clean === false ? " · in place" : " · clean checkout"}`));
1541
+ if (!r.rerun.ok)
1542
+ console.log(pc.dim(String(r.rerun.tail).split("\n").slice(-12).map((l) => ` ${l}`).join("\n")));
1543
+ }
1544
+ if (r.verdict === "contradicted")
1545
+ process.exitCode = 1;
1546
+ });
1547
+ const SHIFT_MARK = {
1548
+ verified: (s) => pc.green(s), done: (s) => s, didnt_hold: (s) => pc.red(s), failed: (s) => pc.red(s),
1549
+ needs_you: (s) => pc.yellow(s), not_started: (s) => pc.dim(s), running: (s) => pc.dim(s),
1550
+ };
1551
+ const SHIFT_WORD = { verified: "verified", done: "done", didnt_hold: "didn't hold", failed: "failed", needs_you: "needs you", not_started: "not started", running: "running" };
1552
+ const shiftLine = (t) => {
1553
+ const why = t.question ? `asks: ${t.question}` : t.error ?? t.headline ?? "";
1554
+ return ` ${(SHIFT_MARK[t.status] ?? ((x) => x))(String(SHIFT_WORD[t.status] ?? t.status).padEnd(11))} ${t.text}${why ? pc.dim(` — ${why}`) : ""}`;
1555
+ };
1556
+ program
1557
+ .command("shift [tasks...]")
1558
+ .description("Night Shift: run these tasks one after another while you're away, a chat each, then one report. No tasks: show the running shift or the last report")
1559
+ .option("--to <agent>", "who takes them (default: the router picks)")
1560
+ .option("--until <time>", "start no new task after this (7:00, 6:30am, an ISO time)")
1561
+ .option("--max <usd>", "stop starting new tasks once the shift has spent this")
1562
+ .option("--file <path>", "read the tasks from a file, one per line")
1563
+ .option("--stop", "stop the running shift: the current task finishes, then the report")
1564
+ .action(async (tasks, opts) => {
1565
+ const client = await ensureDaemon();
1566
+ const project = await currentProject(client);
1567
+ if (opts.stop) {
1568
+ await client.stopShift(project.id);
1569
+ console.log("night shift stopping — no new tasks start; the report follows the one running");
1570
+ return;
1571
+ }
1572
+ const list = [...(tasks ?? []), ...(opts.file ? fs.readFileSync(opts.file, "utf8").split("\n") : [])].map((t) => t.trim()).filter(Boolean);
1573
+ if (list.length) {
1574
+ const r = await client.startShift(project.id, { tasks: list, ...(opts.to ? { to: opts.to } : {}), ...(opts.until ? { until: opts.until } : {}), ...(opts.max ? { maxUsd: Number(opts.max) } : {}) });
1575
+ console.log(`🌙 night shift started — ${r.shift.tasks.length} task${r.shift.tasks.length === 1 ? "" : "s"}${opts.to ? ` for ${opts.to}` : ""}. \`loom shift\` shows how it's going; the report lands in the thread and on your phone.`);
1576
+ return;
1577
+ }
1578
+ const s = await client.shift(project.id);
1579
+ if (s.active) {
1580
+ console.log(`🌙 night shift running${s.shift.stopped ? pc.yellow(` (stopping: ${s.shift.stopped})`) : ""}`);
1581
+ for (const t of s.tasks)
1582
+ console.log(shiftLine(t));
1583
+ }
1584
+ else if (s.last) {
1585
+ console.log(`🌙 last night shift: ${s.last.summary}${s.last.stopped ? pc.dim(` — stopped: ${s.last.stopped}`) : ""}`);
1586
+ for (const t of s.last.tasks)
1587
+ console.log(shiftLine(t));
1588
+ }
1589
+ else
1590
+ console.log(pc.dim("no night shift yet — `loom shift \"task one\" \"task two\" --to codex --until 7:00`"));
1591
+ });
1592
+ program
1593
+ .command("crew [action] [args...]")
1594
+ .description("Agent Teams: status | create | goal | say | approve | pause | stop | resume | pick | refresh | budget | overlap | deliver | apply | diff | remove")
1595
+ .option("--crew <id>", "which crew, when the project has several")
1596
+ .option("--to <teammate>", "say: to one teammate")
1597
+ .option("--no-approval", "create: the Lead's plan starts without your OK")
1598
+ .option("--play <play>", "ship, fanout or race")
1599
+ .option("--parallel <n>", "create: maximum concurrent builders (1–4)")
1600
+ .option("--amount <usd>", "budget: new goal budget in USD")
1601
+ .option("--card <id>", "overlap: held card id")
1602
+ .option("--decision <decision>", "overlap: narrow, wait:<goal>, or proceed:<reason>")
1603
+ .option("--touches <globs>", "overlap: comma-separated revised paths")
1604
+ .action(async (action, args, opts) => {
1605
+ const client = await ensureDaemon();
1606
+ const project = await currentProject(client);
1607
+ const a = (action ?? "status").toLowerCase();
1608
+ const words = (args ?? []).join(" ").trim();
1609
+ const { crews, templates } = await client.crews(project.id);
1610
+ if (a === "create") {
1611
+ const [first, ...rest] = args ?? [];
1612
+ const template = first && templates.includes(first.toLowerCase()) ? first.toLowerCase() : "ship";
1613
+ const name = (first && templates.includes(first.toLowerCase()) ? rest : args ?? []).join(" ");
1614
+ const { crew } = await client.createCrew(project.id, { template, ...(name ? { name } : {}), ...(opts.approval === false ? { planApproval: false } : {}),
1615
+ ...(opts.play ? { play: opts.play } : {}), ...(opts.parallel ? { maxParallel: Number(opts.parallel) } : {}) });
1616
+ console.log(`${pc.green("✓")} ${pc.bold(crew.name)} (${crew.id}) — ${crew.teammates.map((t) => `${t.id}=${t.agent}`).join(", ")}`);
1617
+ console.log(pc.dim(` give it a goal: loom crew goal "…"${crews.length ? ` --crew ${crew.id}` : ""}`));
1618
+ return;
1619
+ }
1620
+ if (!crews.length) {
1621
+ console.log(pc.dim(`no crews here yet — loom crew create [${templates.join("|")}] [name]`));
1622
+ return;
1623
+ }
1624
+ const crew = opts.crew ? crews.find((c) => c.id === opts.crew || c.name === opts.crew) : crews.length === 1 ? crews[0] : undefined;
1625
+ if (!crew)
1626
+ throw new Error(`which crew? --crew ${crews.map((c) => c.id).join(" | ")}`);
1627
+ if (a === "status") {
1628
+ for (const c of opts.crew ? [crew] : crews)
1629
+ printCrew(c);
1630
+ return;
1631
+ }
1632
+ if (a === "diff") {
1633
+ const out = await client.crewDiff(project.id, crew.id);
1634
+ process.stdout.write(out.diff || pc.dim("no changes yet\n"));
1635
+ return;
1636
+ }
1637
+ if (a === "remove") {
1638
+ await client.removeCrew(project.id, crew.id);
1639
+ console.log(`${pc.green("✓")} removed ${crew.name}`);
1640
+ return;
1641
+ }
1642
+ if ((a === "goal" || a === "say") && !words)
1643
+ throw new Error(`loom crew ${a} "<text>"`);
1644
+ const out = await client.crewAction(project.id, crew.id, a, { ...(words ? { text: words } : {}), ...(opts.to ? { to: opts.to } : {}), ...(opts.play ? { play: opts.play } : {}),
1645
+ ...(opts.amount ? { amount: Number(opts.amount) } : {}), ...(opts.card ? { card: opts.card } : {}), ...(opts.decision ? { decision: opts.decision } : {}),
1646
+ ...(opts.touches ? { touches: opts.touches.split(",").map(s => s.trim()).filter(Boolean) } : {}) });
1647
+ if (a === "say")
1648
+ console.log(`${pc.green("✓")} ${out.routed === "goal" ? "new goal" : out.routed === "answer" ? "answered" : out.routed === "replan" ? "the Lead replans with that" : `noted for ${out.to}`}`);
1649
+ else if (a === "apply")
1650
+ console.log(`${pc.green("✓")} merged ${pc.bold(out.merged)} into ${pc.bold(out.into)}`);
1651
+ else
1652
+ printCrew(out.crew);
1653
+ });
1654
+ function printCrew(c) {
1655
+ const g = c.state?.goal;
1656
+ console.log(`${pc.bold(c.name)} ${pc.dim(`(${c.id})`)}${c.busy ? pc.cyan(" working") : ""}`);
1657
+ console.log(` ${c.teammates.map((t) => `${t.id}${pc.dim(`:${t.role}=${t.agent}`)}`).join(" ")}`);
1658
+ if (!g)
1659
+ return void console.log(pc.dim(" no goal yet — loom crew goal \"…\""));
1660
+ const color = g.status === "completed" ? pc.green : g.status === "failed" ? pc.red : g.status.startsWith("waiting") || g.status === "awaiting_approval" ? pc.yellow : pc.cyan;
1661
+ console.log(` ${color(g.status)} ${g.text.split("\n")[0].slice(0, 80)} ${pc.dim(g.branch)}`);
1662
+ for (const card of g.cards ?? []) {
1663
+ const m = card.stage === "done" ? pc.green("✓") : card.stage === "failed" ? pc.red("✗") : card.stage === "planned" ? pc.dim("·") : pc.cyan("→");
1664
+ console.log(` ${m} ${card.title}${card.builder ? pc.dim(` ${card.builder}`) : ""}${card.stage !== "done" && card.stage !== "planned" ? pc.dim(` [${card.stage}]`) : ""}${card.error ? pc.red(` ${card.error.slice(0, 80)}`) : ""}`);
1665
+ }
1666
+ if (g.question)
1667
+ console.log(pc.yellow(` ${g.question.teammate} asks: ${g.question.text}`) + pc.dim(" (loom crew say \"…\")"));
1668
+ if (g.status === "awaiting_approval")
1669
+ console.log(pc.dim(" loom crew approve — or loom crew say \"…\" to change the plan"));
1670
+ if (g.status === "completed" && !g.applied)
1671
+ console.log(pc.dim(" loom crew apply — merge it into your branch · loom crew diff"));
1672
+ if (g.summary && g.status === "completed")
1673
+ console.log(` ${g.summary.split("\n")[0].slice(0, 120)}`);
1674
+ }
1675
+ program
1676
+ .command("team [action] [args...]")
1677
+ .description("Loom Teams: status | signin [hub] | create <name> | invite | join <link> | share | unshare | brain [inbox|promote|resolve|correct|trust|private] | landing | doctor [fix] | adopt <pr> | deploys | release-notes <since> | webhook [--repo r] [--install] | remove <github> | leave")
1678
+ .option("--github <login>", "your GitHub login (defaults to the gh CLI's)")
1679
+ .option("--repo <owner/name>", "webhook: which repo (defaults to this project's)")
1680
+ .option("--install", "webhook: create it on the repo with gh (needs repo admin)")
1681
+ .option("--rotate", "webhook: replace the secret (update the repo's webhook after)")
1682
+ .option("--secret <s>", "the hub's join secret")
1683
+ .option("--team <id>", "which team, when you're in several")
1684
+ .option("--paste", "hosted sign-in without a local browser: open the link anywhere, paste back where you land")
1685
+ .action(async (action, args, opts) => {
1686
+ const client = await ensureDaemon();
1687
+ const a = (action ?? "status").toLowerCase();
1688
+ const arg = args?.[0];
1689
+ const extra = { ...(opts.github ? { github: opts.github } : {}), ...(opts.secret ? { secret: opts.secret } : {}), ...(opts.team ? { teamId: opts.team } : {}) };
1690
+ try {
1691
+ if (a === "status")
1692
+ return void printTeam(await client.team());
1693
+ if (a === "signin") {
1694
+ const { hostedHubUrl, hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
1695
+ const supabaseUrl = hostedSupabaseUrl(arg);
1696
+ if (supabaseUrl !== null) {
1697
+ // The hosted hub: GitHub sign-in in the browser, run here (it can take
1698
+ // minutes); the daemon only receives the resulting session.
1699
+ const session = await hostedSession(supabaseUrl, publishableKeyFor(supabaseUrl), Boolean(opts.paste));
1700
+ const out = await client.teamAction("signin", { hub: hostedHubUrl(supabaseUrl), token: session.refreshToken, ...extra });
1701
+ return void printTeam(out.team);
1702
+ }
1703
+ const out = await client.teamAction("signin", { hub: arg, ...extra });
1704
+ return void printTeam(out.team);
1705
+ }
1706
+ if (a === "create") {
1707
+ if (!arg)
1708
+ throw new Error("name it: loom team create <name>");
1709
+ const out = await client.teamAction("create", { name: arg });
1710
+ console.log(`${pc.green("✓")} created ${pc.bold(String(out.result.name))} — you're its owner`);
1711
+ console.log(pc.dim(" invite teammates: loom team invite · share this project: loom team share"));
1712
+ return;
1713
+ }
1714
+ if (a === "invite") {
1715
+ const out = await client.teamAction("invite", extra);
1716
+ const { link, expiresAt } = out.result;
1717
+ console.log(`${pc.bold("invite link")} ${pc.dim(`(single use, until ${new Date(expiresAt).toLocaleString()})`)}\n\n ${link}\n`);
1718
+ console.log(pc.yellow(" it carries the team key — send it like a password"));
1719
+ qrcode.generate(link, { small: true });
1720
+ return;
1721
+ }
1722
+ if (a === "join") {
1723
+ if (!arg)
1724
+ throw new Error("paste the invite: loom team join '<link>'");
1725
+ const out = await client.teamAction("join", { link: arg, ...extra });
1726
+ console.log(`${pc.green("✓")} joined ${pc.bold(String(out.result.name))}`);
1727
+ return void printTeam(out.team);
1728
+ }
1729
+ if (a === "share" || a === "unshare") {
1730
+ const project = await currentProject(client);
1731
+ if (a === "share") {
1732
+ const out = await client.shareProject(project.id, opts.team);
1733
+ console.log(`${pc.green("✓")} ${pc.bold(out.repo)} is shared — teammates see this project's agents and goals`);
1734
+ }
1735
+ else {
1736
+ await client.unshareProject(project.id);
1737
+ console.log(`${pc.green("✓")} ${project.name} is private — nothing from it reaches the team`);
1738
+ }
1739
+ return;
1740
+ }
1741
+ if (a === "remove") {
1742
+ if (!arg)
1743
+ throw new Error("who? loom team remove <github-login>");
1744
+ const st = await client.team();
1745
+ const teams = st.teams ?? [];
1746
+ const team = opts.team ? teams.find((t) => t.id === opts.team) : teams[0];
1747
+ const m = team?.members.find((x) => x.github === arg.toLowerCase());
1748
+ if (!team || !m)
1749
+ throw new Error(`no member "${arg}"`);
1750
+ const out = await client.teamAction("remove", { userId: m.id, teamId: team.id });
1751
+ console.log(`${pc.green("✓")} removed ${arg}; team key rotated to v${String(out.result.keyVersion)}`);
1752
+ return;
1753
+ }
1754
+ if (a === "brain") {
1755
+ // loom team brain [inbox|sync|promote <id,id>|resolve <winner> --loser <id>|trust <id>|private <id>]
1756
+ const project = await currentProject(client);
1757
+ const sub = (arg ?? "").toLowerCase();
1758
+ const rest = (args ?? []).slice(1);
1759
+ let view;
1760
+ if (!sub || sub === "inbox" || sub === "status")
1761
+ view = await client.teamBrain(project.id, { sync: true });
1762
+ else if (sub === "sync")
1763
+ view = await client.teamBrainAction(project.id, "sync");
1764
+ else if (sub === "promote") {
1765
+ if (!rest[0])
1766
+ throw new Error("which memories? loom team brain promote <id>[,<id>…]");
1767
+ const out = await client.teamBrainAction(project.id, "promote", { ids: rest[0].split(",") });
1768
+ const r = out.result;
1769
+ console.log(`${pc.green("✓")} ${r.added ? `proposed ${r.added} as canon on ${r.branch}` : "already canon"}${r.prUrl ? ` — ${r.prUrl}` : ""}`);
1770
+ if (r.note)
1771
+ console.log(pc.yellow(` ${r.note}`));
1772
+ return;
1773
+ }
1774
+ else if (sub === "resolve" || sub === "merge") {
1775
+ if (!rest[0] || !rest[1])
1776
+ throw new Error(`loom team brain ${sub} <keep-id> <drop-id> [reason]`);
1777
+ view = await client.teamBrainAction(project.id, "resolve", { winner: rest[0], loser: rest[1], reason: rest.slice(2).join(" ") || sub });
1778
+ }
1779
+ else if (sub === "correct") {
1780
+ if (!rest[0] || !rest[1])
1781
+ throw new Error("loom team brain correct <id> <the corrected sentence>");
1782
+ view = await client.teamBrainAction(project.id, "correct", { id: rest[0], text: rest.slice(1).join(" ") });
1783
+ }
1784
+ else if (sub === "trust" || sub === "private") {
1785
+ if (!rest[0])
1786
+ throw new Error(`loom team brain ${sub} <id>`);
1787
+ view = await client.teamBrainAction(project.id, sub, { id: rest[0] });
1788
+ }
1789
+ else
1790
+ throw new Error(`unknown brain action "${sub}" — inbox, sync, promote, resolve, merge, correct, trust, private`);
1791
+ const st = view.status;
1792
+ if (!st.shared)
1793
+ return void console.log(pc.dim("this project isn't shared with a team — `loom team share`"));
1794
+ console.log(`${pc.bold("team brain")} ${String(st.repo)} ${pc.dim(`${String(st.canon)} canon · ${String(st.team)} team · ${String(st.confirmed)} confirmed · ${String(st.mine)} yours`)}`);
1795
+ if (st.lastError)
1796
+ console.log(pc.red(` ${String(st.lastError)}`));
1797
+ const tag = { canon: pc.green("canon"), confirmed: pc.cyan("confirmed"), own: pc.dim("yours"), proposed: pc.yellow("proposed") };
1798
+ for (const m of view.memories.slice(0, 40)) {
1799
+ console.log(` ${(tag[m.tier] ?? m.tier).padEnd(20)} ${m.text}${m.author && !m.mine ? pc.dim(` — ${m.author}`) : ""} ${pc.dim(m.id)}`);
1800
+ }
1801
+ if (view.inbox.length) {
1802
+ console.log(`\n${pc.bold("inbox")} ${pc.dim(`(${view.inbox.length})`)}`);
1803
+ for (const i of view.inbox) {
1804
+ console.log(` ${pc.magenta(i.type.padEnd(13))} ${i.detail}`);
1805
+ console.log(` ${pc.dim(i.a.id)} ${i.a.text}`);
1806
+ if (i.b)
1807
+ console.log(` ${pc.dim(i.b.id)} ${i.b.text}`);
1808
+ }
1809
+ }
1810
+ return;
1811
+ }
1812
+ if (a === "release-notes" || a === "deploys") {
1813
+ const project = await currentProject(client);
1814
+ if (a === "release-notes") {
1815
+ if (!arg)
1816
+ throw new Error("since which tag? loom team release-notes v1.2.0");
1817
+ process.stdout.write((await client.releaseNotes(project.id, arg)).markdown);
1818
+ return;
1819
+ }
1820
+ const { deployments } = await client.deploys(project.id);
1821
+ if (!deployments.length)
1822
+ return void console.log(pc.dim("no deployments on this repo"));
1823
+ for (const d of deployments) {
1824
+ const st = String(d.state);
1825
+ const col = st === "success" ? pc.green : st === "failure" || st === "error" ? pc.red : pc.yellow;
1826
+ console.log(` ${col(st.padEnd(12))} ${String(d.environment).padEnd(14)} ${String(d.sha).slice(0, 8)} ${pc.dim(String(d.url ?? ""))}`);
1827
+ }
1828
+ return;
1829
+ }
1830
+ if (a === "landing" || a === "doctor" || a === "adopt") {
1831
+ const project = await currentProject(client);
1832
+ if (a === "doctor") {
1833
+ if ((args ?? []).includes("--fix") || arg === "fix") {
1834
+ const out = await client.teamDoctorFix(project.id);
1835
+ console.log(out.prUrl ? `${pc.green("✓")} opened ${out.prUrl} (${out.files.join(", ")})` : pc.dim("nothing to fix in the workflows"));
1836
+ return;
1837
+ }
1838
+ const d = await client.teamDoctor(project.id);
1839
+ console.log(`${pc.bold("landing doctor")} ${d.repo ?? pc.dim("(no GitHub repo)")} · ${d.branch}`);
1840
+ const icon = { ok: pc.green("✓"), warn: pc.yellow("!"), error: pc.red("✗") };
1841
+ for (const f of d.findings) {
1842
+ console.log(` ${icon[f.level] ?? "·"} ${f.what}`);
1843
+ if (f.fix)
1844
+ console.log(pc.dim(` ${f.fix}`));
1845
+ }
1846
+ if (d.fixable.length)
1847
+ console.log(pc.dim(`\n loom team doctor fix — opens a PR adding merge_group to ${d.fixable.join(", ")}`));
1848
+ return;
1849
+ }
1850
+ if (a === "adopt") {
1851
+ const pr = Number(String(arg ?? "").replace(/^#/, ""));
1852
+ if (!pr)
1853
+ throw new Error("which PR? loom team adopt <number>");
1854
+ const out = await client.landingAction(project.id, "adopt", { pr });
1855
+ console.log(`${pc.green("✓")} adopted PR #${pr} — goal ${String(out.result.id)} is making it green; it's handed back when it is`);
1856
+ return;
1857
+ }
1858
+ const v = await client.landing(project.id, { poll: true });
1859
+ if (!v.goals.length && !v.adoptable.length)
1860
+ return void console.log(pc.dim("no goal PRs in flight"));
1861
+ for (const g of v.goals) {
1862
+ const l = g.landing;
1863
+ const col = l.state === "green" || l.state === "merged" ? pc.green : l.state === "needs_human" || l.state === "failing" ? pc.red : pc.yellow;
1864
+ console.log(` ${col(l.state.padEnd(12))} #${l.pr} ${String(g.goal)} ${pc.dim(`${String(g.runId)} · fixes ${l.fixAttempts} · $${Number(g.costUsd ?? 0).toFixed(2)}`)}`);
1865
+ if (l.checks?.failing.length)
1866
+ console.log(pc.red(` failing: ${l.checks.failing.join(", ")}`));
1867
+ if (l.review)
1868
+ console.log(pc.dim(` review: ${l.review.state}${l.review.high ? ` (${l.review.high} high)` : ""}`));
1869
+ if (l.reason)
1870
+ console.log(pc.yellow(` ${l.reason}`));
1871
+ }
1872
+ if (v.adoptable.length) {
1873
+ console.log(`\n${pc.bold("needs someone")}`);
1874
+ for (const p of v.adoptable)
1875
+ console.log(` #${String(p.pr)} ${String(p.owner)} — ${String(p.reason)} ${pc.dim(`loom team adopt ${String(p.pr)}`)}`);
1876
+ }
1877
+ return;
1878
+ }
1879
+ if (a === "webhook") {
1880
+ // Phase 6 (D83): GitHub events pushed to the hub instead of polled
1881
+ const project = await currentProject(client).catch(() => null);
1882
+ const out = await client.teamAction("webhook", {
1883
+ ...extra,
1884
+ ...(opts.repo ? { repo: opts.repo } : {}),
1885
+ ...(project ? { projectId: project.id } : {}),
1886
+ ...(opts.install ? { install: "1" } : {}),
1887
+ ...(opts.rotate ? { rotate: "1" } : {}),
1888
+ });
1889
+ const w = out.result;
1890
+ console.log(`${pc.bold("payload URL")} ${w.url}`);
1891
+ console.log(`${pc.bold("secret")} ${w.secret} ${pc.yellow("(treat it like a password)")}`);
1892
+ console.log(`${pc.bold("content type")} application/json`);
1893
+ console.log(`${pc.bold("events")} ${w.events.join(", ")}`);
1894
+ if (w.warning)
1895
+ console.log(pc.yellow(` ${w.warning}`));
1896
+ if (w.installed)
1897
+ console.log(`${pc.green("✓")} webhook created on ${w.installed.repo}${w.installed.id ? pc.dim(` (id ${w.installed.id})`) : ""}`);
1898
+ else
1899
+ console.log(pc.dim(`\n install it: loom team webhook --install${w.repo ? ` --repo ${w.repo}` : " --repo owner/name"} (or GitHub → Settings → Webhooks)`));
1900
+ return;
1901
+ }
1902
+ if (a === "leave") {
1903
+ await client.teamAction("leave", extra);
1904
+ console.log(`${pc.green("✓")} left the team`);
1905
+ return;
1906
+ }
1907
+ throw new Error(`unknown action "${a}"`);
1908
+ }
1909
+ catch (err) {
1910
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
1911
+ process.exitCode = 1;
1912
+ }
1913
+ });
1914
+ program
1915
+ .command("runner [action] [args...]")
1916
+ .description("Loom runners: status | pair | join <link> | start | stop | token <PAT> | doctor | install | revoke <device> | goal \"<goal>\" | move <runId> | back <runId> | jobs | exec")
1917
+ .option("--github <login>", "your GitHub login (self-hosted hub)")
1918
+ .option("--secret <s>", "the self-hosted hub's join secret")
1919
+ .option("--token <refresh>", "hosted hub: a session refresh token (from the paste-the-URL sign-in)")
1920
+ .option("--shared", "take teammates' goals too (when their repo's policy allows)")
1921
+ .option("--runner <id>", "which runner (device id or label)")
1922
+ .option("--job <id>", "exec: the claimed job to run")
1923
+ .option("--team <id>", "exec: the job's team")
1924
+ .option("--browser", "join: sign in with a local browser instead of pasting the address back")
1925
+ .action(async (action, args, opts) => {
1926
+ const a = (action ?? "status").toLowerCase();
1927
+ const arg = args?.[0];
1928
+ try {
1929
+ if (a === "install") {
1930
+ const loomBin = fileURLToPath(new URL("./index.js", import.meta.url));
1931
+ const f = serviceFile({ platform: process.platform, home: os.homedir(), node: process.execPath, loom: loomBin, ...(process.env.LOOM_HOME ? { loomHome: process.env.LOOM_HOME } : {}), ...(process.env.APPDATA ? { appData: process.env.APPDATA } : {}) });
1932
+ fs.mkdirSync(path.dirname(f.path), { recursive: true });
1933
+ // Windows Script Host reads Unicode reliably as UTF-16 with a BOM.
1934
+ fs.writeFileSync(f.path, process.platform === "win32" ? Buffer.from("\uFEFF" + f.content, "utf16le") : f.content);
1935
+ console.log(`${pc.green("✓")} wrote ${f.path}`);
1936
+ for (const cmd of f.enable) {
1937
+ await new Promise((resolve, reject) => spawn(cmd[0], cmd.slice(1), { stdio: "inherit" })
1938
+ .on("error", reject).on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd[0]} failed (exit ${code})`))));
1939
+ }
1940
+ console.log(pc.dim(process.platform === "win32"
1941
+ ? " the daemon now starts at login; `loom runner status` to check, `loom up` to restart after a failure"
1942
+ : " the daemon now starts at login and restarts if it stops; `loom runner status` to check"));
1943
+ return;
1944
+ }
1945
+ if (a === "token") {
1946
+ if (!arg)
1947
+ throw new Error("paste a fine-grained PAT: loom runner token github_pat_…");
1948
+ writeRunnerToken(arg);
1949
+ console.log(`${pc.green("✓")} saved (0600, ${path.join(loomHome(), "runner-token")}) — it never goes through the hub. Check it: loom runner doctor`);
1950
+ return;
1951
+ }
1952
+ if (a === "exec") {
1953
+ // Inside a runner's container: run one claimed job, then exit (D70).
1954
+ if (!opts.job || !opts.team)
1955
+ throw new Error("runner exec needs --job and --team");
1956
+ const daemon = new LoomDaemon({ host: "127.0.0.1", port: 0, runnerExec: { teamId: opts.team, jobId: opts.job, done: (ok) => process.exit(ok ? 0 : 1) } });
1957
+ await daemon.listen();
1958
+ return;
1959
+ }
1960
+ const client = await ensureDaemon();
1961
+ if (a === "status") {
1962
+ const s = await client.runner();
1963
+ const c = s.config;
1964
+ console.log(`runner ${s.running ? pc.green("running") : c?.enabled ? pc.yellow("enabled, not running") : pc.dim("off")}${c ? pc.dim(` · ${c.shared ? "shared" : "personal"} · ${c.capacity ?? 1} at a time · isolation ${c.isolation ?? "auto"}`) : ""}`);
1965
+ for (const j of s.active ?? [])
1966
+ console.log(` ${String(j.kind).padEnd(9)} ${String(j.repo)} ${pc.dim(`${String(j.owner)} · job ${String(j.jobId)}${j.runId ? ` · goal ${String(j.runId)}` : ""}`)}`);
1967
+ if (!s.token)
1968
+ console.log(pc.dim(" no runner token — `loom runner token <PAT>` (see `loom runner doctor`)"));
1969
+ if (s.lastError)
1970
+ console.log(pc.red(` ${String(s.lastError)}`));
1971
+ return;
1972
+ }
1973
+ if (a === "pair") {
1974
+ const out = await client.runnerAction("pair");
1975
+ const { link } = out.result;
1976
+ console.log(`${pc.bold("runner pairing link")} — on the box: ${pc.cyan("loom runner join '<link>'")}\n\n ${link}\n`);
1977
+ console.log(pc.yellow(" it carries your team keys — send it like a password, to a machine you control"));
1978
+ return;
1979
+ }
1980
+ if (a === "join") {
1981
+ if (!arg)
1982
+ throw new Error("paste the link from `loom runner pair`");
1983
+ let token = opts.token;
1984
+ if (!token && !opts.secret) {
1985
+ // A hosted hub: sign in as yourself here (a runner box usually has no browser — D74).
1986
+ const hubUrl = (() => {
1987
+ try {
1988
+ return String(JSON.parse(Buffer.from(arg.replace(/^loom-runner:/, ""), "base64url").toString("utf8")).hub ?? "");
1989
+ }
1990
+ catch {
1991
+ return "";
1992
+ }
1993
+ })();
1994
+ const { hostedSupabaseUrl, publishableKeyFor } = await import("../core/hosted.js");
1995
+ const sbUrl = hostedSupabaseUrl(hubUrl);
1996
+ if (sbUrl !== null)
1997
+ token = (await hostedSession(sbUrl, publishableKeyFor(sbUrl), !opts.browser)).refreshToken;
1998
+ }
1999
+ const out = await client.runnerAction("join", { link: arg, ...(opts.github ? { github: opts.github } : {}), ...(opts.secret ? { secret: opts.secret } : {}), ...(token ? { token } : {}), ...(opts.shared ? { shared: true } : {}) });
2000
+ console.log(`${pc.green("✓")} this machine is now a runner`, pc.dim(JSON.stringify(out.result.registered ?? {})));
2001
+ console.log(pc.dim(" keep it running: loom runner install · check it: loom runner doctor"));
2002
+ return;
2003
+ }
2004
+ if (a === "start" || a === "stop") {
2005
+ await client.runnerAction(a, a === "start" && opts.shared ? { shared: true } : {});
2006
+ console.log(`${pc.green("✓")} runner ${a === "start" ? "started" : "stopped"}`);
2007
+ return;
2008
+ }
2009
+ if (a === "revoke") {
2010
+ if (!arg)
2011
+ throw new Error("which runner device? see `loom runner jobs`");
2012
+ await client.runnerAction("revoke", { deviceId: arg });
2013
+ console.log(`${pc.green("✓")} revoked ${arg}; team keys rotated`);
2014
+ return;
2015
+ }
2016
+ if (a === "doctor") {
2017
+ const token = readRunnerToken();
2018
+ const repos = {};
2019
+ let scopes = [];
2020
+ if (token) {
2021
+ const env = { ...process.env, GH_TOKEN: token };
2022
+ const run = (argv) => new Promise((resolve) => {
2023
+ const p = spawn("gh", argv, { env });
2024
+ let out = "";
2025
+ p.stdout.on("data", (d) => (out += String(d)));
2026
+ p.on("exit", () => resolve(out));
2027
+ p.on("error", () => resolve(""));
2028
+ });
2029
+ const head = await run(["api", "-i", "user"]);
2030
+ scopes = (/^x-oauth-scopes:\s*(.*)$/im.exec(head)?.[1] ?? "").split(",").map((x) => x.trim()).filter(Boolean);
2031
+ const team = await client.team();
2032
+ for (const t of team.teams ?? []) {
2033
+ for (const r of t.repos) {
2034
+ try {
2035
+ repos[r] = JSON.parse(await run(["api", `repos/${r}`])).permissions ?? null;
2036
+ }
2037
+ catch {
2038
+ repos[r] = null;
2039
+ }
2040
+ }
2041
+ }
2042
+ }
2043
+ const s = await client.runner();
2044
+ const items = [
2045
+ ...(s.running ? [{ level: "ok", what: "runner is running" }] : [{ level: "warn", what: "runner isn't running", fix: "loom runner start" }]),
2046
+ ...tokenFindings({ present: Boolean(token), scopes, repos }),
2047
+ ];
2048
+ const icon = { ok: pc.green("✓"), warn: pc.yellow("!"), error: pc.red("✗") };
2049
+ for (const f of items) {
2050
+ console.log(` ${icon[f.level]} ${f.what}`);
2051
+ if ("fix" in f && f.fix)
2052
+ console.log(pc.dim(` ${f.fix}`));
2053
+ }
2054
+ return;
2055
+ }
2056
+ const project = await currentProject(client);
2057
+ if (a === "goal") {
2058
+ const goal = (args ?? []).join(" ").trim();
2059
+ if (!goal)
2060
+ throw new Error('what\'s the goal? loom runner goal "Add rate limiting"');
2061
+ const out = await client.runnersAction(project.id, "start", { goal, ...(opts.runner ? { runner: opts.runner } : {}) });
2062
+ console.log(`${pc.green("✓")} queued for your runner — job ${String(out.result.jobId)}; watch it: loom runner jobs`);
2063
+ return;
2064
+ }
2065
+ if (a === "move" || a === "back") {
2066
+ if (!arg)
2067
+ throw new Error(`which goal? loom runner ${a} <runId>`);
2068
+ const out = await client.runnersAction(project.id, a === "move" ? "continue" : "bring-back", { runId: arg, ...(opts.runner ? { runner: opts.runner } : {}) });
2069
+ console.log(`${pc.green("✓")} ${a === "move" ? "moving the goal to your runner" : "asked the runner to hand it back"} — job ${String(out.result.jobId)}`);
2070
+ return;
2071
+ }
2072
+ if (a === "jobs") {
2073
+ const v = await client.runners(project.id);
2074
+ for (const r of v.runners)
2075
+ console.log(` ${r.online ? pc.green("●") : pc.dim("○")} ${String(r.label)} ${pc.dim(`${String(r.github)} · ${r.kinds.join(", ")}${r.shared ? " · shared" : ""} · ${String(r.deviceId)}`)}`);
2076
+ if (!v.runners.length)
2077
+ console.log(pc.dim(" no runners — `loom runner pair`, then `loom runner join <link>` on an always-on box"));
2078
+ for (const j of v.jobs.slice(-15)) {
2079
+ const p = j.progress;
2080
+ console.log(` ${String(j.state).padEnd(9)} ${String(j.kind).padEnd(8)} ${String(j.goal || j.runId || "")} ${pc.dim(`${p?.status ?? ""}${p?.tasks ? ` · ${p.tasks.length} tasks` : ""}${p?.costUsd ? ` · $${p.costUsd}` : ""}${j.error ? ` · ${String(j.error)}` : ""}`)}`);
2081
+ }
174
2082
  return;
175
2083
  }
2084
+ throw new Error(`unknown runner action "${a}"`);
2085
+ }
2086
+ catch (err) {
2087
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
2088
+ process.exitCode = 1;
176
2089
  }
177
- fail("daemon did not become healthy — try `loom daemon` in the foreground");
178
2090
  });
179
2091
  program
180
- .command("down")
181
- .description("stop the background daemon")
182
- .action(async () => {
183
- if (await stopDaemon())
184
- console.log(pc.green("✓ daemon stopped"));
185
- else
186
- console.log(pc.dim("no running daemon found"));
2092
+ .command("land [runId]")
2093
+ .description("land a goal's PR: fresh main in, fast tests, push, merge when GitHub's rules pass (Loom Teams D56)")
2094
+ .action(async (runId) => {
2095
+ const client = await ensureDaemon();
2096
+ try {
2097
+ const project = await currentProject(client);
2098
+ let id = runId;
2099
+ if (!id) {
2100
+ const v = await client.landing(project.id);
2101
+ const ready = v.goals.filter((g) => !["merged", "closed"].includes(String(g.landing.state)));
2102
+ if (ready.length !== 1)
2103
+ throw new Error(ready.length ? `several goals have PRs — say which: ${ready.map((g) => String(g.runId)).join(", ")}` : "no goal PR to land");
2104
+ id = String(ready[0].runId);
2105
+ }
2106
+ const out = await client.landingAction(project.id, "land", { runId: id });
2107
+ const l = out.result;
2108
+ console.log(l.state === "landing" ? `${pc.green("✓")} PR #${l.pr} will merge when its checks and approvals pass` : `PR #${l.pr}: ${l.state}${l.reason ? ` — ${l.reason}` : ""}`);
2109
+ }
2110
+ catch (err) {
2111
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
2112
+ process.exitCode = 1;
2113
+ }
187
2114
  });
2115
+ // ── Loom Cloud: reach this daemon from any network (daemon/relay.ts) ──
188
2116
  program
189
- .command("status")
190
- .description("daemon health and project board")
2117
+ .command("cloud [action]")
2118
+ .description("Loom Cloud relay: status | enable | disable | rotate — reach your agents from anywhere, end-to-end encrypted")
2119
+ .option("--url <supabaseUrl>", "Supabase project URL (or LOOM_SUPABASE_URL)")
2120
+ .option("--key <anonKey>", "Supabase anon key (or LOOM_SUPABASE_ANON_KEY)")
2121
+ .action(async (action, opts) => {
2122
+ const client = await ensureDaemon();
2123
+ try {
2124
+ const a = (action ?? "status").toLowerCase();
2125
+ let s;
2126
+ if (a === "status")
2127
+ s = await client.cloud();
2128
+ else if (a === "enable" || a === "disable" || a === "rotate") {
2129
+ s = await client.cloudAction(a, {
2130
+ ...(opts.url ? { supabaseUrl: opts.url } : {}),
2131
+ ...(opts.key ? { anonKey: opts.key } : {}),
2132
+ });
2133
+ }
2134
+ else
2135
+ throw new Error(`unknown action "${a}" — status, enable, disable or rotate`);
2136
+ const on = s.connected ? pc.green("connected") : s.enabled ? pc.yellow("enabled, not connected") : pc.dim("off");
2137
+ console.log(`Loom Cloud ${on}${s.supabaseUrl ? pc.dim(` via ${String(s.supabaseUrl)}`) : ""}`);
2138
+ if (s.connected)
2139
+ console.log(pc.dim(` ${String(s.clients)} phone(s) on the relay · end-to-end encrypted — Supabase relays ciphertext only`));
2140
+ if (s.error)
2141
+ console.log(pc.red(` ${String(s.error)}`));
2142
+ if (!s.configured)
2143
+ console.log(pc.dim(" set a Supabase project: loom cloud enable --url https://<ref>.supabase.co --key <anon key>"));
2144
+ if (a === "enable" && s.connected)
2145
+ console.log(pc.dim(" pair a phone with `loom pair` — its QR now works from any network"));
2146
+ if (a === "rotate")
2147
+ console.log(pc.dim(" new key minted — phones paired through the cloud must pair again"));
2148
+ }
2149
+ catch (err) {
2150
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
2151
+ process.exitCode = 1;
2152
+ }
2153
+ });
2154
+ program
2155
+ .command("subtasks")
2156
+ .description("subtasks running right now")
191
2157
  .action(async () => {
192
- const cfg = await daemonRunning();
193
- if (!cfg) {
194
- console.log(pc.dim("daemon: not running (loom up)"));
195
- return;
2158
+ const client = await ensureDaemon();
2159
+ const project = await currentProject(client);
2160
+ const { subtasks } = await client.subtasks(project.id);
2161
+ if (!subtasks.length)
2162
+ return void console.log(pc.dim("nothing running"));
2163
+ for (const s of subtasks) {
2164
+ console.log(`${pc.cyan(s.agentId)} ${pc.dim("for " + s.parent)} ${s.task.slice(0, 60)}`);
196
2165
  }
197
- console.log(pc.green(`daemon: http://${cfg.host}:${cfg.port}`));
198
- const client = new DaemonClient(cfg);
199
- const { projects } = await client.listProjects();
200
- if (!projects.length)
201
- console.log(pc.dim("no projects yet — loom init"));
202
- for (const p of projects)
203
- console.log(formatProjectRow(p));
204
2166
  });
205
- // ---------------------------------------------------------------------------
206
- // init / projects / agents
207
- // ---------------------------------------------------------------------------
208
2167
  program
209
- .command("init")
210
- .description("make the current directory a Loom project")
211
- .option("--name <name>", "project name (default: directory name)")
212
- .action(async (opts) => {
2168
+ .command("agents:add <kind>")
2169
+ .description("add an agent session to this project (repeat for a second session of the same kind)")
2170
+ .option("--as <name>", "name this instance (default: the kind, then kind-2, kind-3…)")
2171
+ .option("--role <role>", "what this instance is for, e.g. planner or reviewer")
2172
+ .option("--model <model>", "for --kind model: which model it runs (loom models)")
2173
+ .option("--provider <id>", "for --kind model: which provider (default openrouter)")
2174
+ .option("--tools", "for --kind model: let it read the project (read-only)")
2175
+ .option("--write", "for --kind model: let it write files too (every write asks you first)")
2176
+ .option("--run <list>", 'for --kind model: commands it may run, comma-separated, e.g. "npm test"')
2177
+ .action(async (kind, opts) => {
213
2178
  const client = await ensureDaemon();
214
- const dir = process.cwd();
215
- const res = await client.addProject(dir, opts.name);
216
- const { project } = await client.project(res.project.id);
217
- console.log(pc.green(`✓ project "${project.name}" (${project.id})`));
218
- for (const a of project.agents)
219
- console.log(formatAgentRow(a));
220
- console.log(pc.dim("\nedit .loom/config.json to add/remove agents or change roles"));
221
- console.log(pc.dim("then: loom chat"));
2179
+ const project = await currentProject(client);
2180
+ // A model agent with no model refuses every turn, so it's set here rather
2181
+ // than in a second step nobody mentions.
2182
+ const allowed = (opts.run ?? "").split(",").map((c) => c.trim()).filter(Boolean);
2183
+ // Writing and running are read-and-then-some: neither is reachable at all
2184
+ // without --tools, so asking for one without it is a mistake worth naming.
2185
+ if ((opts.write || allowed.length) && !opts.tools) {
2186
+ fail("--write and --run need --tools as well — they're the writing half of it");
2187
+ }
2188
+ const options = {
2189
+ ...(opts.model ? { model: opts.model } : {}),
2190
+ ...(opts.provider ? { provider: opts.provider } : {}),
2191
+ ...(opts.tools ? { tools: true } : {}),
2192
+ ...(opts.write ? { write: true } : {}),
2193
+ ...(allowed.length ? { run: allowed } : {}),
2194
+ };
2195
+ if (kind === "model" && !opts.model) {
2196
+ fail('a model agent needs a model: --model "<id>" (see loom models)');
2197
+ }
2198
+ try {
2199
+ const added = await client.addAgent(project.id, kind, {
2200
+ ...opts,
2201
+ ...(Object.keys(options).length ? { options } : {}),
2202
+ });
2203
+ const siblings = project.agents.filter((a) => a.kind === kind).length;
2204
+ console.log(`${pc.green("+")} ${pc.bold(added.id)} ${pc.dim(`(${added.kind} · ${added.role})`)}` +
2205
+ (siblings ? pc.dim(` — session ${siblings + 1} of ${kind}, sharing this project's brain`) : ""));
2206
+ }
2207
+ catch (err) {
2208
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
2209
+ process.exitCode = 1;
2210
+ }
222
2211
  });
223
2212
  program
224
- .command("projects")
225
- .description("board of all projects")
226
- .action(async () => {
2213
+ .command("agents:rm <agentId>")
2214
+ .description("remove an agent session from this project")
2215
+ .action(async (agentId) => {
227
2216
  const client = await ensureDaemon();
228
- const { projects } = await client.listProjects();
229
- if (!projects.length)
230
- console.log(pc.dim("no projects yet — loom init"));
231
- for (const p of projects)
232
- console.log(formatProjectRow(p));
2217
+ const project = await currentProject(client);
2218
+ try {
2219
+ await client.removeAgent(project.id, agentId);
2220
+ console.log(`${pc.red("-")} ${agentId} removed`);
2221
+ }
2222
+ catch (err) {
2223
+ console.error(pc.red(err instanceof Error ? err.message : String(err)));
2224
+ process.exitCode = 1;
2225
+ }
233
2226
  });
234
2227
  program
235
- .command("agents")
236
- .description("agents in the current project")
237
- .action(async () => {
2228
+ .command("agents:available")
2229
+ .description("which agents this machine can drive, and how many sessions are here")
2230
+ .option("--json", "print as JSON")
2231
+ .action(async (opts) => {
238
2232
  const client = await ensureDaemon();
239
2233
  const project = await currentProject(client);
240
- for (const a of project.agents)
241
- console.log(formatAgentRow(a));
2234
+ const { ades } = await client.availableAgents(project.id);
2235
+ if (opts.json)
2236
+ return void console.log(JSON.stringify(ades, null, 2));
2237
+ for (const a of ades) {
2238
+ const state = a.installed === false
2239
+ ? pc.dim("not installed")
2240
+ : a.instances
2241
+ ? pc.green(`${a.instances} session${a.instances === 1 ? "" : "s"} here`)
2242
+ : pc.dim("available");
2243
+ console.log(`${a.kind.padEnd(18)} ${a.label.padEnd(20)} ${state}`);
2244
+ }
2245
+ });
2246
+ program
2247
+ .command("models [agentId]")
2248
+ .description("with an agent: the models it reports. Without: what every configured provider has")
2249
+ .option("--provider <id>", "just this provider")
2250
+ .option("--refresh", "ask again instead of trusting the cache")
2251
+ .option("--free", "only the ones that cost nothing")
2252
+ .action(async (agentId, opts) => {
2253
+ // An agent named means "what can THIS agent run" — a CLI reports its own.
2254
+ if (agentId) {
2255
+ const client = await ensureDaemon();
2256
+ const project = await currentProject(client);
2257
+ const { kind, models } = await client.models(project.id, agentId);
2258
+ if (!models.length) {
2259
+ console.log(pc.dim(`${agentId} (${kind}) reports no selectable models`));
2260
+ return;
2261
+ }
2262
+ console.log(pc.bold(`${agentId} (${kind}) · ${models.length} model${models.length === 1 ? "" : "s"}`));
2263
+ for (const model of models)
2264
+ console.log(model);
2265
+ return;
2266
+ }
2267
+ // No agent: the provider catalogue, which needs no daemon and no project.
2268
+ const refresh = Boolean(opts.refresh);
2269
+ let models;
2270
+ let errors = [];
2271
+ if (opts.provider) {
2272
+ const p = resolveProvider(opts.provider);
2273
+ if (!p)
2274
+ fail(`no provider "${opts.provider}" — loom providers`);
2275
+ const got = await fetchModels(p, refresh ? { refresh: true } : {});
2276
+ models = got.models;
2277
+ if (got.error)
2278
+ errors = [{ provider: p.id, error: got.error }];
2279
+ }
2280
+ else {
2281
+ const got = await allModels(refresh ? { refresh: true } : {});
2282
+ models = got.models;
2283
+ errors = got.errors;
2284
+ }
2285
+ const shown = opts.free ? models.filter((m) => m.free) : models;
2286
+ if (!shown.length)
2287
+ console.log(pc.dim("no models — configure a provider with loom providers:set"));
2288
+ for (const m of shown) {
2289
+ console.log(`${pc.bold(m.id)} ${pc.dim(m.provider)}${m.free ? " " + pc.green("free") : ""}` +
2290
+ (m.endpoints ? pc.dim(` [${m.endpoints.join(", ")}]`) : ""));
2291
+ }
2292
+ for (const e of errors)
2293
+ console.log(pc.yellow(`⚠ ${e.provider}: ${e.error}`));
242
2294
  });
243
2295
  // ---------------------------------------------------------------------------
244
2296
  // send / chat / handoff / interrupt / decision / log
@@ -385,6 +2437,339 @@ program
385
2437
  const { interrupted } = await client.interrupt(project.id);
386
2438
  console.log(pc.dim(interrupted ? `interrupted ${interrupted}` : "nothing running"));
387
2439
  });
2440
+ program
2441
+ .command("digest")
2442
+ .description("what happened in this project while you were away")
2443
+ .option("--since <hours>", "how far back to look, in hours", "12")
2444
+ .action(async (opts) => {
2445
+ const client = await ensureDaemon();
2446
+ const project = await currentProject(client);
2447
+ const hours = Math.max(0.1, Number(opts.since) || 12);
2448
+ const d = await client.digest(project.id, Date.now() - hours * 3_600_000);
2449
+ if (!d.lines.length) {
2450
+ console.log(pc.dim(`nothing in the last ${hours} hour${hours === 1 ? "" : "s"}`));
2451
+ return;
2452
+ }
2453
+ const paint = {
2454
+ question: pc.yellow,
2455
+ failed: pc.red,
2456
+ landed: pc.green,
2457
+ goal: pc.cyan,
2458
+ server: pc.red,
2459
+ cost: pc.dim,
2460
+ turn: pc.dim,
2461
+ };
2462
+ for (const line of d.lines) {
2463
+ const when = new Date(line.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2464
+ console.log(`${pc.dim(when)} ${(paint[line.kind] ?? ((x) => x))(line.text)}`);
2465
+ }
2466
+ if (d.waiting.length)
2467
+ console.log(pc.yellow(`\nwaiting on you: ${d.waiting.join(", ")}`));
2468
+ });
2469
+ /**
2470
+ * The project's dev servers — the thing the Browser tab previews.
2471
+ *
2472
+ * `loom servers` says what each one is doing, and "running" means a port
2473
+ * answered rather than a process existing. Starting and stopping are explicit:
2474
+ * nothing here runs a command because it guessed you wanted it.
2475
+ */
2476
+ const serversCmd = program
2477
+ .command("servers")
2478
+ .description("this project's dev servers: what they are and what they're doing")
2479
+ .action(async () => {
2480
+ const client = await ensureDaemon();
2481
+ const project = await currentProject(client);
2482
+ const { servers, suggested } = await client.servers(project.id);
2483
+ if (!servers.length) {
2484
+ console.log(pc.dim("no servers configured for this project"));
2485
+ if (suggested.length) {
2486
+ console.log(pc.dim("package.json suggests:"));
2487
+ for (const s of suggested)
2488
+ console.log(` ${pc.cyan(s.name.padEnd(10))} ${s.command}${s.port ? pc.dim(` :${s.port}`) : ""}`);
2489
+ console.log(pc.dim('add them under "servers" in .loom/config.json'));
2490
+ }
2491
+ return;
2492
+ }
2493
+ const paint = (s) => s.state === "running" ? pc.green("running") : s.state === "starting" ? pc.yellow("starting") : s.state === "crashed" ? pc.red("crashed") : pc.dim("stopped");
2494
+ for (const s of servers) {
2495
+ const up = s.startedAt ? pc.dim(` up ${Math.max(1, Math.round((Date.now() - s.startedAt) / 1000))}s`) : "";
2496
+ const why = s.state === "crashed" && s.exitCode !== null ? pc.dim(` (exit ${s.exitCode})`) : "";
2497
+ console.log(`${pc.cyan(s.name.padEnd(12))} ${paint(s).padEnd(18)} ${s.port ? pc.dim(":" + s.port) : ""}${up}${why}`);
2498
+ console.log(`${" ".repeat(13)}${pc.dim(s.command)}`);
2499
+ }
2500
+ });
2501
+ for (const action of ["start", "stop", "restart"]) {
2502
+ serversCmd
2503
+ .command(`${action} <name>`)
2504
+ .description(`${action} a dev server`)
2505
+ .action(async (name) => {
2506
+ const client = await ensureDaemon();
2507
+ const project = await currentProject(client);
2508
+ const { server } = await client.serverAction(project.id, name, action);
2509
+ console.log(pc.dim(`${server.name}: ${server.state}${server.port ? ` on :${server.port}` : ""}`));
2510
+ });
2511
+ }
2512
+ serversCmd
2513
+ .command("logs <name>")
2514
+ .description("a dev server's recent output")
2515
+ .option("-n, --lines <n>", "how many lines", "80")
2516
+ .action(async (name, opts) => {
2517
+ const client = await ensureDaemon();
2518
+ const project = await currentProject(client);
2519
+ const { lines } = await client.serverLog(project.id, name, Math.max(1, Number(opts.lines) || 80));
2520
+ for (const l of lines) {
2521
+ const mark = l.stream === "err" ? pc.red("!") : l.stream === "loom" ? pc.dim("\u00b7") : " ";
2522
+ console.log(`${mark} ${l.text}`);
2523
+ }
2524
+ if (!lines.length)
2525
+ console.log(pc.dim("nothing yet"));
2526
+ });
2527
+ /**
2528
+ * Is there a newer Loom, and fetch it.
2529
+ *
2530
+ * `--check` only looks. Without it, the update runs the same commands the
2531
+ * daemon would print: a checkout pulls and rebuilds, a global install
2532
+ * reinstalls itself, and anything else is told where the release is rather
2533
+ * than having a command guessed at and run over it.
2534
+ */
2535
+ program
2536
+ .command("update")
2537
+ .description("check for a newer Loom and install it")
2538
+ .option("--check", "only say whether an update exists")
2539
+ .option("--yes", "don't ask before updating")
2540
+ .action(async (opts) => {
2541
+ const client = await ensureDaemon();
2542
+ const u = await client.updates(true);
2543
+ console.log(`installed ${u.version}${u.latest ? ` latest ${u.latest}` : ""} ${pc.dim(u.install)}`);
2544
+ if (!u.behindRelease) {
2545
+ console.log(pc.dim(u.latest ? "up to date" : "no published release to compare with"));
2546
+ if (u.git?.behind)
2547
+ console.log(pc.yellow(`the checkout is ${u.git.behind} commit(s) behind ${u.git.branch}`));
2548
+ return;
2549
+ }
2550
+ console.log(pc.yellow(`Loom ${u.latest} is out`) + (u.release?.url ? pc.dim(` ${u.release.url}`) : ""));
2551
+ if (opts.check)
2552
+ return;
2553
+ if (!u.canApply) {
2554
+ console.log(pc.dim(u.refusal ?? "this install can't update itself"));
2555
+ return;
2556
+ }
2557
+ console.log("this will run:");
2558
+ for (const step of u.steps)
2559
+ console.log(` ${pc.cyan(step)}`);
2560
+ if (!opts.yes) {
2561
+ const ok = await confirm("update now?");
2562
+ if (!ok)
2563
+ return console.log(pc.dim("left as it is"));
2564
+ }
2565
+ await client.applyUpdate();
2566
+ console.log(pc.dim("updating — the daemon restarts on the new build when it finishes (loom log -f to watch)"));
2567
+ });
2568
+ /**
2569
+ * The prompt queue: what you've lined up for this project, run one at a time.
2570
+ *
2571
+ * `loom queue` shows it; `loom queue add` puts one at the back; the rest edit
2572
+ * what's waiting. Positions are what you see in the list (1 is next to run), so
2573
+ * `loom queue rm 2` removes the one printed as 2 — ids work too.
2574
+ */
2575
+ const queueCmd = program
2576
+ .command("queue")
2577
+ .description("prompts lined up for this project — see, edit, reorder, pause")
2578
+ .action(async () => {
2579
+ const client = await ensureDaemon();
2580
+ const project = await currentProject(client);
2581
+ const view = await client.queue(project.id);
2582
+ if (!view.queue.length) {
2583
+ console.log(pc.dim("nothing queued — loom queue add \"…\" puts one in line"));
2584
+ return;
2585
+ }
2586
+ const where = (t) => t.kind === "orchestra" ? "orchestrate" : t.kind === "auto" ? "auto" : t.agentId;
2587
+ view.queue.forEach((item, i) => {
2588
+ const head = `${pc.dim(String(i + 1).padStart(2))} ${pc.cyan(where(item.target).padEnd(12))}`;
2589
+ const lines = item.text.split("\n");
2590
+ console.log(`${head} ${lines[0]}${lines.length > 1 ? pc.dim(` +${lines.length - 1} more lines`) : ""}`);
2591
+ console.log(`${" ".repeat(15)}${pc.dim(item.id + (item.editedAt ? " · edited" : "") + (item.plan ? " · plan mode" : ""))}`);
2592
+ });
2593
+ const note = view.paused ? pc.yellow(view.reason ?? "paused") : view.waitingFor ? pc.dim(view.waitingFor) : pc.dim("next one goes as soon as it can");
2594
+ console.log(`\n${view.queue.length} queued${view.paused ? pc.yellow(" · paused") : ""} · ${note}`);
2595
+ });
2596
+ /** "15:00", "2026-09-21T03:00", "+90m" — the ways a person says when. */
2597
+ function readWhen(when) {
2598
+ const rel = /^\+(\d+)\s*([smhd])$/i.exec(when.trim());
2599
+ if (rel) {
2600
+ const n = Number(rel[1]);
2601
+ const unit = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[rel[2].toLowerCase()];
2602
+ return Date.now() + n * unit;
2603
+ }
2604
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(when.trim());
2605
+ if (clock) {
2606
+ const d = new Date();
2607
+ d.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
2608
+ // a time already past today means tomorrow — nobody queues for the past
2609
+ if (d.getTime() <= Date.now())
2610
+ d.setDate(d.getDate() + 1);
2611
+ return d.getTime();
2612
+ }
2613
+ const parsed = Date.parse(when);
2614
+ if (Number.isFinite(parsed))
2615
+ return parsed;
2616
+ throw new Error(`I can't read "${when}" as a time — try 15:00, +90m, or 2026-09-21T03:00`);
2617
+ }
2618
+ /** The item you meant: a position from the printed list, or an id. */
2619
+ async function queueItemId(client, projectId, which) {
2620
+ const { queue } = await client.queue(projectId);
2621
+ const n = Number(which);
2622
+ if (Number.isInteger(n) && n >= 1 && n <= queue.length)
2623
+ return queue[n - 1].id;
2624
+ const hit = queue.find((i) => i.id === which);
2625
+ if (!hit)
2626
+ throw new Error(`no queued prompt "${which}" — loom queue lists them`);
2627
+ return hit.id;
2628
+ }
2629
+ queueCmd
2630
+ .command("add <text...>")
2631
+ .description("line a prompt up behind whatever is running")
2632
+ .option("--to <target>", 'who takes it: an agent id, "orchestrate" for a new goal, or "auto"', "auto")
2633
+ .option("--plan", "queue it in plan mode")
2634
+ .action(async (text, opts) => {
2635
+ const client = await ensureDaemon();
2636
+ const project = await currentProject(client);
2637
+ const target = opts.to === "orchestrate" ? "orchestra" : opts.to;
2638
+ const res = await client.queueAdd(project.id, { text: text.join(" "), target, ...(opts.plan ? { plan: true } : {}) });
2639
+ console.log(pc.dim(`queued ${res.item.id} · ${res.queue.length} waiting`));
2640
+ });
2641
+ queueCmd
2642
+ .command("edit <which> <text...>")
2643
+ .description("rewrite a queued prompt (by position or id)")
2644
+ .action(async (which, text) => {
2645
+ const client = await ensureDaemon();
2646
+ const project = await currentProject(client);
2647
+ const id = await queueItemId(client, project.id, which);
2648
+ await client.queueEdit(project.id, id, { text: text.join(" ") });
2649
+ console.log(pc.dim(`edited ${id}`));
2650
+ });
2651
+ queueCmd
2652
+ .command("to <which> <target>")
2653
+ .description('send a waiting prompt somewhere else: an agent id, "orchestrate" or "auto"')
2654
+ .action(async (which, target) => {
2655
+ const client = await ensureDaemon();
2656
+ const project = await currentProject(client);
2657
+ const id = await queueItemId(client, project.id, which);
2658
+ await client.queueEdit(project.id, id, { target: target === "orchestrate" ? "orchestra" : target });
2659
+ console.log(pc.dim(`${id} → ${target}`));
2660
+ });
2661
+ queueCmd
2662
+ .command("move <which> <position>")
2663
+ .description("move a queued prompt (1 is next to run)")
2664
+ .action(async (which, position) => {
2665
+ const client = await ensureDaemon();
2666
+ const project = await currentProject(client);
2667
+ const id = await queueItemId(client, project.id, which);
2668
+ await client.queueEdit(project.id, id, { to: Math.max(0, Number(position) - 1) });
2669
+ console.log(pc.dim(`moved ${id} to ${position}`));
2670
+ });
2671
+ queueCmd
2672
+ .command("rm <which>")
2673
+ .description("drop a queued prompt before it runs")
2674
+ .action(async (which) => {
2675
+ const client = await ensureDaemon();
2676
+ const project = await currentProject(client);
2677
+ const id = await queueItemId(client, project.id, which);
2678
+ const res = await client.queueRemove(project.id, id);
2679
+ console.log(pc.dim(`removed ${id} · ${res.queue.length} waiting`));
2680
+ });
2681
+ queueCmd
2682
+ .command("at <when> <text...>")
2683
+ .description('queue a prompt for later — "15:00", "2026-09-21T03:00", or "+90m"')
2684
+ .option("--to <target>", 'who takes it: an agent id, "orchestrate" or "auto"', "auto")
2685
+ .action(async (when, text, opts) => {
2686
+ const client = await ensureDaemon();
2687
+ const project = await currentProject(client);
2688
+ const at = readWhen(when);
2689
+ const res = await client.queueAdd(project.id, {
2690
+ text: text.join(" "),
2691
+ target: opts.to === "orchestrate" ? "orchestra" : opts.to,
2692
+ when: { kind: "at", at },
2693
+ });
2694
+ console.log(pc.dim(`queued ${res.item.id} for ${new Date(at).toLocaleString()}`));
2695
+ });
2696
+ queueCmd
2697
+ .command("after <what> <text...>")
2698
+ .description('queue a prompt until a goal lands or its checks go green: "landed:<runId>" or "green:<runId>"')
2699
+ .option("--to <target>", "who takes it", "auto")
2700
+ .action(async (what, text, opts) => {
2701
+ const client = await ensureDaemon();
2702
+ const project = await currentProject(client);
2703
+ const [kind, runId] = what.split(":");
2704
+ if (!runId || (kind !== "landed" && kind !== "green")) {
2705
+ throw new Error('say landed:<runId> or green:<runId> — loom orchestra lists the runs');
2706
+ }
2707
+ const res = await client.queueAdd(project.id, {
2708
+ text: text.join(" "),
2709
+ target: opts.to === "orchestrate" ? "orchestra" : opts.to,
2710
+ when: { kind: kind === "landed" ? "landed" : "checks-green", runId },
2711
+ });
2712
+ console.log(pc.dim(`queued ${res.item.id}, waiting for ${runId}`));
2713
+ });
2714
+ queueCmd
2715
+ .command("save <name>")
2716
+ .description("save what's queued as a recipe you can run anywhere")
2717
+ .action(async (name) => {
2718
+ const client = await ensureDaemon();
2719
+ const project = await currentProject(client);
2720
+ const { recipe } = await client.saveQueueRecipe(project.id, name);
2721
+ console.log(pc.dim(`saved "${recipe.name}" — ${recipe.steps.length} step(s)`));
2722
+ });
2723
+ queueCmd
2724
+ .command("run <name>")
2725
+ .description("queue a saved recipe on this project")
2726
+ .action(async (name) => {
2727
+ const client = await ensureDaemon();
2728
+ const project = await currentProject(client);
2729
+ const res = await client.runQueueRecipe(project.id, name);
2730
+ console.log(pc.dim(`queued ${res.added} step(s) — ${res.queue.length} waiting`));
2731
+ });
2732
+ queueCmd
2733
+ .command("recipes")
2734
+ .description("the recipes saved on this machine")
2735
+ .action(async () => {
2736
+ const client = await ensureDaemon();
2737
+ const { recipes } = await client.recipes();
2738
+ if (!recipes.length)
2739
+ return console.log(pc.dim("none saved — loom queue save <name>"));
2740
+ for (const r of recipes) {
2741
+ console.log(`${pc.cyan(r.name)} ${pc.dim(`${r.steps.length} step(s)${r.fromProject ? ` · from ${r.fromProject}` : ""}`)}`);
2742
+ for (const s of r.steps)
2743
+ console.log(` ${pc.dim(s.to.padEnd(12))} ${s.text.split("\n")[0].slice(0, 80)}`);
2744
+ }
2745
+ });
2746
+ queueCmd
2747
+ .command("clear")
2748
+ .description("drop everything that's waiting")
2749
+ .action(async () => {
2750
+ const client = await ensureDaemon();
2751
+ const project = await currentProject(client);
2752
+ const res = await client.queueClear(project.id);
2753
+ console.log(pc.dim(`dropped ${res.dropped}`));
2754
+ });
2755
+ queueCmd
2756
+ .command("pause")
2757
+ .description("hold the queue where it is")
2758
+ .action(async () => {
2759
+ const client = await ensureDaemon();
2760
+ const project = await currentProject(client);
2761
+ await client.queuePause(project.id, true);
2762
+ console.log(pc.dim("paused — loom queue resume lets it run"));
2763
+ });
2764
+ queueCmd
2765
+ .command("resume")
2766
+ .description("let the queue run again")
2767
+ .action(async () => {
2768
+ const client = await ensureDaemon();
2769
+ const project = await currentProject(client);
2770
+ const view = await client.queuePause(project.id, false);
2771
+ console.log(pc.dim(`running · ${view.queue.length} waiting`));
2772
+ });
388
2773
  const memoryCmd = program
389
2774
  .command("memory")
390
2775
  .description("the unified brain — one memory across every connected ADE")
@@ -410,9 +2795,23 @@ memoryCmd
410
2795
  program
411
2796
  .command("costs")
412
2797
  .description("what this project has spent, per agent")
413
- .action(async () => {
2798
+ .option("--series [days]", "daily breakdown instead of totals (default 14 days)")
2799
+ .action(async (opts) => {
414
2800
  const client = await ensureDaemon();
415
2801
  const project = await currentProject(client);
2802
+ if (opts.series) {
2803
+ const days = typeof opts.series === "string" ? Number(opts.series) || 14 : 14;
2804
+ const { series } = await client.costSeries(project.id, days);
2805
+ if (!series.length)
2806
+ return void console.log(pc.dim("no spend in this window"));
2807
+ for (const d of series) {
2808
+ const agents = Object.entries(d.byAgent)
2809
+ .map(([id, a]) => `${id} ${fmtUsd(a.usd)}`)
2810
+ .join(" ");
2811
+ console.log(`${d.day} ${fmtUsd(d.usd).padStart(9)} ${String(d.turns).padStart(4)} turns ${pc.dim(agents)}`);
2812
+ }
2813
+ return;
2814
+ }
416
2815
  const { costs } = await client.costs(project.id);
417
2816
  console.log(pc.bold(`${project.name}`) +
418
2817
  pc.dim(` total ${fmtUsd(costs.totalUsd)} · ${costs.turns} turns · ${(costs.totalMs / 1000).toFixed(0)}s agent time`));
@@ -491,7 +2890,9 @@ program
491
2890
  return;
492
2891
  }
493
2892
  if (!spec || !words.length) {
494
- fail('usage: loom route <name|steps> "<task>" e.g. loom route planner,executor "add dark mode"\n (or: loom route --status / --abort)');
2893
+ fail('usage: loom route <name|steps> "<task>" e.g. loom route planner,executor "add dark mode"\n' +
2894
+ " a step runs only if the previous turn meets its condition: 'planner,executor,reviewer?lines>200'\n" +
2895
+ " (or: loom route --status / --abort)");
495
2896
  }
496
2897
  const { route } = await client.startRoute(project.id, words.join(" "), spec, {
497
2898
  ...(opts.router ? { router: opts.router } : {}),
@@ -547,33 +2948,51 @@ program
547
2948
  program
548
2949
  .command("doctor")
549
2950
  .description("diagnose the environment, daemon, and current project")
550
- .action(async () => {
551
- const { envChecks, projectChecks } = await import("./doctor.js");
552
- const checks = await envChecks();
2951
+ .option("--json", "print machine-readable diagnostic results")
2952
+ .option("--fix", "repair what has exactly one safe repair; report the rest")
2953
+ .action(async (opts) => {
2954
+ const { doctorReport, envChecks, projectChecks, fixProject } = await import("./doctor.js");
553
2955
  const dir = currentProjectDir();
2956
+ if (opts.fix) {
2957
+ if (!dir) {
2958
+ console.error(pc.red("not inside a Loom project — nothing to fix"));
2959
+ process.exitCode = 1;
2960
+ return;
2961
+ }
2962
+ const { fixed, unfixable } = fixProject(dir);
2963
+ for (const f of fixed)
2964
+ console.log(` ${pc.green("✓")} ${f}`);
2965
+ for (const u of unfixable)
2966
+ console.log(` ${pc.yellow("⚠")} ${u}`);
2967
+ if (!fixed.length && !unfixable.length)
2968
+ console.log(pc.dim("nothing needed fixing"));
2969
+ // fall through to a fresh diagnosis, so the report reflects the repairs
2970
+ }
2971
+ const checks = await envChecks();
554
2972
  if (dir)
555
2973
  checks.push(...projectChecks(dir));
556
2974
  else
557
2975
  checks.push({ name: "project", status: "warn", detail: "not inside a Loom project (loom init)" });
558
- let failures = 0;
559
- let warnings = 0;
2976
+ const report = doctorReport(checks);
2977
+ if (opts.json) {
2978
+ console.log(JSON.stringify(report));
2979
+ if (report.summary.failures)
2980
+ process.exitCode = 1;
2981
+ return;
2982
+ }
560
2983
  for (const c of checks) {
561
2984
  const icon = c.status === "ok" ? pc.green("✓") : c.status === "warn" ? pc.yellow("⚠") : pc.red("✗");
562
- if (c.status === "fail")
563
- failures++;
564
- if (c.status === "warn")
565
- warnings++;
566
2985
  console.log(` ${icon} ${pc.bold(c.name.padEnd(11))} ${c.status === "ok" ? pc.dim(c.detail) : c.detail}`);
567
2986
  }
568
- if (failures) {
569
- console.log(pc.red(`\n${failures} problem${failures > 1 ? "s" : ""} found`));
2987
+ if (report.summary.failures) {
2988
+ console.log(pc.red(`\n${report.summary.failures} problem${report.summary.failures > 1 ? "s" : ""} found`));
570
2989
  process.exit(1);
571
2990
  }
572
2991
  // "all clear" over a screen of warnings is a lie, and it's the lie that
573
2992
  // teaches people to stop reading this command. Nothing is broken; several
574
2993
  // things aren't set up. Those are different sentences.
575
- if (warnings) {
576
- console.log(pc.yellow(`\nnothing broken · ${warnings} thing${warnings > 1 ? "s" : ""} not set up (see above)`));
2994
+ if (report.summary.warnings) {
2995
+ console.log(pc.yellow(`\nnothing broken · ${report.summary.warnings} thing${report.summary.warnings > 1 ? "s" : ""} not set up (see above)`));
577
2996
  return;
578
2997
  }
579
2998
  console.log(pc.green("\nall clear"));
@@ -625,15 +3044,160 @@ program
625
3044
  " testing on this machine only? loom pair --allow-local"));
626
3045
  process.exit(1);
627
3046
  }
628
- const { token, expiresAt, url } = await client.newPairingToken();
3047
+ const minted = await client.newPairingToken();
3048
+ const { token, expiresAt, url } = minted;
629
3049
  // Deep link: scanning with any camera opens the phone app, which claims
630
3050
  // the (single-use, 10-min) token from the URL fragment and pairs itself.
631
- const link = `${url}/app#pair=${token}`;
3051
+ // The daemon's own link carries the Loom Cloud route when Cloud is on, so
3052
+ // a phone paired from the terminal can reach home from any network too.
3053
+ const link = minted.link ?? `${url}/app#pair=${token}`;
632
3054
  qrcode.generate(link, { small: true }, (qr) => console.log(qr));
633
3055
  console.log(pc.bold(` ${link}`));
634
3056
  console.log(pc.dim(` scan with your phone camera · single use · expires ${new Date(expiresAt).toLocaleTimeString()}`));
635
3057
  console.log(pc.dim(` (manual claim: POST ${url}/api/pair/claim {"token":"${token.slice(0, 6)}…"})`));
636
3058
  });
3059
+ program
3060
+ .command("backup")
3061
+ .description("everything Loom knows — settings, pairings, prompts, every project's log and brain — in one .tar.gz")
3062
+ .option("--out <file>", "where to write it (default: ~/loom-backup-<time>.tar.gz)")
3063
+ .action((opts) => {
3064
+ const r = makeBackup(opts.out);
3065
+ const mb = (r.bytes / 1024 / 1024).toFixed(1);
3066
+ console.log(pc.green(`✓ backed up ${r.projects} project${r.projects === 1 ? "" : "s"} and ~/.loom → ${r.file} (${mb} MB)`));
3067
+ console.log(pc.dim(" left out: orchestra worktrees (their branches are in your repos) and the model cache"));
3068
+ console.log(pc.yellow(" it holds your pairing tokens and cloud keys — keep it somewhere private"));
3069
+ });
3070
+ program
3071
+ .command("export")
3072
+ .description("write a chat as Markdown — prompts, replies, what was done, what changed")
3073
+ .option("--chat <chat>", "the chat's id or title (default: main)", "main")
3074
+ .option("--out <file>", "write to a file instead of printing")
3075
+ .action(async (opts) => {
3076
+ const client = await ensureDaemon();
3077
+ const project = await currentProject(client);
3078
+ const { chats } = await client.chats(project.id);
3079
+ const want = opts.chat.trim().toLowerCase();
3080
+ const chat = chats.find((c) => c.id === opts.chat) ?? chats.find((c) => c.title.toLowerCase() === want);
3081
+ if (!chat)
3082
+ fail(`no chat "${opts.chat}" in ${project.name} — try one of: ${chats.map((c) => c.title).join(", ")}`);
3083
+ let events = [];
3084
+ let before;
3085
+ for (let page = 0; page < 20; page++) {
3086
+ const { events: evs } = await client.chatEvents(project.id, chat.id, { ...(before ? { before } : {}), limit: 500 });
3087
+ events = [...evs, ...events];
3088
+ if (evs.length < 500)
3089
+ break;
3090
+ before = evs[0].id;
3091
+ }
3092
+ const md = threadMarkdown(events, `${project.name} — ${chat.title}`);
3093
+ if (!opts.out)
3094
+ return void process.stdout.write(md);
3095
+ fs.writeFileSync(opts.out, md);
3096
+ console.error(pc.green(`✓ ${events.length} events → ${opts.out}`));
3097
+ });
3098
+ program
3099
+ .command("replay")
3100
+ .description("a chat as one self-contained HTML page to share — prompts, replies, what ran, what changed, what Loom checked")
3101
+ .option("--chat <chat>", "the chat's id or title (default: main)", "main")
3102
+ .option("--out <file>", "where to write it (default: <project>-<chat>.replay.html)")
3103
+ .option("--open", "open it in your browser")
3104
+ .action(async (opts) => {
3105
+ const client = await ensureDaemon();
3106
+ const project = await currentProject(client);
3107
+ const want = opts.chat.trim().toLowerCase();
3108
+ let chat = { id: "main", title: "Main" };
3109
+ if (want !== "main") {
3110
+ const { chats } = await client.chats(project.id);
3111
+ const found = chats.find((c) => c.id === opts.chat) ?? chats.find((c) => c.title.toLowerCase() === want) ?? chats.find((c) => c.title.toLowerCase().includes(want));
3112
+ if (!found)
3113
+ fail(`no chat "${opts.chat}" in ${project.name} — try one of: ${chats.map((c) => c.title).join(", ")}`);
3114
+ chat = found;
3115
+ }
3116
+ const html = await client.replay(project.id, chat.id);
3117
+ const out = opts.out ?? `${project.name}-${chat.title}`.replace(/[^\w.-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() + ".replay.html";
3118
+ fs.writeFileSync(out, html);
3119
+ console.log(pc.green(`✓ ${out}`) + pc.dim(` — opens in any browser, nothing to install`));
3120
+ if (opts.open) {
3121
+ const { spawn: sp } = await import("node:child_process");
3122
+ sp(process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open", [out], { detached: true, stdio: "ignore", shell: process.platform === "win32" }).on("error", () => { }).unref();
3123
+ }
3124
+ });
3125
+ program
3126
+ .command("scorecard")
3127
+ .description("each agent's record here: turns, how often it finished, how often its \"done\" held up when Proof checked, circles, shield trips, and cost per verified result")
3128
+ .option("--days <n>", "only the last n days (default: everything)")
3129
+ .action(async (opts) => {
3130
+ const client = await ensureDaemon();
3131
+ const project = await currentProject(client);
3132
+ const since = opts.days ? Date.now() - Math.max(1, Number(opts.days) || 1) * 86_400_000 : undefined;
3133
+ const t = await client.turnStats(project.id, { ...(since ? { since } : {}), days: 7 });
3134
+ if (!t.leaderboard.length)
3135
+ return void console.log(pc.dim("no finished turns yet — the scorecard fills in as agents work"));
3136
+ const pct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
3137
+ console.log(pc.bold(`${project.name}${opts.days ? ` · last ${opts.days} days` : ""}`));
3138
+ console.log(pc.dim(` ${"agent".padEnd(16)}${"turns".padStart(6)}${"finished".padStart(10)}${"held up".padStart(10)}${"circles".padStart(9)}${"shield".padStart(8)} per verified`));
3139
+ for (const a of t.leaderboard) {
3140
+ const tr = a.trust ?? {};
3141
+ const checked = (tr.verified ?? 0) + (tr.contradicted ?? 0);
3142
+ const held = checked ? `${tr.verified}/${checked}` : "—";
3143
+ const tone = tr.holdRate == null ? (x) => x : tr.holdRate >= 0.9 ? pc.green : tr.holdRate >= 0.7 ? pc.yellow : pc.red;
3144
+ console.log(` ${String(a.agentId).padEnd(16)}${String(a.turns).padStart(6)}${pct(a.successRate).padStart(10)}${tone(held.padStart(10))}${String(tr.loops ?? 0).padStart(9)}${String(tr.shield ?? 0).padStart(8)} ${tr.costPerVerifiedUsd != null ? `$${Number(tr.costPerVerifiedUsd).toFixed(2)}` : "—"}`);
3145
+ }
3146
+ });
3147
+ program
3148
+ .command("stats")
3149
+ .description("today across every project: turns, how many finished cleanly, spend, busiest agent")
3150
+ .option("--days <n>", "look back this many days instead of just today", "1")
3151
+ .action(async (opts) => {
3152
+ const client = await ensureDaemon();
3153
+ const days = Math.max(1, Math.min(90, Number(opts.days) || 1));
3154
+ const since = new Date();
3155
+ since.setHours(0, 0, 0, 0);
3156
+ since.setDate(since.getDate() - (days - 1));
3157
+ const { projects } = await client.listProjects();
3158
+ let turns = 0, errors = 0, cost = 0;
3159
+ const byAgent = new Map();
3160
+ const rows = [];
3161
+ for (const p of projects) {
3162
+ const t = await client.turnStats(p.id, { since: since.getTime(), days: 7 }).catch(() => null);
3163
+ if (!t)
3164
+ continue;
3165
+ const pt = t.leaderboard.reduce((a, x) => a + x.turns, 0);
3166
+ if (!pt)
3167
+ continue;
3168
+ const pe = t.leaderboard.reduce((a, x) => a + x.errors, 0);
3169
+ const pc$ = t.leaderboard.reduce((a, x) => a + x.totalCostUsd, 0);
3170
+ turns += pt;
3171
+ errors += pe;
3172
+ cost += pc$;
3173
+ for (const x of t.leaderboard)
3174
+ byAgent.set(x.agentId, (byAgent.get(x.agentId) ?? 0) + x.turns);
3175
+ rows.push(` ${pc.bold(p.name.padEnd(18))} ${String(pt).padStart(4)} turns ${pe ? pc.red(`${pe} failed`) : pc.green("all clean")} ${fmtUsd(pc$)}`);
3176
+ }
3177
+ const label = days === 1 ? "today" : `the last ${days} days`;
3178
+ if (!turns)
3179
+ return void console.log(pc.dim(`no turns ${label}`));
3180
+ const busiest = [...byAgent.entries()].sort((a, b) => b[1] - a[1])[0];
3181
+ const clean = Math.round(((turns - errors) / turns) * 100);
3182
+ console.log(pc.bold(`${label}: ${turns} turn${turns === 1 ? "" : "s"} · ${clean}% finished cleanly · ${fmtUsd(cost)} · busiest ${busiest[0]} (${busiest[1]})`));
3183
+ for (const r of rows)
3184
+ console.log(r);
3185
+ });
3186
+ program
3187
+ .command("completion")
3188
+ .description("print a shell completion script: loom completion zsh >> ~/.zshrc (or bash >> ~/.bashrc)")
3189
+ .argument("<shell>", "zsh or bash")
3190
+ .action((shell) => {
3191
+ const names = program.commands.map((c) => c.name()).filter((n) => n && n !== "completion").sort();
3192
+ if (shell === "bash") {
3193
+ console.log(`complete -W "${names.join(" ")} completion" loom`);
3194
+ }
3195
+ else if (shell === "zsh") {
3196
+ console.log(`_loom() { compadd -- ${names.join(" ")} completion; }\ncompdef _loom loom 2>/dev/null || { autoload -Uz compinit && compinit && compdef _loom loom; }`);
3197
+ }
3198
+ else
3199
+ fail(`unknown shell "${shell}" — zsh or bash`);
3200
+ });
637
3201
  program.parseAsync().catch((err) => {
638
3202
  console.error(pc.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
639
3203
  process.exit(1);