@loompad/cli 0.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (687) hide show
  1. package/ARCHITECTURE.md +96 -11
  2. package/CHANGELOG.md +1042 -0
  3. package/README.md +1473 -44
  4. package/THIRD_PARTY_NOTICES.md +275 -0
  5. package/dist/adapters/antigravity-cli.d.ts +62 -0
  6. package/dist/adapters/antigravity-cli.js +258 -0
  7. package/dist/adapters/antigravity-cli.js.map +1 -0
  8. package/dist/adapters/base.d.ts +61 -9
  9. package/dist/adapters/base.js +266 -15
  10. package/dist/adapters/base.js.map +1 -1
  11. package/dist/adapters/bridges/antigravity.d.ts +9 -12
  12. package/dist/adapters/bridges/antigravity.js +9 -12
  13. package/dist/adapters/bridges/antigravity.js.map +1 -1
  14. package/dist/adapters/echo.d.ts +1 -0
  15. package/dist/adapters/echo.js +59 -3
  16. package/dist/adapters/echo.js.map +1 -1
  17. package/dist/adapters/grok.d.ts +7 -0
  18. package/dist/adapters/grok.js +20 -10
  19. package/dist/adapters/grok.js.map +1 -1
  20. package/dist/adapters/index.d.ts +5 -1
  21. package/dist/adapters/index.js +53 -7
  22. package/dist/adapters/index.js.map +1 -1
  23. package/dist/adapters/model.d.ts +137 -0
  24. package/dist/adapters/model.js +527 -0
  25. package/dist/adapters/model.js.map +1 -0
  26. package/dist/adapters/opencode.d.ts +101 -1
  27. package/dist/adapters/opencode.js +874 -98
  28. package/dist/adapters/opencode.js.map +1 -1
  29. package/dist/cli/doctor.d.ts +32 -0
  30. package/dist/cli/doctor.js +89 -4
  31. package/dist/cli/doctor.js.map +1 -1
  32. package/dist/cli/index.js +2651 -87
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/tui-model.js +3 -1
  35. package/dist/cli/tui-model.js.map +1 -1
  36. package/dist/cli/tui.js +44 -1
  37. package/dist/cli/tui.js.map +1 -1
  38. package/dist/cli/ui.d.ts +2 -0
  39. package/dist/cli/ui.js +13 -0
  40. package/dist/cli/ui.js.map +1 -1
  41. package/dist/core/ades.d.ts +32 -16
  42. package/dist/core/ades.js +57 -20
  43. package/dist/core/ades.js.map +1 -1
  44. package/dist/core/agent-state.d.ts +14 -0
  45. package/dist/core/agent-state.js +32 -0
  46. package/dist/core/agent-state.js.map +1 -0
  47. package/dist/core/approvals.d.ts +33 -0
  48. package/dist/core/approvals.js +29 -0
  49. package/dist/core/approvals.js.map +1 -0
  50. package/dist/core/backup.d.ts +19 -0
  51. package/dist/core/backup.js +86 -0
  52. package/dist/core/backup.js.map +1 -0
  53. package/dist/core/baton.d.ts +2 -2
  54. package/dist/core/baton.js.map +1 -1
  55. package/dist/core/brain-extract.d.ts +17 -0
  56. package/dist/core/brain-extract.js +28 -2
  57. package/dist/core/brain-extract.js.map +1 -1
  58. package/dist/core/brain-index.d.ts +71 -0
  59. package/dist/core/brain-index.js +204 -5
  60. package/dist/core/brain-index.js.map +1 -1
  61. package/dist/core/brain.d.ts +58 -2
  62. package/dist/core/brain.js +79 -1
  63. package/dist/core/brain.js.map +1 -1
  64. package/dist/core/chat-import.d.ts +71 -0
  65. package/dist/core/chat-import.js +419 -0
  66. package/dist/core/chat-import.js.map +1 -0
  67. package/dist/core/checkpoint.d.ts +107 -0
  68. package/dist/core/checkpoint.js +259 -0
  69. package/dist/core/checkpoint.js.map +1 -0
  70. package/dist/core/claude-cli.js +1 -1
  71. package/dist/core/claude-cli.js.map +1 -1
  72. package/dist/core/continuity/artifacts.d.ts +14 -0
  73. package/dist/core/continuity/artifacts.js +118 -0
  74. package/dist/core/continuity/artifacts.js.map +1 -0
  75. package/dist/core/continuity/capabilities.d.ts +65 -0
  76. package/dist/core/continuity/capabilities.js +129 -0
  77. package/dist/core/continuity/capabilities.js.map +1 -0
  78. package/dist/core/continuity/contracts.d.ts +352 -0
  79. package/dist/core/continuity/contracts.js +117 -0
  80. package/dist/core/continuity/contracts.js.map +1 -0
  81. package/dist/core/continuity/engine.d.ts +55 -0
  82. package/dist/core/continuity/engine.js +504 -0
  83. package/dist/core/continuity/engine.js.map +1 -0
  84. package/dist/core/continuity/store.d.ts +73 -0
  85. package/dist/core/continuity/store.js +438 -0
  86. package/dist/core/continuity/store.js.map +1 -0
  87. package/dist/core/conversations.d.ts +19 -0
  88. package/dist/core/conversations.js +87 -0
  89. package/dist/core/conversations.js.map +1 -0
  90. package/dist/core/crew-coordination.d.ts +6 -0
  91. package/dist/core/crew-coordination.js +24 -0
  92. package/dist/core/crew-coordination.js.map +1 -0
  93. package/dist/core/crew-policy.d.ts +18 -0
  94. package/dist/core/crew-policy.js +40 -0
  95. package/dist/core/crew-policy.js.map +1 -0
  96. package/dist/core/crew-protocol.d.ts +64 -0
  97. package/dist/core/crew-protocol.js +146 -0
  98. package/dist/core/crew-protocol.js.map +1 -0
  99. package/dist/core/crew-requests.d.ts +14 -0
  100. package/dist/core/crew-requests.js +22 -0
  101. package/dist/core/crew-requests.js.map +1 -0
  102. package/dist/core/crew.d.ts +395 -0
  103. package/dist/core/crew.js +1857 -0
  104. package/dist/core/crew.js.map +1 -0
  105. package/dist/core/digest.d.ts +40 -0
  106. package/dist/core/digest.js +94 -0
  107. package/dist/core/digest.js.map +1 -0
  108. package/dist/core/distill.js +3 -5
  109. package/dist/core/distill.js.map +1 -1
  110. package/dist/core/eventlog.d.ts +27 -25
  111. package/dist/core/eventlog.js +97 -150
  112. package/dist/core/eventlog.js.map +1 -1
  113. package/dist/core/events/contracts.d.ts +51 -0
  114. package/dist/core/events/contracts.js +2 -0
  115. package/dist/core/events/contracts.js.map +1 -0
  116. package/dist/core/events/jsonl-store.d.ts +23 -0
  117. package/dist/core/events/jsonl-store.js +90 -0
  118. package/dist/core/events/jsonl-store.js.map +1 -0
  119. package/dist/core/events/sqlite-store.d.ts +37 -0
  120. package/dist/core/events/sqlite-store.js +215 -0
  121. package/dist/core/events/sqlite-store.js.map +1 -0
  122. package/dist/core/forecast.d.ts +44 -0
  123. package/dist/core/forecast.js +46 -0
  124. package/dist/core/forecast.js.map +1 -0
  125. package/dist/core/free-pool.d.ts +65 -0
  126. package/dist/core/free-pool.js +171 -0
  127. package/dist/core/free-pool.js.map +1 -0
  128. package/dist/core/git.d.ts +30 -0
  129. package/dist/core/git.js +50 -2
  130. package/dist/core/git.js.map +1 -1
  131. package/dist/core/github-events.d.ts +53 -0
  132. package/dist/core/github-events.js +227 -0
  133. package/dist/core/github-events.js.map +1 -0
  134. package/dist/core/goal-lanes.d.ts +53 -0
  135. package/dist/core/goal-lanes.js +99 -0
  136. package/dist/core/goal-lanes.js.map +1 -0
  137. package/dist/core/hosted.d.ts +27 -0
  138. package/dist/core/hosted.js +50 -0
  139. package/dist/core/hosted.js.map +1 -0
  140. package/dist/core/http-server.d.ts +5 -0
  141. package/dist/core/http-server.js +8 -0
  142. package/dist/core/http-server.js.map +1 -0
  143. package/dist/core/invite-link.d.ts +31 -0
  144. package/dist/core/invite-link.js +45 -0
  145. package/dist/core/invite-link.js.map +1 -0
  146. package/dist/core/loops.d.ts +37 -0
  147. package/dist/core/loops.js +81 -0
  148. package/dist/core/loops.js.map +1 -0
  149. package/dist/core/mcp-catalog.d.ts +182 -0
  150. package/dist/core/mcp-catalog.js +431 -0
  151. package/dist/core/mcp-catalog.js.map +1 -0
  152. package/dist/core/mcp.d.ts +87 -0
  153. package/dist/core/mcp.js +181 -0
  154. package/dist/core/mcp.js.map +1 -0
  155. package/dist/core/memory.d.ts +1 -0
  156. package/dist/core/memory.js +5 -2
  157. package/dist/core/memory.js.map +1 -1
  158. package/dist/core/model-tools.d.ts +97 -0
  159. package/dist/core/model-tools.js +388 -0
  160. package/dist/core/model-tools.js.map +1 -0
  161. package/dist/core/notify.js +19 -1
  162. package/dist/core/notify.js.map +1 -1
  163. package/dist/core/orchestra.d.ts +635 -0
  164. package/dist/core/orchestra.js +2120 -0
  165. package/dist/core/orchestra.js.map +1 -0
  166. package/dist/core/permissions.d.ts +56 -0
  167. package/dist/core/permissions.js +122 -0
  168. package/dist/core/permissions.js.map +1 -0
  169. package/dist/core/preview-proxy.d.ts +70 -0
  170. package/dist/core/preview-proxy.js +563 -0
  171. package/dist/core/preview-proxy.js.map +1 -0
  172. package/dist/core/preview-shot.d.ts +46 -0
  173. package/dist/core/preview-shot.js +109 -0
  174. package/dist/core/preview-shot.js.map +1 -0
  175. package/dist/core/process.d.ts +36 -0
  176. package/dist/core/process.js +168 -0
  177. package/dist/core/process.js.map +1 -0
  178. package/dist/core/prompt-queue.d.ts +136 -0
  179. package/dist/core/prompt-queue.js +269 -0
  180. package/dist/core/prompt-queue.js.map +1 -0
  181. package/dist/core/prompts.d.ts +53 -0
  182. package/dist/core/prompts.js +127 -0
  183. package/dist/core/prompts.js.map +1 -0
  184. package/dist/core/proof.d.ts +82 -0
  185. package/dist/core/proof.js +216 -0
  186. package/dist/core/proof.js.map +1 -0
  187. package/dist/core/provider-text.d.ts +23 -0
  188. package/dist/core/provider-text.js +66 -0
  189. package/dist/core/provider-text.js.map +1 -0
  190. package/dist/core/providers.d.ts +152 -0
  191. package/dist/core/providers.js +343 -0
  192. package/dist/core/providers.js.map +1 -0
  193. package/dist/core/quota.d.ts +33 -0
  194. package/dist/core/quota.js +74 -0
  195. package/dist/core/quota.js.map +1 -0
  196. package/dist/core/recipes.d.ts +50 -0
  197. package/dist/core/recipes.js +99 -0
  198. package/dist/core/recipes.js.map +1 -0
  199. package/dist/core/registry.d.ts +54 -0
  200. package/dist/core/registry.js +62 -5
  201. package/dist/core/registry.js.map +1 -1
  202. package/dist/core/relay-client.d.ts +67 -0
  203. package/dist/core/relay-client.js +184 -0
  204. package/dist/core/relay-client.js.map +1 -0
  205. package/dist/core/relay-protocol.d.ts +140 -0
  206. package/dist/core/relay-protocol.js +189 -0
  207. package/dist/core/relay-protocol.js.map +1 -0
  208. package/dist/core/replay.d.ts +18 -0
  209. package/dist/core/replay.js +191 -0
  210. package/dist/core/replay.js.map +1 -0
  211. package/dist/core/review-triage.d.ts +41 -0
  212. package/dist/core/review-triage.js +124 -0
  213. package/dist/core/review-triage.js.map +1 -0
  214. package/dist/core/router.js +2 -2
  215. package/dist/core/router.js.map +1 -1
  216. package/dist/core/routes.d.ts +39 -3
  217. package/dist/core/routes.js +137 -4
  218. package/dist/core/routes.js.map +1 -1
  219. package/dist/core/run-command.d.ts +14 -0
  220. package/dist/core/run-command.js +45 -0
  221. package/dist/core/run-command.js.map +1 -0
  222. package/dist/core/runner-setup.d.ts +36 -0
  223. package/dist/core/runner-setup.js +114 -0
  224. package/dist/core/runner-setup.js.map +1 -0
  225. package/dist/core/search.js +0 -0
  226. package/dist/core/search.js.map +1 -1
  227. package/dist/core/semantic.d.ts +92 -0
  228. package/dist/core/semantic.js +227 -0
  229. package/dist/core/semantic.js.map +1 -0
  230. package/dist/core/servers.d.ts +115 -0
  231. package/dist/core/servers.js +305 -0
  232. package/dist/core/servers.js.map +1 -0
  233. package/dist/core/setup.js +64 -6
  234. package/dist/core/setup.js.map +1 -1
  235. package/dist/core/shield.d.ts +17 -0
  236. package/dist/core/shield.js +81 -0
  237. package/dist/core/shield.js.map +1 -0
  238. package/dist/core/shift.d.ts +69 -0
  239. package/dist/core/shift.js +120 -0
  240. package/dist/core/shift.js.map +1 -0
  241. package/dist/core/skill-install.d.ts +125 -0
  242. package/dist/core/skill-install.js +328 -0
  243. package/dist/core/skill-install.js.map +1 -0
  244. package/dist/core/skills.d.ts +116 -0
  245. package/dist/core/skills.js +223 -0
  246. package/dist/core/skills.js.map +1 -0
  247. package/dist/core/step-conditions.d.ts +65 -0
  248. package/dist/core/step-conditions.js +102 -0
  249. package/dist/core/step-conditions.js.map +1 -0
  250. package/dist/core/subagents.d.ts +45 -0
  251. package/dist/core/subagents.js +90 -0
  252. package/dist/core/subagents.js.map +1 -0
  253. package/dist/core/team-canon.d.ts +42 -0
  254. package/dist/core/team-canon.js +121 -0
  255. package/dist/core/team-canon.js.map +1 -0
  256. package/dist/core/team-crypto.d.ts +97 -0
  257. package/dist/core/team-crypto.js +175 -0
  258. package/dist/core/team-crypto.js.map +1 -0
  259. package/dist/core/team-hub.d.ts +514 -0
  260. package/dist/core/team-hub.js +964 -0
  261. package/dist/core/team-hub.js.map +1 -0
  262. package/dist/core/team-landing.d.ts +239 -0
  263. package/dist/core/team-landing.js +504 -0
  264. package/dist/core/team-landing.js.map +1 -0
  265. package/dist/core/team-leases.d.ts +44 -0
  266. package/dist/core/team-leases.js +145 -0
  267. package/dist/core/team-leases.js.map +1 -0
  268. package/dist/core/team-memory.d.ts +42 -0
  269. package/dist/core/team-memory.js +84 -0
  270. package/dist/core/team-memory.js.map +1 -0
  271. package/dist/core/team-policy.d.ts +85 -0
  272. package/dist/core/team-policy.js +191 -0
  273. package/dist/core/team-policy.js.map +1 -0
  274. package/dist/core/thread-md.d.ts +6 -0
  275. package/dist/core/thread-md.js +42 -0
  276. package/dist/core/thread-md.js.map +1 -0
  277. package/dist/core/turn-stats.d.ts +61 -0
  278. package/dist/core/turn-stats.js +124 -0
  279. package/dist/core/turn-stats.js.map +1 -0
  280. package/dist/core/updater.d.ts +73 -0
  281. package/dist/core/updater.js +153 -0
  282. package/dist/core/updater.js.map +1 -0
  283. package/dist/core/usage.d.ts +75 -0
  284. package/dist/core/usage.js +163 -0
  285. package/dist/core/usage.js.map +1 -0
  286. package/dist/core/windows-jobs.d.ts +15 -0
  287. package/dist/core/windows-jobs.js +170 -0
  288. package/dist/core/windows-jobs.js.map +1 -0
  289. package/dist/core/worktree-merge.d.ts +57 -0
  290. package/dist/core/worktree-merge.js +123 -0
  291. package/dist/core/worktree-merge.js.map +1 -0
  292. package/dist/core/worktree.d.ts +5 -0
  293. package/dist/core/worktree.js +45 -5
  294. package/dist/core/worktree.js.map +1 -1
  295. package/dist/core/worktrees.d.ts +6 -0
  296. package/dist/core/worktrees.js +21 -0
  297. package/dist/core/worktrees.js.map +1 -0
  298. package/dist/daemon/app-page.d.ts +0 -17
  299. package/dist/daemon/app-page.js +10 -6346
  300. package/dist/daemon/app-page.js.map +1 -1
  301. package/dist/daemon/auth.d.ts +24 -3
  302. package/dist/daemon/auth.js +66 -7
  303. package/dist/daemon/auth.js.map +1 -1
  304. package/dist/daemon/board.d.ts +7 -0
  305. package/dist/daemon/board.js +12 -1
  306. package/dist/daemon/board.js.map +1 -1
  307. package/dist/daemon/brand-icons.d.ts +6 -1
  308. package/dist/daemon/brand-icons.js +11 -3
  309. package/dist/daemon/brand-icons.js.map +1 -1
  310. package/dist/daemon/client.d.ts +665 -1
  311. package/dist/daemon/client.js +381 -3
  312. package/dist/daemon/client.js.map +1 -1
  313. package/dist/daemon/delivery.d.ts +34 -0
  314. package/dist/daemon/delivery.js +63 -0
  315. package/dist/daemon/delivery.js.map +1 -0
  316. package/dist/daemon/deploys.d.ts +62 -0
  317. package/dist/daemon/deploys.js +159 -0
  318. package/dist/daemon/deploys.js.map +1 -0
  319. package/dist/daemon/landing.d.ts +192 -0
  320. package/dist/daemon/landing.js +992 -0
  321. package/dist/daemon/landing.js.map +1 -0
  322. package/dist/daemon/onboard.d.ts +150 -0
  323. package/dist/daemon/onboard.js +390 -0
  324. package/dist/daemon/onboard.js.map +1 -0
  325. package/dist/daemon/proof.d.ts +70 -0
  326. package/dist/daemon/proof.js +172 -0
  327. package/dist/daemon/proof.js.map +1 -0
  328. package/dist/daemon/push.d.ts +19 -1
  329. package/dist/daemon/push.js +90 -3
  330. package/dist/daemon/push.js.map +1 -1
  331. package/dist/daemon/relay.d.ts +82 -0
  332. package/dist/daemon/relay.js +351 -0
  333. package/dist/daemon/relay.js.map +1 -0
  334. package/dist/daemon/routes/agent-discovery.d.ts +4 -0
  335. package/dist/daemon/routes/agent-discovery.js +29 -0
  336. package/dist/daemon/routes/agent-discovery.js.map +1 -0
  337. package/dist/daemon/routes/agent-settings.d.ts +4 -0
  338. package/dist/daemon/routes/agent-settings.js +94 -0
  339. package/dist/daemon/routes/agent-settings.js.map +1 -0
  340. package/dist/daemon/routes/agents.d.ts +4 -0
  341. package/dist/daemon/routes/agents.js +105 -0
  342. package/dist/daemon/routes/agents.js.map +1 -0
  343. package/dist/daemon/routes/alerts.d.ts +4 -0
  344. package/dist/daemon/routes/alerts.js +137 -0
  345. package/dist/daemon/routes/alerts.js.map +1 -0
  346. package/dist/daemon/routes/approvals.d.ts +4 -0
  347. package/dist/daemon/routes/approvals.js +40 -0
  348. package/dist/daemon/routes/approvals.js.map +1 -0
  349. package/dist/daemon/routes/assets.d.ts +3 -0
  350. package/dist/daemon/routes/assets.js +64 -0
  351. package/dist/daemon/routes/assets.js.map +1 -0
  352. package/dist/daemon/routes/board.d.ts +4 -0
  353. package/dist/daemon/routes/board.js +147 -0
  354. package/dist/daemon/routes/board.js.map +1 -0
  355. package/dist/daemon/routes/brain.d.ts +4 -0
  356. package/dist/daemon/routes/brain.js +270 -0
  357. package/dist/daemon/routes/brain.js.map +1 -0
  358. package/dist/daemon/routes/chats.d.ts +4 -0
  359. package/dist/daemon/routes/chats.js +249 -0
  360. package/dist/daemon/routes/chats.js.map +1 -0
  361. package/dist/daemon/routes/clients.d.ts +4 -0
  362. package/dist/daemon/routes/clients.js +49 -0
  363. package/dist/daemon/routes/clients.js.map +1 -0
  364. package/dist/daemon/routes/cloud.d.ts +4 -0
  365. package/dist/daemon/routes/cloud.js +46 -0
  366. package/dist/daemon/routes/cloud.js.map +1 -0
  367. package/dist/daemon/routes/config.d.ts +4 -0
  368. package/dist/daemon/routes/config.js +35 -0
  369. package/dist/daemon/routes/config.js.map +1 -0
  370. package/dist/daemon/routes/context.d.ts +51 -0
  371. package/dist/daemon/routes/context.js +2 -0
  372. package/dist/daemon/routes/context.js.map +1 -0
  373. package/dist/daemon/routes/crews.d.ts +4 -0
  374. package/dist/daemon/routes/crews.js +149 -0
  375. package/dist/daemon/routes/crews.js.map +1 -0
  376. package/dist/daemon/routes/files.d.ts +3 -0
  377. package/dist/daemon/routes/files.js +253 -0
  378. package/dist/daemon/routes/files.js.map +1 -0
  379. package/dist/daemon/routes/git.d.ts +4 -0
  380. package/dist/daemon/routes/git.js +96 -0
  381. package/dist/daemon/routes/git.js.map +1 -0
  382. package/dist/daemon/routes/history.d.ts +4 -0
  383. package/dist/daemon/routes/history.js +101 -0
  384. package/dist/daemon/routes/history.js.map +1 -0
  385. package/dist/daemon/routes/integrations-project.d.ts +3 -0
  386. package/dist/daemon/routes/integrations-project.js +103 -0
  387. package/dist/daemon/routes/integrations-project.js.map +1 -0
  388. package/dist/daemon/routes/integrations.d.ts +3 -0
  389. package/dist/daemon/routes/integrations.js +111 -0
  390. package/dist/daemon/routes/integrations.js.map +1 -0
  391. package/dist/daemon/routes/logs.d.ts +3 -0
  392. package/dist/daemon/routes/logs.js +40 -0
  393. package/dist/daemon/routes/logs.js.map +1 -0
  394. package/dist/daemon/routes/messages.d.ts +4 -0
  395. package/dist/daemon/routes/messages.js +60 -0
  396. package/dist/daemon/routes/messages.js.map +1 -0
  397. package/dist/daemon/routes/observability.d.ts +4 -0
  398. package/dist/daemon/routes/observability.js +247 -0
  399. package/dist/daemon/routes/observability.js.map +1 -0
  400. package/dist/daemon/routes/onboard.d.ts +12 -0
  401. package/dist/daemon/routes/onboard.js +67 -0
  402. package/dist/daemon/routes/onboard.js.map +1 -0
  403. package/dist/daemon/routes/orchestra.d.ts +4 -0
  404. package/dist/daemon/routes/orchestra.js +110 -0
  405. package/dist/daemon/routes/orchestra.js.map +1 -0
  406. package/dist/daemon/routes/pairing.d.ts +4 -0
  407. package/dist/daemon/routes/pairing.js +155 -0
  408. package/dist/daemon/routes/pairing.js.map +1 -0
  409. package/dist/daemon/routes/preview.d.ts +24 -0
  410. package/dist/daemon/routes/preview.js +122 -0
  411. package/dist/daemon/routes/preview.js.map +1 -0
  412. package/dist/daemon/routes/project-actions.d.ts +4 -0
  413. package/dist/daemon/routes/project-actions.js +53 -0
  414. package/dist/daemon/routes/project-actions.js.map +1 -0
  415. package/dist/daemon/routes/project-team.d.ts +4 -0
  416. package/dist/daemon/routes/project-team.js +201 -0
  417. package/dist/daemon/routes/project-team.js.map +1 -0
  418. package/dist/daemon/routes/projects.d.ts +14 -0
  419. package/dist/daemon/routes/projects.js +138 -0
  420. package/dist/daemon/routes/projects.js.map +1 -0
  421. package/dist/daemon/routes/prompts.d.ts +4 -0
  422. package/dist/daemon/routes/prompts.js +44 -0
  423. package/dist/daemon/routes/prompts.js.map +1 -0
  424. package/dist/daemon/routes/proof.d.ts +4 -0
  425. package/dist/daemon/routes/proof.js +22 -0
  426. package/dist/daemon/routes/proof.js.map +1 -0
  427. package/dist/daemon/routes/providers.d.ts +3 -0
  428. package/dist/daemon/routes/providers.js +60 -0
  429. package/dist/daemon/routes/providers.js.map +1 -0
  430. package/dist/daemon/routes/public.d.ts +4 -0
  431. package/dist/daemon/routes/public.js +64 -0
  432. package/dist/daemon/routes/public.js.map +1 -0
  433. package/dist/daemon/routes/queue.d.ts +4 -0
  434. package/dist/daemon/routes/queue.js +140 -0
  435. package/dist/daemon/routes/queue.js.map +1 -0
  436. package/dist/daemon/routes/routing.d.ts +4 -0
  437. package/dist/daemon/routes/routing.js +61 -0
  438. package/dist/daemon/routes/routing.js.map +1 -0
  439. package/dist/daemon/routes/runtime-handler.d.ts +2 -0
  440. package/dist/daemon/routes/runtime-handler.js +63 -0
  441. package/dist/daemon/routes/runtime-handler.js.map +1 -0
  442. package/dist/daemon/routes/search.d.ts +4 -0
  443. package/dist/daemon/routes/search.js +17 -0
  444. package/dist/daemon/routes/search.js.map +1 -0
  445. package/dist/daemon/routes/servers.d.ts +4 -0
  446. package/dist/daemon/routes/servers.js +133 -0
  447. package/dist/daemon/routes/servers.js.map +1 -0
  448. package/dist/daemon/routes/setup.d.ts +3 -0
  449. package/dist/daemon/routes/setup.js +50 -0
  450. package/dist/daemon/routes/setup.js.map +1 -0
  451. package/dist/daemon/routes/shift.d.ts +4 -0
  452. package/dist/daemon/routes/shift.js +30 -0
  453. package/dist/daemon/routes/shift.js.map +1 -0
  454. package/dist/daemon/routes/specs.d.ts +4 -0
  455. package/dist/daemon/routes/specs.js +57 -0
  456. package/dist/daemon/routes/specs.js.map +1 -0
  457. package/dist/daemon/routes/task-delivery.d.ts +4 -0
  458. package/dist/daemon/routes/task-delivery.js +38 -0
  459. package/dist/daemon/routes/task-delivery.js.map +1 -0
  460. package/dist/daemon/routes/team.d.ts +4 -0
  461. package/dist/daemon/routes/team.js +193 -0
  462. package/dist/daemon/routes/team.js.map +1 -0
  463. package/dist/daemon/routes/terminals.d.ts +4 -0
  464. package/dist/daemon/routes/terminals.js +87 -0
  465. package/dist/daemon/routes/terminals.js.map +1 -0
  466. package/dist/daemon/routes/tools.d.ts +4 -0
  467. package/dist/daemon/routes/tools.js +180 -0
  468. package/dist/daemon/routes/tools.js.map +1 -0
  469. package/dist/daemon/routes/updates.d.ts +4 -0
  470. package/dist/daemon/routes/updates.js +89 -0
  471. package/dist/daemon/routes/updates.js.map +1 -0
  472. package/dist/daemon/routes/voice.d.ts +4 -0
  473. package/dist/daemon/routes/voice.js +53 -0
  474. package/dist/daemon/routes/voice.js.map +1 -0
  475. package/dist/daemon/runner.d.ts +186 -0
  476. package/dist/daemon/runner.js +612 -0
  477. package/dist/daemon/runner.js.map +1 -0
  478. package/dist/daemon/runtime/accounting.d.ts +130 -0
  479. package/dist/daemon/runtime/accounting.js +303 -0
  480. package/dist/daemon/runtime/accounting.js.map +1 -0
  481. package/dist/daemon/runtime/agents.d.ts +16 -0
  482. package/dist/daemon/runtime/agents.js +71 -0
  483. package/dist/daemon/runtime/agents.js.map +1 -0
  484. package/dist/daemon/runtime/briefings.d.ts +134 -0
  485. package/dist/daemon/runtime/briefings.js +329 -0
  486. package/dist/daemon/runtime/briefings.js.map +1 -0
  487. package/dist/daemon/runtime/native-usage.d.ts +21 -0
  488. package/dist/daemon/runtime/native-usage.js +71 -0
  489. package/dist/daemon/runtime/native-usage.js.map +1 -0
  490. package/dist/daemon/runtime/queue.d.ts +94 -0
  491. package/dist/daemon/runtime/queue.js +222 -0
  492. package/dist/daemon/runtime/queue.js.map +1 -0
  493. package/dist/daemon/runtime/relay.d.ts +93 -0
  494. package/dist/daemon/runtime/relay.js +240 -0
  495. package/dist/daemon/runtime/relay.js.map +1 -0
  496. package/dist/daemon/runtime/turns.d.ts +202 -0
  497. package/dist/daemon/runtime/turns.js +663 -0
  498. package/dist/daemon/runtime/turns.js.map +1 -0
  499. package/dist/daemon/runtime-support.d.ts +76 -0
  500. package/dist/daemon/runtime-support.js +134 -0
  501. package/dist/daemon/runtime-support.js.map +1 -0
  502. package/dist/daemon/runtime.d.ts +739 -70
  503. package/dist/daemon/runtime.js +2751 -470
  504. package/dist/daemon/runtime.js.map +1 -1
  505. package/dist/daemon/server.d.ts +82 -55
  506. package/dist/daemon/server.js +674 -1435
  507. package/dist/daemon/server.js.map +1 -1
  508. package/dist/daemon/shift.d.ts +70 -0
  509. package/dist/daemon/shift.js +170 -0
  510. package/dist/daemon/shift.js.map +1 -0
  511. package/dist/daemon/specs.d.ts +92 -0
  512. package/dist/daemon/specs.js +285 -0
  513. package/dist/daemon/specs.js.map +1 -0
  514. package/dist/daemon/system.d.ts +208 -0
  515. package/dist/daemon/system.js +557 -0
  516. package/dist/daemon/system.js.map +1 -0
  517. package/dist/daemon/tasks.js +1 -1
  518. package/dist/daemon/team-brain.d.ts +136 -0
  519. package/dist/daemon/team-brain.js +659 -0
  520. package/dist/daemon/team-brain.js.map +1 -0
  521. package/dist/daemon/team-coordinator.d.ts +78 -0
  522. package/dist/daemon/team-coordinator.js +408 -0
  523. package/dist/daemon/team-coordinator.js.map +1 -0
  524. package/dist/daemon/team.d.ts +339 -0
  525. package/dist/daemon/team.js +1433 -0
  526. package/dist/daemon/team.js.map +1 -0
  527. package/dist/daemon/terminals.d.ts +22 -3
  528. package/dist/daemon/terminals.js +236 -16
  529. package/dist/daemon/terminals.js.map +1 -1
  530. package/dist/hub/client.d.ts +120 -0
  531. package/dist/hub/client.js +228 -0
  532. package/dist/hub/client.js.map +1 -0
  533. package/dist/hub/server.d.ts +32 -0
  534. package/dist/hub/server.js +180 -0
  535. package/dist/hub/server.js.map +1 -0
  536. package/dist/hub/supabase-client.d.ts +220 -0
  537. package/dist/hub/supabase-client.js +843 -0
  538. package/dist/hub/supabase-client.js.map +1 -0
  539. package/dist/observability/ask.d.ts +91 -0
  540. package/dist/observability/ask.js +179 -0
  541. package/dist/observability/ask.js.map +1 -0
  542. package/dist/observability/clickhouse.d.ts +33 -0
  543. package/dist/observability/clickhouse.js +48 -0
  544. package/dist/observability/clickhouse.js.map +1 -0
  545. package/dist/observability/decisions.d.ts +176 -0
  546. package/dist/observability/decisions.js +377 -0
  547. package/dist/observability/decisions.js.map +1 -0
  548. package/dist/observability/index.d.ts +89 -0
  549. package/dist/observability/index.js +348 -0
  550. package/dist/observability/index.js.map +1 -0
  551. package/dist/observability/insights.d.ts +151 -0
  552. package/dist/observability/insights.js +319 -0
  553. package/dist/observability/insights.js.map +1 -0
  554. package/dist/observability/logs-query.d.ts +82 -0
  555. package/dist/observability/logs-query.js +102 -0
  556. package/dist/observability/logs-query.js.map +1 -0
  557. package/dist/observability/logs.d.ts +72 -0
  558. package/dist/observability/logs.js +295 -0
  559. package/dist/observability/logs.js.map +1 -0
  560. package/dist/observability/metrics.d.ts +86 -0
  561. package/dist/observability/metrics.js +303 -0
  562. package/dist/observability/metrics.js.map +1 -0
  563. package/dist/observability/otlp.d.ts +127 -0
  564. package/dist/observability/otlp.js +242 -0
  565. package/dist/observability/otlp.js.map +1 -0
  566. package/dist/observability/snapshots.d.ts +43 -0
  567. package/dist/observability/snapshots.js +129 -0
  568. package/dist/observability/snapshots.js.map +1 -0
  569. package/dist/observability/triage.d.ts +49 -0
  570. package/dist/observability/triage.js +284 -0
  571. package/dist/observability/triage.js.map +1 -0
  572. package/dist/providers/adapter.d.ts +50 -0
  573. package/dist/providers/adapter.js +38 -0
  574. package/dist/providers/adapter.js.map +1 -0
  575. package/dist/providers/agent.d.ts +110 -0
  576. package/dist/providers/agent.js +487 -0
  577. package/dist/providers/agent.js.map +1 -0
  578. package/dist/providers/approvals.d.ts +24 -0
  579. package/dist/providers/approvals.js +78 -0
  580. package/dist/providers/approvals.js.map +1 -0
  581. package/dist/providers/attachments.d.ts +20 -0
  582. package/dist/providers/attachments.js +51 -0
  583. package/dist/providers/attachments.js.map +1 -0
  584. package/dist/providers/claude/adapter.d.ts +99 -0
  585. package/dist/providers/claude/adapter.js +790 -0
  586. package/dist/providers/claude/adapter.js.map +1 -0
  587. package/dist/providers/codex/adapter.d.ts +113 -0
  588. package/dist/providers/codex/adapter.js +881 -0
  589. package/dist/providers/codex/adapter.js.map +1 -0
  590. package/dist/providers/codex/instructions.d.ts +11 -0
  591. package/dist/providers/codex/instructions.js +154 -0
  592. package/dist/providers/codex/instructions.js.map +1 -0
  593. package/dist/providers/codex/rpc.d.ts +36 -0
  594. package/dist/providers/codex/rpc.js +104 -0
  595. package/dist/providers/codex/rpc.js.map +1 -0
  596. package/dist/providers/contracts.d.ts +339 -0
  597. package/dist/providers/contracts.js +19 -0
  598. package/dist/providers/contracts.js.map +1 -0
  599. package/dist/providers/directory.d.ts +70 -0
  600. package/dist/providers/directory.js +95 -0
  601. package/dist/providers/directory.js.map +1 -0
  602. package/dist/providers/errors.d.ts +47 -0
  603. package/dist/providers/errors.js +24 -0
  604. package/dist/providers/errors.js.map +1 -0
  605. package/dist/providers/index.d.ts +18 -0
  606. package/dist/providers/index.js +19 -0
  607. package/dist/providers/index.js.map +1 -0
  608. package/dist/providers/ingestion.d.ts +80 -0
  609. package/dist/providers/ingestion.js +330 -0
  610. package/dist/providers/ingestion.js.map +1 -0
  611. package/dist/providers/live.d.ts +22 -0
  612. package/dist/providers/live.js +56 -0
  613. package/dist/providers/live.js.map +1 -0
  614. package/dist/providers/process.d.ts +29 -0
  615. package/dist/providers/process.js +71 -0
  616. package/dist/providers/process.js.map +1 -0
  617. package/dist/providers/reaper.d.ts +33 -0
  618. package/dist/providers/reaper.js +65 -0
  619. package/dist/providers/reaper.js.map +1 -0
  620. package/dist/providers/service.d.ts +87 -0
  621. package/dist/providers/service.js +256 -0
  622. package/dist/providers/service.js.map +1 -0
  623. package/dist/sdk.d.ts +1 -1
  624. package/dist/sdk.js +1 -1
  625. package/dist/sdk.js.map +1 -1
  626. package/dist/types.d.ts +385 -6
  627. package/dist/types.js +1 -0
  628. package/dist/types.js.map +1 -1
  629. package/dist/version.d.ts +7 -0
  630. package/dist/version.js +16 -0
  631. package/dist/version.js.map +1 -0
  632. package/dist/web/app.css +4643 -0
  633. package/dist/web/app.js +22640 -0
  634. package/dist/web/app.js.map +7 -0
  635. package/dist/web/shell.html +39 -0
  636. package/docs/README.md +21 -0
  637. package/docs/SETUP.md +10 -0
  638. package/docs/adapters.md +21 -8
  639. package/docs/brain-continuity.md +186 -0
  640. package/docs/cloud.md +108 -0
  641. package/docs/crews.md +62 -0
  642. package/docs/design-system.md +31 -2
  643. package/docs/integration-notes.md +128 -20
  644. package/docs/loompad.md +105 -0
  645. package/docs/observability.md +220 -0
  646. package/docs/play-store.md +123 -0
  647. package/docs/proposals/agent-teams.md +249 -0
  648. package/docs/proposals/brain-continuity-implementation.md +468 -0
  649. package/docs/proposals/brain-session-continuity.md +623 -0
  650. package/docs/proposals/t3code-port.md +81 -0
  651. package/docs/proposals/t3code-reference.md +95 -0
  652. package/docs/refactoring/AGENT-TEAMS-PROGRESS.md +88 -0
  653. package/docs/refactoring/BOUNDARIES.md +120 -0
  654. package/docs/refactoring/BRAIN-AUDIT.md +176 -0
  655. package/docs/refactoring/BRAIN-NOTES.md +390 -0
  656. package/docs/refactoring/BRAIN-TODO.md +214 -0
  657. package/docs/refactoring/NOTES.md +129 -0
  658. package/docs/refactoring/T3-PORT-NOTES.md +165 -0
  659. package/docs/refactoring/T3-PORT-TODO.md +123 -0
  660. package/docs/refactoring/TODO.md +46 -0
  661. package/docs/refactoring/brain-benchmark-2026-09-28.json +48 -0
  662. package/docs/refactoring/brain-benchmark-audit-2026-09-28.json +48 -0
  663. package/docs/release-notes/v0.2.0.md +35 -0
  664. package/docs/release-notes/v0.2.1.md +59 -0
  665. package/docs/release-notes/v0.2.3.md +77 -0
  666. package/docs/release-notes/v0.2.4.md +128 -0
  667. package/docs/release-notes/v0.2.5.md +153 -0
  668. package/docs/release-notes/v0.2.6.md +62 -0
  669. package/docs/release-notes/v1.0.0.md +120 -0
  670. package/docs/release-notes/v1.0.1.md +77 -0
  671. package/docs/release-notes/v1.1.0.md +71 -0
  672. package/docs/release-notes/v1.1.1.md +38 -0
  673. package/docs/teams-architecture.md +697 -0
  674. package/docs/teams.md +171 -0
  675. package/docs/windows.md +85 -0
  676. package/package.json +22 -9
  677. package/dist/adapters/claude-code.d.ts +0 -26
  678. package/dist/adapters/claude-code.js +0 -209
  679. package/dist/adapters/claude-code.js.map +0 -1
  680. package/dist/adapters/codex.d.ts +0 -51
  681. package/dist/adapters/codex.js +0 -291
  682. package/dist/adapters/codex.js.map +0 -1
  683. package/docs/img/board-linear.png +0 -0
  684. package/docs/img/board-projects.png +0 -0
  685. package/docs/img/brain.png +0 -0
  686. package/docs/img/settings.png +0 -0
  687. package/docs/img/workspace.png +0 -0
@@ -1,57 +1,278 @@
1
- /**
2
- * ProjectRuntime — one live project inside the daemon: its event log, its
3
- * agents, and its baton. All mutations flow through here so the log stays
4
- * the single source of truth.
5
- */
1
+ import { execSync } from "node:child_process";
6
2
  import fs from "node:fs";
7
3
  import path from "node:path";
8
- import { isAdapter, MAIN_CHAT } from "../types.js";
9
- import { createAgent, knownAgentKinds } from "../adapters/index.js";
10
- import { BatonManager, NotHolderError } from "../core/baton.js";
11
- import { Brain, CONFIDENCE_FLOOR } from "../core/brain.js";
12
- import { compileBrief, retrieve } from "../core/brain-index.js";
13
- import { extractFromTurn } from "../core/brain-extract.js";
14
- import { claudeText } from "../core/claude-cli.js";
15
- import { EventLog } from "../core/eventlog.js";
4
+ import { createAgent, isWithdrawnKind, knownAgentKinds, tierForKind } from "../adapters/index.js";
5
+ import { ModelAdapter } from "../adapters/model.js";
6
+ import { ADES, detectAdes } from "../core/ades.js";
7
+ import { BatonManager } from "../core/baton.js";
8
+ import { Brain } from "../core/brain.js";
9
+ import { helperText } from "../core/provider-text.js";
10
+ import { ConversationStore } from "../core/conversations.js";
11
+ import * as checkpoints from "../core/checkpoint.js";
16
12
  import { renderProjection } from "../core/distill.js";
17
- import { buildUnifiedMemory, hashContent, readNativeMemory, } from "../core/memory.js";
13
+ import { EventLog } from "../core/eventlog.js";
14
+ import { ensureBranch, addWorktree as gitAddWorktree, readOut, worktreePath } from "../core/git.js";
15
+ import { logbook } from "../core/logbook.js";
16
+ import { probeMcpServer, probeMcpServers, writeMcpSession } from "../core/mcp.js";
17
+ import { NativeUsage } from "./runtime/native-usage.js";
18
18
  import { notify } from "../core/notify.js";
19
- import { RouteEngine } from "../core/routes.js";
20
- import { buildBriefing, buildProjection } from "../core/projection.js";
21
- import { newId, projectLoomDir, readProjectConfig, readProjectState, writeProjectConfig, writeProjectState, writeMemoryFile, } from "../core/registry.js";
19
+ import { OrchestraEngine } from "../core/orchestra.js";
20
+ import { CrewEngine } from "../core/crew.js";
21
+ import { LoopWatch, nudgeText } from "../core/loops.js";
22
+ import { commandOf, dangerOf } from "../core/shield.js";
23
+ import { QuotaRelay } from "./runtime/relay.js";
24
+ import { ProofRunner } from "./proof.js";
25
+ import { NightShift } from "./shift.js";
26
+ import { isPermissionMode, permissionFor, unsupportedReason } from "../core/permissions.js";
27
+ import { startPreviewProxy } from "../core/preview-proxy.js";
28
+ import { PromptQueue } from "../core/prompt-queue.js";
29
+ import { newId, projectLoomDir, readProjectConfig, readProjectState, writeMemoryFile, writeProjectConfig, writeProjectState, } from "../core/registry.js";
30
+ import { RouteEngine, resolveSteps } from "../core/routes.js";
31
+ import { SemanticIndex } from "../core/semantic.js";
32
+ import { Servers } from "../core/servers.js";
33
+ import { SkillInstallError, installSkillFromDir, installSkillFromGit, } from "../core/skill-install.js";
34
+ import { buildSkillsBlock, discoverSkillRoots, loadSkills, } from "../core/skills.js";
22
35
  import { suggestHandoff } from "../core/suggestions.js";
23
- import { diffSinceSnapshot, porcelainStatus, workingTree, } from "../core/worktree.js";
24
- const PROJECTION_WINDOW = 400; // recent events distilled on handoff
36
+ import { cappedPermission } from "../core/team-policy.js";
37
+ import { capCrewPermission, DEFAULT_CREW_POLICY } from "../core/crew-policy.js";
38
+ import { describeMerge, mergeAgentWork } from "../core/worktree-merge.js";
39
+ import { workingTree } from "../core/worktree.js";
40
+ import { decisionStats, extractDecisions, normalizeStoredDecision, } from "../observability/decisions.js";
41
+ import { turnTraceId } from "../observability/index.js";
42
+ import { GIT_DELIVERIES, MAIN_CHAT, isAdapter } from "../types.js";
43
+ import { MAX_FANOUT, activityLine, configMtimeOf, withLoomAskTimeout } from './runtime-support.js';
44
+ import { RuntimeAccounting } from './runtime/accounting.js';
45
+ import { RuntimeAgents } from './runtime/agents.js';
46
+ import { RuntimeBriefings } from './runtime/briefings.js';
47
+ import { RuntimeQueue } from './runtime/queue.js';
48
+ import { RuntimeTurns } from './runtime/turns.js';
49
+ import { ContinuityEngine } from "../core/continuity/engine.js";
50
+ import { ContinuityError } from "../core/continuity/contracts.js";
51
+ import { HarnessMonitor, isNativeKind } from "../core/continuity/capabilities.js";
52
+ import { ProviderAgent } from "../providers/agent.js";
53
+ import { LiveDeltaThrottle } from "../providers/live.js";
54
+ import { AdapterBase } from "../adapters/base.js";
55
+ export { BudgetExceededError, CLOCK_TICK_MS, LOOM_ASK_TIMEOUT_MESSAGE, LOOM_ASK_TIMEOUT_MS, LoomAskTimeoutError, QuarantinedError, activityLine, planModeBriefing, relativeToProject, withLoomAskTimeout } from './runtime-support.js';
56
+ /**
57
+ * Priority and due date on a card: set when given, cleared by null, refused
58
+ * in words when they don't make sense.
59
+ */
60
+ function applyCardMeta(task, p) {
61
+ if (p.priority !== undefined) {
62
+ if (p.priority === null || p.priority === "")
63
+ delete task.priority;
64
+ else if (p.priority === "high" || p.priority === "medium" || p.priority === "low")
65
+ task.priority = p.priority;
66
+ else
67
+ throw new Error("priority is high, medium or low");
68
+ }
69
+ if (p.due !== undefined) {
70
+ if (p.due === null || p.due === "")
71
+ delete task.due;
72
+ else if (/^\d{4}-\d{2}-\d{2}$/.test(p.due) && !Number.isNaN(new Date(`${p.due}T00:00:00`).getTime()))
73
+ task.due = p.due;
74
+ else
75
+ throw new Error("due is a date: YYYY-MM-DD");
76
+ }
77
+ }
78
+ /** A model agent's sampling settings, for its status (empty for everything else). */
79
+ function sampling(cfg) {
80
+ if (cfg.kind !== "model")
81
+ return {};
82
+ const o = (cfg.options ?? {});
83
+ const out = {};
84
+ if (typeof o.temperature === "number")
85
+ out.temperature = o.temperature;
86
+ if (typeof o.maxTokens === "number")
87
+ out.maxTokens = o.maxTokens;
88
+ return { sampling: out };
89
+ }
25
90
  export class ProjectRuntime {
91
+ accounting;
92
+ briefings;
93
+ queueCoordinator;
94
+ turns;
26
95
  info;
27
96
  config;
28
97
  log;
98
+ conversations;
29
99
  baton;
30
100
  routes;
101
+ /** One orchestrator, many parallel workers — see core/orchestra.ts. */
102
+ orchestra;
103
+ /** Agent Teams: crews of agents with roles, one goal at a time (core/crew.ts). */
104
+ crews;
105
+ /** Loop watch: an agent stuck re-running the same failing command (core/loops.ts). */
106
+ loops = new LoopWatch();
107
+ /** agent × chat already nudged since a person last spoke there: a second loop is only reported. */
108
+ nudged = new Set();
109
+ /** agent × chat × command already flagged this turn by the shield. */
110
+ shielded = new Set();
111
+ /** Quota Relay: an agent out of quota mid-task hands its work to the next. */
112
+ relay;
113
+ /** Proof: checks an agent's "done" against what ran and a clean re-run of the tests. */
114
+ proofs;
115
+ /** Night Shift: a line of tasks run while you're away, then one report (daemon/shift.ts). */
116
+ shifts;
117
+ /** Adapter kinds installed on this machine, probed once at open. */
118
+ installedKinds = [];
31
119
  /** Memory as units — see core/brain.ts. Reads and writes through `log`. */
32
120
  brain;
33
- agents = new Map();
34
- startedAgents = new Set();
121
+ continuity = null;
122
+ /** Native harness reachability, polled while native continuity is on. */
123
+ harnesses;
124
+ /** Latest context and usage-limit readings from native harnesses. */
125
+ nativeUsage = new NativeUsage();
126
+ agentLifecycle = new RuntimeAgents();
127
+ get agents() { return this.agentLifecycle.agents; }
35
128
  configMtime = 0;
36
- /**
37
- * Which conversation each agent's current turn belongs to. Set when a turn
38
- * starts and left in place afterwards — an agent's trailing events (a late
39
- * run_complete, a diff) still belong to the chat that prompted them.
40
- */
41
- turnChat = new Map();
129
+ journalFailure = null;
130
+ /** What you've lined up, run one at a time — see core/prompt-queue.ts. */
131
+ queue;
132
+ /** This project's dev servers — see core/servers.ts. */
133
+ servers;
134
+ /** One preview proxy per server — see core/preview-proxy.ts. */
135
+ proxies = new Map();
136
+ serverListeners = new Set();
137
+ streamListeners = new Set();
138
+ liveListeners = new Set();
139
+ /** Streamed text and tool progress from provider agents, coalesced for clients. Not persisted. */
140
+ live = new LiveDeltaThrottle((d) => { for (const cb of this.liveListeners)
141
+ cb(d); });
42
142
  constructor(info, config, log) {
43
143
  this.info = info;
44
144
  this.config = config;
45
145
  this.log = log;
146
+ if (config.brain?.continuity === true)
147
+ this.continuity = new ContinuityEngine(log, info.id);
148
+ this.harnesses = new HarnessMonitor(() => this.config.agents.filter(a => a.enabled !== false && isNativeKind(a.kind)).map(a => ({ id: a.id, kind: a.kind, options: this.policyOptions(a) })), (id, next, previous) => {
149
+ if (!next.available)
150
+ logbook.warn("harness", `${id} CLI is not reachable — native turns are refused until it answers`, next.error, info.id);
151
+ else if (previous && !previous.available)
152
+ logbook.info("harness", `${id} CLI is reachable again (${next.version ?? "unknown version"})`, undefined, info.id);
153
+ });
154
+ if (this.continuity)
155
+ this.harnesses.start();
156
+ // The last readings survive a restart: replay the recent reports.
157
+ for (const e of log.list({ kinds: ["status", "run_complete", "error"], limit: 500 }))
158
+ this.nativeUsage.observe(e);
159
+ this.conversations = new ConversationStore(info.dir);
160
+ const runtime = this;
161
+ this.accounting = new RuntimeAccounting({
162
+ get log() { return runtime.log; },
163
+ appendIfOpen: (...args) => this.appendIfOpen(...args),
164
+ get info() { return runtime.info; },
165
+ });
166
+ this.briefings = new RuntimeBriefings({
167
+ get info() { return runtime.info; },
168
+ get config() { return runtime.config; },
169
+ get log() { return runtime.log; },
170
+ get teamBrain() { return runtime.teamBrain; },
171
+ get brain() { return runtime.brain; },
172
+ activeSkillsBlock: (...args) => this.activeSkillsBlock(...args),
173
+ get turnChat() { return runtime.turns.turnChat; },
174
+ // a provider/model (ollama/qwen3:4b, openrouter/pool:free) runs there; anything else is a Claude alias
175
+ extractionEngine: (model) => (prompt) => helperText(model, `${prompt.system}\n\n${prompt.user}`, { timeoutMs: 60_000 }),
176
+ renderProjection: (input) => renderProjection(input, this.config.projection),
177
+ createSemanticIndex: () => new SemanticIndex(path.join(this.info.dir, ".loom")),
178
+ noteMemoriesUsed: (ids) => this.noteMemoriesUsed(ids),
179
+ });
180
+ this.queueCoordinator = new RuntimeQueue({
181
+ get queue() { return runtime.queue; },
182
+ get agents() { return runtime.agents; },
183
+ routeState: (...args) => this.routeState(...args),
184
+ validHolder: (...args) => this.validHolder(...args),
185
+ get orchestra() { return runtime.orchestra; },
186
+ get config() { return runtime.config; },
187
+ get busySince() { return runtime.turns.busySince; },
188
+ get closed() { return runtime.closed; },
189
+ appendIfOpen: (...args) => this.appendIfOpen(...args),
190
+ startRoute: (...args) => this.startRoute(...args),
191
+ handoff: (...args) => this.handoff(...args),
192
+ sendMessage: (...args) => this.sendMessage(...args),
193
+ });
194
+ this.turns = new RuntimeTurns({
195
+ get continuity() { return runtime.continuity; },
196
+ harness: (id) => this.harnesses.ensure(id),
197
+ kickQueue: () => this.kickQueue(),
198
+ nativeOptions: (id) => this.policyOptions(this.config.agents.find(a => a.id === id)),
199
+ chatExists: (chat) => this.conversations.chats().some(c => c.id === chat),
200
+ staleTurnMs: ProjectRuntime.STALE_TURN_MS,
201
+ get closed() { return runtime.closed; },
202
+ agentDir: (...args) => this.agentDir(...args),
203
+ get log() { return runtime.log; },
204
+ get info() { return runtime.info; },
205
+ extractMemory: (...args) => this.extractMemory(...args),
206
+ get config() { return runtime.config; },
207
+ chatBinding: (...args) => this.chatBinding(...args),
208
+ validHolder: (...args) => this.validHolder(...args),
209
+ defaultAdapterId: (...args) => this.defaultAdapterId(...args),
210
+ agent: (...args) => this.agent(...args),
211
+ enforceQuarantine: (...args) => {
212
+ if (this.journalFailure)
213
+ throw this.journalFailure;
214
+ this.enforceQuarantine(...args);
215
+ },
216
+ enforceBudget: (...args) => this.enforceBudget(...args),
217
+ get teamPolicy() { return runtime.teamPolicy; },
218
+ get baton() { return runtime.baton; },
219
+ releaseQuestionHold: (...args) => this.releaseQuestionHold(...args),
220
+ openQuestion: (agentId) => this.openQuestions.get(agentId),
221
+ answerQuestion: (...args) => this.answerQuestion(...args),
222
+ get queue() { return runtime.queue; },
223
+ get routes() { return runtime.routes; },
224
+ ensureStarted: (...args) => this.ensureStarted(...args),
225
+ isCurrentAgent: (agent) => !this.closed && this.agents.get(agent.id) === agent,
226
+ dispatchFailed: (agent, chat, error) => {
227
+ if (this.closed || this.agents.get(agent.id) !== agent)
228
+ return;
229
+ if (!this.continuity || !isAdapter(agent) || !agent.busy())
230
+ this.turns.busySince.delete(agent.id);
231
+ const event = this.log.append({ kind: "error", agentId: agent.id, chat,
232
+ payload: { message: error instanceof Error ? error.message : String(error) } });
233
+ this.afterAgentEvent(event);
234
+ this.kickQueue();
235
+ },
236
+ consumePendingBriefing: (...args) => this.consumePendingBriefing(...args),
237
+ activeSkillsBlock: (...args) => this.activeSkillsBlock(...args),
238
+ agentInstructions: (id) => this.agentInstructions(id),
239
+ healthyMcps: (...args) => this.healthyMcps(...args),
240
+ appendIfOpen: (...args) => this.appendIfOpen(...args),
241
+ get agents() { return runtime.agents; },
242
+ handoff: (...args) => this.handoff(...args),
243
+ get pendingBriefings() { return runtime.briefings.pendingBriefings; },
244
+ });
245
+ // What each agent has typed since its last finished message, so a window
246
+ // opened (or reloaded) mid-reply can show the reply so far, not just the
247
+ // words that arrive after it. A finished message supersedes its typing;
248
+ // a turn's end drops it.
249
+ log.onEvent((e) => {
250
+ const s = e.agentId ? this.liveSoFar.get(e.agentId) : undefined;
251
+ if (!s || !e.agentId)
252
+ return;
253
+ const p = e.payload;
254
+ if (e.kind === "message") {
255
+ if (p.reasoning)
256
+ s.think = "";
257
+ else
258
+ s.text = "";
259
+ }
260
+ else if (e.kind === "run_complete" || e.kind === "error" || e.kind === "needs_input" ||
261
+ (e.kind === "status" && (p.state === "interrupted" || p.state === "stopped"))) {
262
+ this.liveSoFar.delete(e.agentId);
263
+ }
264
+ });
46
265
  this.baton = new BatonManager(info.dir, log);
47
266
  this.brain = new Brain(log);
267
+ void this.briefings.configureSemantic(!this.continuity && config.brain?.semantic === true);
48
268
  // Same path as addAgent: an agent added at runtime must behave exactly like
49
269
  // one that was here at open, and two copies of this loop would drift.
50
270
  // (An agent streams events long after send() returns and has no idea which
51
271
  // conversation prompted it — spawnAgent tags them with the chat that started
52
272
  // the turn, so a reply lands where the question was asked.)
53
273
  for (const agentCfg of config.agents)
54
- this.spawnAgent(agentCfg);
274
+ if (agentCfg.enabled !== false)
275
+ this.spawnAgent(agentCfg);
55
276
  this.routes = new RouteEngine({
56
277
  projectName: info.name,
57
278
  projectDir: info.dir,
@@ -60,19 +281,207 @@ export class ProjectRuntime {
60
281
  handoff: (to) => this.handoff(to, { source: "route" }),
61
282
  send: (text, agentId) => this.sendMessage(text, agentId, { source: "route" }),
62
283
  interrupt: () => this.interrupt({ source: "route" }),
63
- costTotal: () => this.costs.totalUsd,
284
+ costTotal: () => this.accounting.costs.totalUsd,
285
+ turnFacts: (agentId) => this.turnFacts(agentId),
64
286
  isAdapterId: (id) => {
65
287
  const agent = this.agents.get(id);
66
288
  return Boolean(agent && isAdapter(agent));
67
289
  },
68
290
  });
291
+ this.orchestra = new OrchestraEngine({
292
+ projectId: info.id,
293
+ projectName: info.name,
294
+ projectDir: info.dir,
295
+ roster: () => this.config.agents.filter((a) => a.enabled !== false && tierForKind(a.kind) === "adapter" && !isWithdrawnKind(a.kind)),
296
+ installedKinds: () => this.installedKinds,
297
+ makeAgent: (cfg, dir) => {
298
+ if (this.continuity)
299
+ throw new ContinuityError("unsupported", "parallel orchestra execution is outside sequential native continuity");
300
+ const agent = createAgent({ ...cfg, options: { ...this.policyOptions(cfg), loomProject: info.id } }, dir);
301
+ if (!isAdapter(agent))
302
+ throw new Error(`"${cfg.id}" is a bridge — it cannot run orchestra work`);
303
+ return agent;
304
+ },
305
+ append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
306
+ // A task thread is pinned to its worker, so the sidebar says who it is
307
+ // and a reply there reaches that agent. A worker that can't be pinned
308
+ // (none can't, today — but a roster can change mid-run) still gets its
309
+ // thread, just unpinned.
310
+ createChat: (title, opts) => {
311
+ try {
312
+ return this.createChat(title, opts?.agentId ? { agentId: opts.agentId } : {});
313
+ }
314
+ catch {
315
+ return this.createChat(title);
316
+ }
317
+ },
318
+ // Asked before a run is told to answer in a thread: an id from a client
319
+ // is a claim about this machine, and Main is real without being stored.
320
+ chatExists: (id) => this.conversations.chats().some((c) => c.id === id),
321
+ briefingFor: async (query, agentId, files) => [
322
+ this.activeSkillsBlock(),
323
+ await this.brainBriefFor({ query, agent: agentId, limit: 6 }),
324
+ this.teamBrain?.context(files ?? []) ?? "",
325
+ ]
326
+ .filter(Boolean)
327
+ .join("\n\n"),
328
+ gate: (agentId) => {
329
+ if (this.continuity)
330
+ throw new ContinuityError("unsupported", "parallel orchestra execution is outside sequential native continuity");
331
+ this.enforceQuarantine(agentId);
332
+ this.enforceBudget(agentId);
333
+ },
334
+ observe: (event) => this.trackCost(event),
335
+ stream: (f) => this.liveText(f),
336
+ gitDelivery: () => this.config.git?.delivery ?? "none",
337
+ goalBudgetUsd: () => this.config.budgets?.perGoalUsd ?? null,
338
+ maxConcurrentGoals: () => this.config.maxConcurrentGoals ?? null,
339
+ member: () => this.memberLogin,
340
+ coordinator: () => this.coordinator,
341
+ });
342
+ this.relay = new QuotaRelay({
343
+ config: () => this.config,
344
+ busy: (id) => { const a = this.agents.get(id); return Boolean(a && isAdapter(a) && a.busy()) || this.turns.busySince.has(id); },
345
+ turnChat: (id) => this.turns.turnChat.get(id),
346
+ available: async (id) => { const a = this.agents.get(id); return Boolean(a && isAdapter(a) && (await a.available())); },
347
+ interrupt: (chat) => this.interrupt({ chat, source: "route" }),
348
+ handoff: (to) => this.handoff(to, { source: "route" }),
349
+ holder: () => this.validHolder(),
350
+ setBriefing: (id, text) => {
351
+ const prior = this.briefings.pendingBriefings.get(id);
352
+ this.briefings.pendingBriefings.set(id, [prior, text].filter(Boolean).join("\n"));
353
+ },
354
+ send: (text, id, chat) => this.sendMessage(text, id, { chat, source: "route" }),
355
+ enqueue: (input) => this.enqueue(input),
356
+ list: (opts) => this.log.list(opts),
357
+ append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
358
+ warn: (message, detail) => logbook.warn("relay", message, String(detail), this.info.id),
359
+ });
360
+ this.proofs = new ProofRunner({
361
+ projectId: info.id,
362
+ dir: info.dir,
363
+ config: () => this.config.proof ?? {},
364
+ list: (opts) => this.log.list(opts),
365
+ append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
366
+ });
367
+ this.shifts = new NightShift({
368
+ dir: info.dir,
369
+ hasAgent: (id) => this.config.agents.some((a) => a.id === id),
370
+ createChat: (title, agentId) => this.createChat(title, agentId ? { agentId } : {}),
371
+ enqueue: (input) => this.enqueue(input),
372
+ queued: () => this.queue.snapshot().items,
373
+ removeQueued: (id) => this.queue.remove(id),
374
+ busy: () => this.turns.busySince.size > 0 || [...this.agents.values()].some((a) => isAdapter(a) && a.busy()),
375
+ proofsBusy: () => this.proofs.busy,
376
+ resumeAfterRestart: () => {
377
+ const q = this.queue.snapshot();
378
+ if (!q.paused || !q.reason?.startsWith("the daemon restarted"))
379
+ return;
380
+ this.queue.setPaused(false);
381
+ void this.drainPromptQueue();
382
+ },
383
+ list: (opts) => this.log.list(opts),
384
+ append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
385
+ warn: (message, detail) => logbook.warn("shift", message, String(detail), this.info.id),
386
+ });
387
+ this.crews = new CrewEngine({
388
+ projectId: info.id,
389
+ projectName: info.name,
390
+ projectDir: info.dir,
391
+ crews: () => this.config.crews ?? [],
392
+ saveCrews: (crews) => {
393
+ if (crews.length)
394
+ this.config.crews = crews;
395
+ else
396
+ delete this.config.crews;
397
+ this.saveConfig();
398
+ },
399
+ roster: () => this.config.agents.filter((a) => a.enabled !== false && tierForKind(a.kind) === "adapter" && !isWithdrawnKind(a.kind)),
400
+ makeAgent: (cfg, dir) => {
401
+ const agent = createAgent({ ...cfg, options: { ...this.policyOptions(cfg), loomProject: info.id } }, dir);
402
+ if (!isAdapter(agent))
403
+ throw new Error(`"${cfg.id}" is a bridge — it can't be on a crew`);
404
+ return agent;
405
+ },
406
+ append: (e) => (this.closed ? { ...e, id: -1, ts: Date.now() } : this.log.append(e)),
407
+ createChat: (title, opts) => this.createChat(title, opts?.agentId ? { agentId: opts.agentId } : {}),
408
+ // The decorated sidebar list calls crews.list() to discover groups.
409
+ // Using it to validate a crew's channel recurses back into crew state.
410
+ chatExists: (id) => this.conversations.chats().some((c) => c.id === id),
411
+ native: () => this.continuity,
412
+ nativeOptions: (cfg) => this.policyOptions(cfg),
413
+ nativeHealth: (id) => this.harnesses.ensure(id),
414
+ coordinator: () => this.coordinator,
415
+ policy: () => this.teamPolicy,
416
+ spentToday: (crew) => this.crewSpendToday(crew),
417
+ gitDelivery: () => this.config.git?.delivery ?? "none",
418
+ // agent-asserted, so below a human's word, and private to the crew until promoted
419
+ remember: (m) => {
420
+ this.brain.add({ kind: m.kind, text: m.text, scope: { crew: m.crewId }, confidence: 0.85, evidence: m.text,
421
+ provenance: { agentId: m.agentId, eventId: m.eventId, ts: Date.now() } });
422
+ },
423
+ briefingFor: async (query, agentId, crewId) => [this.activeSkillsBlock(), await this.brainBriefFor({ query, agent: agentId, crew: crewId, limit: 6 }), this.teamBrain?.context([]) ?? ""]
424
+ .filter(Boolean)
425
+ .join("\n\n"),
426
+ gate: (agentId) => {
427
+ this.enforceQuarantine(agentId);
428
+ this.enforceBudget(agentId);
429
+ },
430
+ observe: (event) => this.trackCost(event),
431
+ stream: (f) => this.liveText(f),
432
+ createTask: (input) => this.createTask(input),
433
+ updateTask: (id, patch) => this.updateTask(id, patch),
434
+ member: () => this.memberLogin,
435
+ });
436
+ this.queue = new PromptQueue(path.join(projectLoomDir(info.dir), "queue.json"), (q) => {
437
+ for (const cb of this.queueCoordinator.queueListeners)
438
+ cb(q);
439
+ this.watchClockConditions(q);
440
+ });
441
+ this.servers = new Servers({
442
+ projectDir: info.dir,
443
+ configs: () => this.config.servers ?? [],
444
+ onChange: (name, status) => {
445
+ for (const cb of this.serverListeners)
446
+ cb({ kind: "state", name, status });
447
+ // A server that died while an agent worked against it is the answer to
448
+ // "why is the page blank?" — it belongs in the thread, not only a pane.
449
+ if (status.state === "crashed") {
450
+ this.appendIfOpen({
451
+ kind: "status",
452
+ payload: { state: "server_crashed", server: name, exitCode: status.exitCode },
453
+ });
454
+ }
455
+ },
456
+ onLine: (name, line) => {
457
+ for (const cb of this.serverListeners)
458
+ cb({ kind: "line", name, line });
459
+ },
460
+ });
461
+ // a goal or a route that ends frees the head of the queue
462
+ log.onEvent((e) => {
463
+ if (e.kind === "orchestra" || e.kind === "route_completed" || e.kind === "route_failed")
464
+ this.kickQueue();
465
+ });
69
466
  }
70
467
  static async open(info) {
71
468
  const config = readProjectConfig(info.dir);
72
- if (!config)
469
+ if (!config) {
470
+ // Say which: a folder that's gone (moved, deleted, a temp dir a reboot
471
+ // cleared) is a different fix from a folder that was never initialised.
472
+ if (!fs.existsSync(info.dir))
473
+ throw new Error(`this project's folder is gone: ${info.dir} — it was moved or deleted`);
73
474
  throw new Error(`project at ${info.dir} has no .loom/config.json — run loom init`);
475
+ }
74
476
  const log = await EventLog.open(projectLoomDir(info.dir));
75
- const rt = new ProjectRuntime(info, config, log);
477
+ let rt;
478
+ try {
479
+ rt = new ProjectRuntime(info, config, log);
480
+ }
481
+ catch (error) {
482
+ log.close();
483
+ throw error;
484
+ }
76
485
  rt.configMtime = configMtimeOf(info.dir);
77
486
  rt.rehydrateCosts();
78
487
  // Pull each connected ADE's native memory into the shared brain on open.
@@ -82,49 +491,42 @@ export class ProjectRuntime {
82
491
  catch {
83
492
  // Memory import is best-effort; never block opening a project.
84
493
  }
85
- return rt;
86
- }
87
- // -------------------------------------------------------------------------
88
- // Cost telemetry — O(1) incremental, rehydrated from the log on open
89
- // -------------------------------------------------------------------------
90
- costs = { totalUsd: 0, turns: 0, totalMs: 0 };
91
- costsByAgent = new Map();
92
- rehydrateCosts() {
93
- for (const event of this.log.list({ kinds: ["status", "run_complete"] })) {
94
- this.trackCost(event);
95
- }
96
- }
97
- trackCost(event) {
98
- const agentId = event.agentId ?? "unknown";
99
- const entry = this.costsByAgent.get(agentId) ?? { usd: 0, turns: 0, ms: 0 };
100
- if (event.kind === "status" && event.payload.state === "turn_cost") {
101
- const usd = Number(event.payload.costUsd ?? 0);
102
- if (usd > 0) {
103
- this.costs.totalUsd += usd;
104
- entry.usd += usd;
105
- this.costsByAgent.set(agentId, entry);
494
+ // Watch the project's MCP servers, if it has any — see mcpHealth.
495
+ rt.startMcpHealthLoop();
496
+ void detectAdes()
497
+ .then((found) => (rt.installedKinds = Object.keys(found).filter((k) => found[k])))
498
+ .catch(() => { });
499
+ // Worktree-per-agent: prepare each adapter's checkout and respawn it there.
500
+ // Safe pre-start — agents are constructed lazily-started, so replacing the
501
+ // instance before its first turn loses nothing.
502
+ if (config.git?.worktreePerAgent) {
503
+ for (const cfg of config.agents) {
504
+ if (cfg.enabled === false)
505
+ continue;
506
+ const live = rt.agents.get(cfg.id);
507
+ if (!live || live.capabilities.tier !== "adapter")
508
+ continue;
509
+ await rt.ensureAgentWorktree(cfg.id);
510
+ await rt.agentLifecycle.retire(cfg.id);
511
+ rt.spawnAgent(cfg);
106
512
  }
107
513
  }
108
- else if (event.kind === "run_complete") {
109
- const ms = Number(event.payload.durationMs ?? 0);
110
- this.costs.turns += 1;
111
- this.costs.totalMs += ms;
112
- entry.turns += 1;
113
- entry.ms += ms;
114
- this.costsByAgent.set(agentId, entry);
115
- }
116
- }
117
- costSummary() {
118
- const byAgent = [...this.costsByAgent.entries()]
119
- .map(([agentId, c]) => ({ agentId, ...c }))
120
- .sort((a, b) => b.usd - a.usd || b.turns - a.turns);
121
- return {
122
- totalUsd: this.costs.totalUsd,
123
- turns: this.costs.turns,
124
- totalMs: this.costs.totalMs,
125
- byAgent,
126
- };
514
+ return rt;
127
515
  }
516
+ rehydrateCosts() { return this.accounting.rehydrateCosts(); }
517
+ trackCost(event) { return this.accounting.trackCost(event); }
518
+ costSummary() { return this.accounting.costSummary(); }
519
+ budgets() { return this.accounting.budgets(); }
520
+ setBudget(agentId, usdPerDay) { return this.accounting.setBudget(agentId, usdPerDay); }
521
+ spendTodayFor(agentId, now = Date.now()) { return this.accounting.spendTodayFor(agentId, now); }
522
+ costSeries(days = 30, now = Date.now()) { return this.accounting.costSeries(days, now); }
523
+ budgetStatus(now = Date.now()) { return this.accounting.budgetStatus(now); }
524
+ enforceQuarantine(agentId) { return this.accounting.enforceQuarantine(agentId); }
525
+ enforceBudget(agentId, now = Date.now()) { return this.accounting.enforceBudget(agentId, now); }
526
+ liftBudgetPause(agentId, now = Date.now()) { return this.accounting.liftBudgetPause(agentId, now); }
527
+ quarantined() { return this.accounting.quarantined(); }
528
+ quarantine(agentId, reason, displaced, now = Date.now()) { return this.accounting.quarantine(agentId, reason, displaced, now); }
529
+ unquarantine(agentId) { return this.accounting.unquarantine(agentId); }
128
530
  /** Has .loom/config.json changed since this runtime was opened? */
129
531
  configStale() {
130
532
  return configMtimeOf(this.info.dir) > this.configMtime;
@@ -136,6 +538,64 @@ export class ProjectRuntime {
136
538
  * on an agent's turn to finish. Nothing is torn down: a role is a name, not
137
539
  * a capability, so no adapter needs restarting.
138
540
  */
541
+ /** An agent's picture: a small PNG/JPEG/WebP data URL, or null to clear. */
542
+ setAgentAvatar(agentId, dataUrl) {
543
+ const cfg = this.config.agents.find((a) => a.id === agentId);
544
+ if (!cfg)
545
+ return null;
546
+ if (dataUrl === null || dataUrl === "")
547
+ delete cfg.avatar;
548
+ else {
549
+ if (!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(dataUrl))
550
+ throw new Error("a picture is a PNG, JPEG or WebP image");
551
+ if (dataUrl.length > 120_000)
552
+ throw new Error("that picture is too big — Loom keeps them small (about 96px)");
553
+ cfg.avatar = dataUrl;
554
+ }
555
+ this.saveConfig();
556
+ return { id: agentId, avatar: cfg.avatar ?? null };
557
+ }
558
+ /**
559
+ * Ask an agent whether it's ready — installed, signed in, model listed —
560
+ * without sending it a prompt (see AdapterBase.selfCheck).
561
+ */
562
+ async checkAgent(agentId) {
563
+ const cfg = this.config.agents.find((a) => a.id === agentId);
564
+ if (!cfg)
565
+ return null;
566
+ const started = Date.now();
567
+ if (cfg.enabled === false)
568
+ return { ok: false, ms: 0, checks: [{ name: "switched on", ok: false, detail: "this agent is switched off in the roster" }] };
569
+ const live = this.agents.get(agentId);
570
+ let checks;
571
+ if (live && live instanceof AdapterBase)
572
+ checks = await live.selfCheck();
573
+ else if (live) {
574
+ const ok = await live.available();
575
+ checks = [{ name: "available", ok, detail: ok ? "running" : "not available on this machine" }];
576
+ }
577
+ else
578
+ checks = [{ name: "loaded", ok: false, detail: "Loom hasn't loaded this agent — is its CLI installed?" }];
579
+ return { ok: checks.every((c) => c.ok), ms: Date.now() - started, checks };
580
+ }
581
+ /** Standing instructions for an agent; empty clears them. */
582
+ setAgentInstructions(agentId, text) {
583
+ const cfg = this.config.agents.find((a) => a.id === agentId);
584
+ if (!cfg)
585
+ return null;
586
+ const clean = text.trim().slice(0, 4000);
587
+ if (clean)
588
+ cfg.instructions = clean;
589
+ else
590
+ delete cfg.instructions;
591
+ this.saveConfig();
592
+ return { id: agentId, instructions: clean };
593
+ }
594
+ /** The block an agent's standing instructions ride in, ahead of its turn. */
595
+ agentInstructions(agentId) {
596
+ const text = this.config.agents.find((a) => a.id === agentId)?.instructions?.trim();
597
+ return text ? `Standing instructions for you in this project, from the person you work for:\n${text}\n` : "";
598
+ }
139
599
  setAgentRole(agentId, role) {
140
600
  const cfg = this.config.agents.find((a) => a.id === agentId);
141
601
  if (!cfg)
@@ -160,30 +620,115 @@ export class ProjectRuntime {
160
620
  if (wantsDefault && defaultId && !this.config.agents.some((a) => a.id === defaultId)) {
161
621
  throw new Error(`no agent "${defaultId}" in this project`);
162
622
  }
163
- if (patch.brain) {
164
- const b = { ...(this.config.brain ?? {}) };
165
- if (patch.brain.extractor === "auto" || patch.brain.extractor === "off")
166
- b.extractor = patch.brain.extractor;
167
- if (typeof patch.brain.model === "string")
168
- b.model = patch.brain.model.trim() || undefined;
169
- this.config.brain = b;
170
- }
171
- if (patch.projection) {
172
- const pr = { ...(this.config.projection ?? {}) };
173
- if (patch.projection.mode === "template" || patch.projection.mode === "llm")
174
- pr.mode = patch.projection.mode;
175
- if (typeof patch.projection.model === "string")
176
- pr.model = patch.projection.model.trim() || undefined;
177
- this.config.projection = pr;
178
- }
179
- if (wantsDefault) {
180
- // empty clears it; a real value was checked against the roster above
181
- if (!defaultId)
182
- delete this.config.defaultAgent;
183
- else
184
- this.config.defaultAgent = defaultId;
623
+ if (patch.git?.delivery !== undefined && !GIT_DELIVERIES.includes(patch.git.delivery))
624
+ throw new Error(`git.delivery must be one of ${GIT_DELIVERIES.join(", ")}`);
625
+ const previousConfig = { ...this.config }, previousContinuity = this.continuity;
626
+ const changingContinuity = typeof patch.brain?.continuity === "boolean" && patch.brain.continuity !== Boolean(this.config.brain?.continuity);
627
+ try {
628
+ if (patch.brain) {
629
+ if (changingContinuity) {
630
+ if (this.anyBusy() || this.continuity?.store.activeReceipts().length || this.queue.length)
631
+ throw new ContinuityError("conflict", "finish or reconcile native turns before changing continuity mode; also clear queued prompts");
632
+ if (patch.brain.continuity) {
633
+ this.continuity = new ContinuityEngine(this.log, this.info.id);
634
+ this.harnesses.start();
635
+ }
636
+ else {
637
+ this.continuity?.store.releaseOwner();
638
+ this.continuity = null;
639
+ this.harnesses.stop();
640
+ }
641
+ }
642
+ const b = { ...(this.config.brain ?? {}) };
643
+ if (typeof patch.brain.continuity === "boolean")
644
+ b.continuity = patch.brain.continuity;
645
+ if (patch.brain.extractor === "auto" || patch.brain.extractor === "off")
646
+ b.extractor = patch.brain.extractor;
647
+ if (typeof patch.brain.semantic === "boolean") {
648
+ if (patch.brain.semantic)
649
+ b.semantic = true;
650
+ else
651
+ delete b.semantic;
652
+ }
653
+ if (typeof patch.brain.model === "string")
654
+ b.model = patch.brain.model.trim() || undefined;
655
+ this.config.brain = b;
656
+ }
657
+ if (patch.projection) {
658
+ const pr = { ...(this.config.projection ?? {}) };
659
+ if (patch.projection.mode === "template" || patch.projection.mode === "llm")
660
+ pr.mode = patch.projection.mode;
661
+ if (typeof patch.projection.model === "string")
662
+ pr.model = patch.projection.model.trim() || undefined;
663
+ this.config.projection = pr;
664
+ }
665
+ if (wantsDefault) {
666
+ // empty clears it; a real value was checked against the roster above
667
+ if (!defaultId)
668
+ delete this.config.defaultAgent;
669
+ else
670
+ this.config.defaultAgent = defaultId;
671
+ }
672
+ if (patch.git) {
673
+ const g = { ...(this.config.git ?? {}) };
674
+ for (const k of [
675
+ "commitPerTurn",
676
+ "branchPerTask",
677
+ "worktreePerAgent",
678
+ "mergeOnHandoff",
679
+ ]) {
680
+ if (typeof patch.git[k] === "boolean") {
681
+ if (patch.git[k])
682
+ g[k] = true;
683
+ else
684
+ delete g[k];
685
+ }
686
+ }
687
+ if (patch.git.delivery !== undefined) {
688
+ if (patch.git.delivery === "none")
689
+ delete g.delivery;
690
+ else
691
+ g.delivery = patch.git.delivery;
692
+ }
693
+ if (Object.keys(g).length)
694
+ this.config.git = g;
695
+ else
696
+ delete this.config.git;
697
+ // worktreePerAgent takes effect for agents spawned from here on; the
698
+ // Settings screen says so rather than pretending it's instant.
699
+ }
700
+ if (patch.safety) {
701
+ if (typeof patch.safety.snapshotBeforeRoutes === "boolean") {
702
+ if (patch.safety.snapshotBeforeRoutes)
703
+ this.config.safety = { snapshotBeforeRoutes: true };
704
+ else
705
+ delete this.config.safety;
706
+ }
707
+ }
708
+ this.saveConfig();
185
709
  }
186
- this.saveConfig();
710
+ catch (error) {
711
+ if (this.continuity !== previousContinuity) {
712
+ this.continuity?.store.releaseOwner();
713
+ this.continuity = previousContinuity;
714
+ previousContinuity?.store.claimOwner();
715
+ if (previousContinuity)
716
+ this.harnesses.start();
717
+ else
718
+ this.harnesses.stop();
719
+ }
720
+ for (const key of Object.keys(this.config))
721
+ delete this.config[key];
722
+ Object.assign(this.config, previousConfig);
723
+ throw error;
724
+ }
725
+ if (changingContinuity) {
726
+ this.briefings.cancelExtraction();
727
+ this.turns.turnText.clear();
728
+ this.briefings.pendingBriefings.clear();
729
+ }
730
+ if (changingContinuity || typeof patch.brain?.semantic === "boolean")
731
+ void this.briefings.configureSemantic(!this.continuity && this.config.brain?.semantic === true);
187
732
  return this.config;
188
733
  }
189
734
  /**
@@ -196,13 +741,23 @@ export class ProjectRuntime {
196
741
  settings() {
197
742
  return {
198
743
  brain: {
744
+ continuity: Boolean(this.config.brain?.continuity),
199
745
  extractor: this.config.brain?.extractor === "off" ? "off" : "auto",
200
746
  model: this.config.brain?.model ?? "",
747
+ semantic: Boolean(this.config.brain?.semantic),
201
748
  },
202
749
  projection: {
203
750
  mode: this.config.projection?.mode === "llm" ? "llm" : "template",
204
751
  model: this.config.projection?.model ?? "",
205
752
  },
753
+ git: {
754
+ commitPerTurn: Boolean(this.config.git?.commitPerTurn),
755
+ branchPerTask: Boolean(this.config.git?.branchPerTask),
756
+ worktreePerAgent: Boolean(this.config.git?.worktreePerAgent),
757
+ mergeOnHandoff: Boolean(this.config.git?.mergeOnHandoff),
758
+ delivery: this.config.git?.delivery ?? "none",
759
+ },
760
+ safety: { snapshotBeforeRoutes: Boolean(this.config.safety?.snapshotBeforeRoutes) },
206
761
  defaultAgent: this.config.defaultAgent ?? "",
207
762
  agents: this.config.agents.map((a) => ({ id: a.id, kind: a.kind, role: a.role })),
208
763
  };
@@ -222,13 +777,40 @@ export class ProjectRuntime {
222
777
  if (!knownAgentKinds().includes(kind)) {
223
778
  throw new Error(`unknown agent kind "${kind}" (known: ${knownAgentKinds().join(", ")})`);
224
779
  }
225
- const id = (opts.id ?? kind).trim().slice(0, 40);
780
+ // Known is not the same as offered. This is the endpoint the "add agent"
781
+ // rail drives, and the rail only ever lists ADES. Accepting a kind that no
782
+ // view offers meant a withdrawn agent could be put in a roster by guessing
783
+ // its name, and then sat there unadvertised and unexplained. Refuse it
784
+ // here, and name what replaced it.
785
+ if (isWithdrawnKind(kind)) {
786
+ const replacement = kind === "antigravity" ? ' — use "antigravity-cli"' : "";
787
+ throw new Error(`"${kind}" is no longer offered${replacement}`);
788
+ }
789
+ // A second session of the same kind is a feature, not a mistake.
790
+ //
791
+ // The roster has always been keyed by instance id with kind alongside it, so
792
+ // two Claude Code sessions in one project were representable — but adding
793
+ // one threw, because the id defaulted to the kind and the kind was taken. A
794
+ // caller who names the instance gets that name; a caller who doesn't gets
795
+ // the next free suffix, so "add another" needs no ceremony. Both sessions
796
+ // read and write the one project brain: memory import dedupes by file path,
797
+ // and units are project-scoped, so nothing has to change for them to share.
798
+ const explicit = opts.id?.trim().slice(0, 40);
799
+ if (explicit && this.config.agents.some((a) => a.id === explicit)) {
800
+ throw new Error(`"${explicit}" is already in this project`);
801
+ }
802
+ const id = explicit || this.nextInstanceId(kind);
226
803
  if (!id)
227
804
  throw new Error("an agent needs an id");
228
- if (this.config.agents.some((a) => a.id === id)) {
229
- throw new Error(`"${id}" is already in this project`);
230
- }
231
- const cfg = { id, kind, role: (opts.role ?? kind).trim().slice(0, 40) || kind };
805
+ // A `model` agent with no model is an agent that refuses every turn, so
806
+ // the options that make it work are settable as it's added rather than in
807
+ // a second step nobody is told about.
808
+ const cfg = {
809
+ id,
810
+ kind,
811
+ role: (opts.role ?? kind).trim().slice(0, 40) || kind,
812
+ ...(opts.options && Object.keys(opts.options).length ? { options: opts.options } : {}),
813
+ };
232
814
  // Build it before saving. A config entry with no live agent behind it makes
233
815
  // status() throw the moment anything asks — this.agent(id) doesn't find it —
234
816
  // so the project 500s on every poll and the roster you just changed becomes
@@ -239,6 +821,73 @@ export class ProjectRuntime {
239
821
  this.saveConfig();
240
822
  return cfg;
241
823
  }
824
+ /**
825
+ * The next free instance id for a kind: `codex`, then `codex-2`, `codex-3`.
826
+ *
827
+ * The bare kind stays the first instance's id so existing projects, configs
828
+ * and route specs that name `codex` keep meaning what they meant.
829
+ */
830
+ nextInstanceId(kind) {
831
+ const taken = new Set(this.config.agents.map((a) => a.id));
832
+ if (!taken.has(kind))
833
+ return kind;
834
+ for (let n = 2; n < 100; n++) {
835
+ const candidate = `${kind}-${n}`;
836
+ if (!taken.has(candidate))
837
+ return candidate;
838
+ }
839
+ throw new Error(`too many ${kind} sessions in this project`);
840
+ }
841
+ /** How many instances of each kind the roster holds. */
842
+ instanceCounts() {
843
+ const counts = {};
844
+ for (const a of this.config.agents)
845
+ counts[a.kind] = (counts[a.kind] ?? 0) + 1;
846
+ return counts;
847
+ }
848
+ /**
849
+ * Where this agent works: its own worktree when the project opted in, the
850
+ * shared tree otherwise.
851
+ *
852
+ * With git.worktreePerAgent on, each adapter gets a sibling checkout on its
853
+ * own branch (agent/<id>), so two agents editing at once cannot collide in
854
+ * the filesystem. The trade is stated where it's decided: merging is
855
+ * MANUAL in this version — the branches are ordinary git branches and
856
+ * `git merge agent/<id>` is the handoff of record. Auto-merge on baton
857
+ * handoff is a different feature with different failure modes (conflicts
858
+ * mid-handoff), deliberately not smuggled in here.
859
+ */
860
+ agentDirs = new Map();
861
+ agentDir(agentId) {
862
+ return this.agentDirs.get(agentId) ?? this.info.dir;
863
+ }
864
+ async ensureAgentWorktree(agentId) {
865
+ const existing = this.agentDirs.get(agentId);
866
+ if (existing)
867
+ return existing;
868
+ const wt = worktreePath(this.info.dir, `agent-${agentId}`);
869
+ if (!fs.existsSync(wt)) {
870
+ try {
871
+ await gitAddWorktree(this.info.dir, {
872
+ slug: `agent-${agentId}`,
873
+ newBranch: `agent/${agentId}`,
874
+ });
875
+ }
876
+ catch (err) {
877
+ // Branch already exists from a previous run — attach to it instead.
878
+ try {
879
+ await gitAddWorktree(this.info.dir, { slug: `agent-${agentId}`, branch: `agent/${agentId}` });
880
+ }
881
+ catch {
882
+ logbook.warn("git", `worktree for ${agentId} could not be created — falling back to the shared tree`, String(err), this.info.id);
883
+ this.agentDirs.set(agentId, this.info.dir);
884
+ return this.info.dir;
885
+ }
886
+ }
887
+ }
888
+ this.agentDirs.set(agentId, wt);
889
+ return wt;
890
+ }
242
891
  /**
243
892
  * Create one agent and subscribe to it, exactly as the constructor does.
244
893
  *
@@ -247,18 +896,117 @@ export class ProjectRuntime {
247
896
  * there when the project opened.
248
897
  */
249
898
  spawnAgent(cfg) {
250
- const agent = createAgent(cfg, this.info.dir);
251
- this.agents.set(cfg.id, agent);
252
- agent.onEvent((e) => {
253
- const chat = this.turnChat.get(agent.id);
254
- const event = this.log.append({
255
- kind: e.kind,
256
- agentId: agent.id,
257
- ...(chat ? { chat } : {}),
258
- payload: e.payload,
259
- });
260
- this.afterAgentEvent(event);
899
+ // The project id rides along so an adapter can file approvals ("always
900
+ // ask") against the right project — see core/approvals.ts.
901
+ const agent = createAgent({ ...cfg, options: { ...this.policyOptions(cfg), loomProject: this.info.id } }, this.agentDir(cfg.id));
902
+ // What this turn has typed since its last finished message. A turn that
903
+ // is stopped (or dies) mid-reply never sends that message, and the words
904
+ // you watched arrive would vanish — so they're kept, marked partial.
905
+ // Provider agents keep their own buffers (see providers/ingestion.ts).
906
+ let typed = "";
907
+ const keepsPartial = !(agent instanceof ProviderAgent);
908
+ agent.onStream?.((d) => {
909
+ if (!d.reasoning)
910
+ typed += d.text;
911
+ this.liveText({ agentId: agent.id, chat: this.turns.turnChat.get(agent.id) ?? MAIN_CHAT, ...d });
912
+ });
913
+ this.agentLifecycle.install(agent, (e) => {
914
+ try {
915
+ const runId = typeof e.payload.loomRunId === "string" ? e.payload.loomRunId : undefined;
916
+ const liveRun = runId ? this.continuity?.isLiveRun(runId) : true;
917
+ const chat = (runId ? this.continuity?.eventChat(runId) : undefined) ?? this.turns.turnChat.get(agent.id);
918
+ let payload = e.payload;
919
+ // Enrich the completed turn so its gen_ai span carries system + model +
920
+ // cost (adapters only put tokens on run_complete). The kind is known
921
+ // here; the model prefers what the adapter actually used, else the
922
+ // configured override; the cost is the turn_cost stashed a moment ago.
923
+ const p = e.payload;
924
+ if (liveRun && e.kind === "status" && p.state === "turn_cost") {
925
+ const usd = Number(p.costUsd ?? 0);
926
+ if (usd > 0)
927
+ this.turns.pendingCost.set(agent.id, usd);
928
+ }
929
+ else if (liveRun && e.kind === "run_complete") {
930
+ const model = (typeof p.model === "string" && p.model) ||
931
+ (typeof cfg.options?.model === "string" ? cfg.options.model : undefined);
932
+ const cost = this.turns.pendingCost.get(agent.id);
933
+ this.turns.pendingCost.delete(agent.id);
934
+ payload = {
935
+ ...p,
936
+ adapter: cfg.kind,
937
+ ...(model ? { model } : {}),
938
+ ...(cost !== undefined ? { costUsd: cost } : {}),
939
+ };
940
+ }
941
+ // An agent that stops to ask you something is the whole reason Loom
942
+ // exists: the next queued prompt would answer a question you never saw,
943
+ // so the queue waits for you instead. Answering goes out immediately —
944
+ // a paused queue holds what's lined up, not what you type now.
945
+ if (liveRun && e.kind === "needs_input")
946
+ this.holdQueueFor(agent.id);
947
+ // A question asked through the agent's tool blocks its turn until answered: remember it,
948
+ // so a reply typed in the chat reaches it (turns.ts) and status stays honest.
949
+ if (e.kind === "needs_input" && typeof p.requestId === "string" && p.responseMode !== "message") {
950
+ const ids = Array.isArray(p.questions) ? p.questions.map((q, i) => String(q.id ?? i)) : [];
951
+ this.openQuestions.set(agent.id, { requestId: p.requestId, chat: chat ?? MAIN_CHAT, ids });
952
+ }
953
+ if (e.kind === "status" && p.state === "question_answered" && this.openQuestions.get(agent.id)?.requestId === p.requestId) {
954
+ this.openQuestions.delete(agent.id);
955
+ }
956
+ // Any terminal event stops the stale-session clock — a turn that ended in
957
+ // an error is over, not hung.
958
+ const turnOver = e.kind === "run_complete" || e.kind === "error" || (e.kind === "status" && p.state === "interrupted");
959
+ if (turnOver && !this.continuity) {
960
+ this.turns.busySince.delete(agent.id);
961
+ }
962
+ if (turnOver)
963
+ this.openQuestions.delete(agent.id);
964
+ if (keepsPartial) {
965
+ if (e.kind === "message" && !p.reasoning)
966
+ typed = "";
967
+ if (turnOver && typed.trim()) {
968
+ this.log.append({ kind: "message", agentId: agent.id, ...(chat ? { chat } : {}), payload: { text: typed, partial: true } });
969
+ }
970
+ if (turnOver)
971
+ typed = "";
972
+ }
973
+ const event = this.log.append({
974
+ kind: e.kind,
975
+ agentId: agent.id,
976
+ ...(chat ? { chat } : {}),
977
+ payload,
978
+ });
979
+ this.continuity?.ingest(event);
980
+ this.nativeUsage.observe(event);
981
+ if (liveRun)
982
+ this.afterAgentEvent(event);
983
+ if (liveRun && e.kind === "message" && p.proposedPlan === true)
984
+ this.saveProposedPlan(agent.id, chat, String(p.text ?? ""));
985
+ if (turnOver && !this.continuity)
986
+ this.kickQueue();
987
+ }
988
+ catch {
989
+ // A failed durable ingest is a project fault, not a disposable UI
990
+ // observer. Stop foreground work and keep its receipt uncertain.
991
+ this.journalFailure = new ContinuityError("recovery_required", "project journal ingest failed; native execution was interrupted; reopen and reconcile before continuing");
992
+ if (isAdapter(agent) && agent.busy())
993
+ void agent.interrupt().catch(() => { });
994
+ }
261
995
  });
996
+ if (agent instanceof ProviderAgent) {
997
+ agent.onLive((d) => {
998
+ if (this.closed || this.agents.get(agent.id) !== agent)
999
+ return;
1000
+ this.live.push(d);
1001
+ // The same text feeds the thread's live reply (the `stream` frames and
1002
+ // the reload-safe snapshot), as every other agent's does.
1003
+ if (d.streamKind === "assistant_text" || d.streamKind === "reasoning_text" || d.streamKind === "reasoning_summary_text") {
1004
+ this.liveText({ agentId: agent.id, chat: d.chat, text: d.delta, ...(d.streamKind === "assistant_text" ? {} : { reasoning: true }) });
1005
+ }
1006
+ });
1007
+ agent.onLiveItem((i) => { if (!this.closed && this.agents.get(agent.id) === agent)
1008
+ this.live.pushItem(i); });
1009
+ }
262
1010
  return agent;
263
1011
  }
264
1012
  /**
@@ -277,7 +1025,7 @@ export class ProjectRuntime {
277
1025
  throw new Error(`"${agentId}" holds the baton — hand it to someone else first`);
278
1026
  }
279
1027
  const live = this.agents.get(agentId);
280
- if (live && isAdapter(live) && live.busy()) {
1028
+ if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId))) {
281
1029
  throw new Error(`"${agentId}" is mid-turn — interrupt it first`);
282
1030
  }
283
1031
  // Its events stay in the log: the history happened, and a roster change
@@ -285,8 +1033,7 @@ export class ProjectRuntime {
285
1033
  this.config.agents = this.config.agents.filter((a) => a.id !== agentId);
286
1034
  this.saveConfig();
287
1035
  if (live) {
288
- void Promise.resolve(live.stop()).catch(() => { });
289
- this.agents.delete(agentId);
1036
+ void this.agentLifecycle.retire(agentId);
290
1037
  }
291
1038
  return { removed: agentId };
292
1039
  }
@@ -294,40 +1041,114 @@ export class ProjectRuntime {
294
1041
  * Point an agent at a different model.
295
1042
  *
296
1043
  * The model is read once, when the adapter is constructed (createAgent hands
297
- * it cfg.options), so changing it means building a fresh agent — which drops
298
- * the CLI session the old one was resuming. That's the right behaviour for a
299
- * model switch: continuing one model's conversation on another model is not a
300
- * thing the underlying CLIs support anyway. Refused mid-turn, because swapping
301
- * the process out from under a running turn would strand it.
1044
+ * it cfg.options), so changing it means building a fresh adapter instance.
1045
+ * Native resume data stays persisted; each adapter decides whether its harness
1046
+ * can reuse it. Refused while a turn is preparing or running, because swapping
1047
+ * the process out from under that turn would strand it.
302
1048
  *
303
1049
  * An empty model clears the override, so the CLI falls back to its own default
304
1050
  * — the honest "Default" the picker offers.
305
1051
  */
306
- setAgentModel(agentId, model) {
1052
+ setAgentModel(agentId, model, provider) {
307
1053
  const cfg = this.config.agents.find((a) => a.id === agentId);
308
1054
  if (!cfg)
309
1055
  throw new Error(`unknown agent "${agentId}"`);
310
1056
  const live = this.agents.get(agentId);
311
- if (live && isAdapter(live) && live.busy()) {
1057
+ if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId))) {
312
1058
  throw new Error(`"${agentId}" is mid-turn — wait for it to finish, then switch models`);
313
1059
  }
314
- const next = model.trim().slice(0, 80);
1060
+ // OpenRouter ids run long ("provider/family-size-variant:free"); 80 cut
1061
+ // real ones off mid-name.
1062
+ const next = model.trim().slice(0, 200);
315
1063
  const options = { ...(cfg.options ?? {}) };
316
1064
  if (next)
317
1065
  options.model = next;
318
1066
  else
319
1067
  delete options.model;
1068
+ // A model agent's pick names its provider too (the list spans them all).
1069
+ if (cfg.kind === "model" && provider?.trim())
1070
+ options.provider = provider.trim().toLowerCase();
320
1071
  cfg.options = options;
321
1072
  // Rebuild so the new model actually takes: stop the old process, spawn a
322
1073
  // replacement subscribed exactly as the constructor's loop does.
323
1074
  if (live) {
324
- void Promise.resolve(live.stop()).catch(() => { });
325
- this.agents.delete(agentId);
1075
+ void this.agentLifecycle.retire(agentId);
326
1076
  }
327
1077
  this.spawnAgent(cfg);
328
1078
  this.saveConfig();
329
1079
  return cfg;
330
1080
  }
1081
+ /**
1082
+ * A model agent's sampling: temperature (0–2) and the reply's token cap.
1083
+ * Null clears one back to the provider's default. Rebuilt like a model
1084
+ * switch, because the adapter reads them when it's built.
1085
+ */
1086
+ setAgentSampling(agentId, s) {
1087
+ const cfg = this.config.agents.find((a) => a.id === agentId);
1088
+ if (!cfg)
1089
+ throw new Error(`unknown agent "${agentId}"`);
1090
+ if (cfg.kind !== "model")
1091
+ throw new Error(`"${agentId}" is a ${cfg.kind} — sampling is set in its own CLI, not here`);
1092
+ const live = this.agents.get(agentId);
1093
+ if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId)))
1094
+ throw new Error(`"${agentId}" is mid-turn — wait for it to finish`);
1095
+ const options = { ...(cfg.options ?? {}) };
1096
+ if (s.temperature !== undefined) {
1097
+ if (s.temperature === null)
1098
+ delete options.temperature;
1099
+ else if (!Number.isFinite(s.temperature) || s.temperature < 0 || s.temperature > 2)
1100
+ throw new Error("temperature must be between 0 and 2");
1101
+ else
1102
+ options.temperature = Math.round(s.temperature * 100) / 100;
1103
+ }
1104
+ if (s.maxTokens !== undefined) {
1105
+ if (s.maxTokens === null)
1106
+ delete options.maxTokens;
1107
+ else if (!Number.isInteger(s.maxTokens) || s.maxTokens < 16 || s.maxTokens > 200_000)
1108
+ throw new Error("max tokens must be a whole number from 16 to 200000");
1109
+ else
1110
+ options.maxTokens = s.maxTokens;
1111
+ }
1112
+ cfg.options = options;
1113
+ if (live)
1114
+ void this.agentLifecycle.retire(agentId);
1115
+ this.spawnAgent(cfg);
1116
+ this.saveConfig();
1117
+ return cfg;
1118
+ }
1119
+ /**
1120
+ * Choose how much an agent may do without asking: bypass | auto | ask.
1121
+ *
1122
+ * Same shape as setAgentModel — the mode is read when the adapter is built
1123
+ * (it becomes CLI flags, or the env of opencode's server), so the agent is
1124
+ * rebuilt. Refused mid-turn for the same reason.
1125
+ */
1126
+ setAgentPermissions(agentId, mode) {
1127
+ if (!isPermissionMode(mode))
1128
+ throw new Error(`permissions must be bypass, auto or ask`);
1129
+ const cfg = this.config.agents.find((a) => a.id === agentId);
1130
+ if (!cfg)
1131
+ throw new Error(`unknown agent "${agentId}"`);
1132
+ const why = unsupportedReason(cfg.kind, mode);
1133
+ if (why)
1134
+ throw new Error(`"${mode}" isn't available for ${cfg.kind}: ${why}`);
1135
+ // D38: a team-shared project can't go looser than loom.team.json allows.
1136
+ if (this.teamPolicy && cappedPermission(this.teamPolicy, mode, true) !== mode) {
1137
+ throw new Error(`team policy caps permissions at "${this.teamPolicy.permissions.ceiling}" on this repo (loom.team.json)`);
1138
+ }
1139
+ const live = this.agents.get(agentId);
1140
+ if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId))) {
1141
+ throw new Error(`"${agentId}" is mid-turn — wait for it to finish, then change its permissions`);
1142
+ }
1143
+ cfg.options = { ...(cfg.options ?? {}), permissions: mode };
1144
+ if (live) {
1145
+ void this.agentLifecycle.retire(agentId);
1146
+ }
1147
+ if (cfg.enabled !== false)
1148
+ this.spawnAgent(cfg);
1149
+ this.saveConfig();
1150
+ return cfg;
1151
+ }
331
1152
  /** Write the roster, without tripping our own staleness check. */
332
1153
  saveConfig() {
333
1154
  writeProjectConfig(this.info.dir, this.config);
@@ -335,6 +1156,279 @@ export class ProjectRuntime {
335
1156
  // schedule a pointless reload
336
1157
  this.configMtime = configMtimeOf(this.info.dir);
337
1158
  }
1159
+ // ── Skills (SKILL.md context blocks injected into the briefing) ──
1160
+ /**
1161
+ * Where skills live — every layout, not just ours.
1162
+ *
1163
+ * See core/skills.ts#discoverSkillRoots for the list and the precedence. This
1164
+ * used to be two hardcoded directories, which meant a machine with sixty
1165
+ * skills in `~/.claude` reported the one that shipped in this repo.
1166
+ */
1167
+ skillRoots() {
1168
+ return discoverSkillRoots(this.info.dir, path.join(process.cwd(), "skills"));
1169
+ }
1170
+ /** The directory this project's own skills are installed into. */
1171
+ ownSkillsDir() {
1172
+ return path.join(this.info.dir, "skills");
1173
+ }
1174
+ /** All available skills with this project's enabled state. */
1175
+ getSkills() {
1176
+ return loadSkills(this.skillRoots(), this.config.skills ?? {});
1177
+ }
1178
+ /**
1179
+ * The catalog the picker renders: every discoverable skill, without the body.
1180
+ *
1181
+ * The bodies are the entire point of a skill and also the reason this exists
1182
+ * separately from `getSkills()` — a machine with sixty installed skills has
1183
+ * megabytes of markdown, and a list screen needs none of it.
1184
+ *
1185
+ * `installed` means "this file is inside the project directory", which is the
1186
+ * same question `removeSkill` asks: those are the only ones Loom put there
1187
+ * and the only ones it may delete.
1188
+ */
1189
+ skillsCatalog() {
1190
+ const own = this.ownSkillsDir();
1191
+ return this.getSkills().map((s) => ({
1192
+ id: s.id,
1193
+ name: s.name,
1194
+ description: s.description,
1195
+ enabled: s.enabled,
1196
+ origin: s.origin,
1197
+ source: s.source,
1198
+ installed: path.resolve(s.source) === path.resolve(own),
1199
+ }));
1200
+ }
1201
+ /**
1202
+ * Install a skill from a local directory or a git remote, into this project.
1203
+ *
1204
+ * Throws SkillInstallError for anything the user can fix (bad URL, unsafe id,
1205
+ * a name that already exists) — the route turns those into a 400 with the
1206
+ * message, because "invalid input" tells you nothing when the real answer is
1207
+ * "that repo has no SKILL.md in it".
1208
+ */
1209
+ async installSkill(input) {
1210
+ const gitUrl = String(input.gitUrl ?? "").trim();
1211
+ const dir = String(input.dir ?? "").trim();
1212
+ if (gitUrl && dir)
1213
+ throw new SkillInstallError("give either gitUrl or dir, not both");
1214
+ if (gitUrl)
1215
+ return installSkillFromGit(gitUrl, this.info.dir, { force: input.force === true });
1216
+ if (dir)
1217
+ return installSkillFromDir(dir, this.info.dir, { force: input.force === true });
1218
+ throw new SkillInstallError("nothing to install — pass gitUrl or dir");
1219
+ }
1220
+ /**
1221
+ * Delete a project-installed skill from disk.
1222
+ *
1223
+ * Refused for anything outside the project. A skill in `~/.claude/skills` is
1224
+ * the user's, shared with every other tool they run, and a project-scoped
1225
+ * "remove" button that reached out and deleted it would be a data-loss bug
1226
+ * dressed as a feature. The refusal says where the skill actually lives so
1227
+ * the answer ("go delete it yourself, here") is in the message.
1228
+ *
1229
+ * The enabled flag goes with it: leaving `skills[id] = true` in config for a
1230
+ * directory that no longer exists means the next turn's briefing silently
1231
+ * loses a block the config still claims is on.
1232
+ */
1233
+ removeSkill(id) {
1234
+ const skill = this.getSkills().find((s) => s.id === id);
1235
+ if (!skill)
1236
+ throw new SkillInstallError(`no skill "${id}"`);
1237
+ const own = this.ownSkillsDir();
1238
+ if (path.resolve(skill.source) !== path.resolve(own)) {
1239
+ throw new SkillInstallError(`"${id}" lives in ${skill.source}, outside this project — Loom didn't install it and won't delete it`);
1240
+ }
1241
+ const dir = path.join(own, skill.id);
1242
+ fs.rmSync(dir, { recursive: true, force: true });
1243
+ if (this.config.skills?.[skill.id])
1244
+ this.setSkillEnabled(skill.id, false);
1245
+ return { removed: true, id: skill.id, path: dir };
1246
+ }
1247
+ /**
1248
+ * Switch one agent off (or back on) without taking it out of the roster.
1249
+ *
1250
+ * Refused while it holds the baton or is mid-turn, for the same reason
1251
+ * `removeAgent` is: stopping it there would strand the lock on something that
1252
+ * no longer exists, and the thread would show a turn nothing is running. The
1253
+ * refusal names the fix rather than just saying no.
1254
+ *
1255
+ * Off really means off — the agent is stopped and dropped from the live map,
1256
+ * not merely hidden. A roster entry that still answers is the kind of "off"
1257
+ * that costs money.
1258
+ */
1259
+ setAgentEnabled(agentId, enabled) {
1260
+ const cfg = this.config.agents.find((a) => a.id === agentId);
1261
+ if (!cfg)
1262
+ throw new Error(`unknown agent "${agentId}"`);
1263
+ const on = enabled !== false;
1264
+ if (!on) {
1265
+ if (this.validHolder() === agentId) {
1266
+ throw new Error(`"${agentId}" holds the baton — hand it off before switching it off`);
1267
+ }
1268
+ const live = this.agents.get(agentId);
1269
+ if (live && isAdapter(live) && (live.busy() || this.turns.busySince.has(agentId))) {
1270
+ throw new Error(`"${agentId}" is mid-turn — interrupt it first`);
1271
+ }
1272
+ }
1273
+ cfg.enabled = on;
1274
+ this.saveConfig();
1275
+ const live = this.agents.get(agentId);
1276
+ if (on && !live) {
1277
+ this.spawnAgent(cfg); // bring it back to life
1278
+ }
1279
+ else if (!on && live) {
1280
+ void this.agentLifecycle.retire(agentId);
1281
+ }
1282
+ this.log.append({ kind: on ? "agent_join" : "agent_leave", agentId, payload: { enabled: on } });
1283
+ return { id: agentId, enabled: on };
1284
+ }
1285
+ /** Enable/disable one skill for this project; returns the new enabled map. */
1286
+ setSkillEnabled(id, on) {
1287
+ const skills = { ...(this.config.skills ?? {}) };
1288
+ if (on)
1289
+ skills[id] = true;
1290
+ else
1291
+ delete skills[id];
1292
+ this.config.skills = skills;
1293
+ this.saveConfig();
1294
+ return skills;
1295
+ }
1296
+ /** The ACTIVE SKILLS block to prepend to a briefing, or "" when none are on. */
1297
+ activeSkillsBlock() {
1298
+ return buildSkillsBlock(this.getSkills());
1299
+ }
1300
+ // ── MCP servers ──
1301
+ static DEFAULT_MCPS = [
1302
+ { name: "GitHub", url: "", description: "issues, PRs, code search", icon: "github" },
1303
+ { name: "Supabase", url: "", description: "query, schema, migrations", icon: "database" },
1304
+ { name: "SigNoz", url: "", description: "traces, metrics, alerts", icon: "chart" },
1305
+ { name: "Linear", url: "", description: "issues, projects, cycles", icon: "linear" },
1306
+ { name: "Slack", url: "", description: "messages, channels, users", icon: "slack" },
1307
+ { name: "Filesystem", url: "", description: "read/write local files", icon: "folder" },
1308
+ ];
1309
+ /** Configured MCP servers, merged over the built-in suggestions (deduped by name). */
1310
+ getMcps() {
1311
+ const saved = this.config.mcps ?? [];
1312
+ const byName = new Map(ProjectRuntime.DEFAULT_MCPS.map((m) => [m.name, { ...m }]));
1313
+ for (const m of saved)
1314
+ byName.set(m.name, { ...(byName.get(m.name) ?? {}), ...m });
1315
+ return [...byName.values()];
1316
+ }
1317
+ /**
1318
+ * The same list, with `connected` MEASURED rather than assumed.
1319
+ *
1320
+ * The old reading of that field was "a url is typed into this row", which the
1321
+ * UI rendered as a green "connected" badge — a claim about a live connection
1322
+ * made without ever opening one. Here every configured URL gets a bounded
1323
+ * probe (see core/mcp.ts) and the answer is whatever came back. Rows with no
1324
+ * URL aren't probed and report false, because "not configured" is not
1325
+ * "connected".
1326
+ */
1327
+ // -------------------------------------------------------------------------
1328
+ // MCP health
1329
+ // -------------------------------------------------------------------------
1330
+ /**
1331
+ * Live health per configured MCP server, from the background poll.
1332
+ *
1333
+ * A server that died used to stay listed as connected, and its tools failed
1334
+ * silently mid-turn — the agent just found them gone. The poll notices the
1335
+ * transition, says so in the Console, and while a server is down it is left
1336
+ * out of turn injection entirely: an absent tool the agent never saw beats a
1337
+ * present tool that throws. When the server answers again it is included
1338
+ * again automatically — that is the whole reconnect story for per-turn
1339
+ * config files; there is no persistent connection to rebuild.
1340
+ */
1341
+ mcpHealth = new Map();
1342
+ mcpTimer = null;
1343
+ /** Poll every configured server once; log the transitions. */
1344
+ async pollMcpHealth(timeoutMs = 2_000) {
1345
+ const mcps = this.getMcps().filter((m) => m.enabledForSession !== false);
1346
+ for (const m of mcps) {
1347
+ const url = String(m.url ?? "").trim();
1348
+ if (!url)
1349
+ continue; // stdio servers have no probe-able endpoint
1350
+ const up = await probeMcpServer(url, timeoutMs).catch(() => false);
1351
+ const prev = this.mcpHealth.get(m.name);
1352
+ const failures = up ? 0 : (prev?.failures ?? 0) + 1;
1353
+ this.mcpHealth.set(m.name, { up, failures, probedAt: Date.now() });
1354
+ if (prev && prev.up && !up) {
1355
+ logbook.error("mcp", `"${m.name}" stopped answering — its tools are withheld from turns until it returns`, url, this.info.id);
1356
+ }
1357
+ else if (prev && !prev.up && up) {
1358
+ logbook.info("mcp", `"${m.name}" is back — its tools rejoin the next turn`, undefined, this.info.id);
1359
+ }
1360
+ }
1361
+ // Forget servers that were removed from config.
1362
+ const names = new Set(mcps.map((m) => m.name));
1363
+ for (const k of [...this.mcpHealth.keys()])
1364
+ if (!names.has(k))
1365
+ this.mcpHealth.delete(k);
1366
+ }
1367
+ mcpHealthReport() {
1368
+ return Object.fromEntries(this.mcpHealth);
1369
+ }
1370
+ /**
1371
+ * The servers a turn should carry: everything not known-down.
1372
+ *
1373
+ * Unknown (never probed — a fresh daemon, a just-added server) passes
1374
+ * through; refusing a server nobody has measured would block first use on a
1375
+ * poll that hasn't run yet. Only a measured, repeated failure withholds.
1376
+ */
1377
+ healthyMcps() {
1378
+ return (this.config.mcps ?? []).filter((m) => {
1379
+ const h = this.mcpHealth.get(m.name);
1380
+ return !h || h.up || h.failures < 2;
1381
+ });
1382
+ }
1383
+ startMcpHealthLoop(intervalMs = Number(process.env.LOOM_MCP_POLL_MS) || 60_000) {
1384
+ if (this.mcpTimer || !(this.config.mcps ?? []).length)
1385
+ return;
1386
+ this.mcpTimer = setInterval(() => {
1387
+ void this.pollMcpHealth().catch(() => { });
1388
+ }, intervalMs);
1389
+ this.mcpTimer.unref?.();
1390
+ }
1391
+ async getMcpsProbed(timeoutMs = 2_000) {
1392
+ const mcps = this.getMcps();
1393
+ const reachable = await probeMcpServers(mcps, timeoutMs).catch(() => ({}));
1394
+ const probedAt = Date.now();
1395
+ return mcps.map((m) => ({
1396
+ ...m,
1397
+ connected: reachable[m.name] ?? false,
1398
+ ...(m.name in reachable ? { probedAt } : {}),
1399
+ }));
1400
+ }
1401
+ /** Add or update one MCP (by name); persists only the real (non-default) fields. */
1402
+ upsertMcp(mcp) {
1403
+ const saved = (this.config.mcps ?? []).filter((m) => m.name !== mcp.name);
1404
+ saved.push(mcp);
1405
+ this.config.mcps = saved;
1406
+ this.saveConfig();
1407
+ return this.getMcps();
1408
+ }
1409
+ /**
1410
+ * Remove a configured MCP server by name.
1411
+ *
1412
+ * `removed` is false when nothing was configured under that name, which the
1413
+ * route answers with a 404 rather than a cheerful 200 — "deleted a thing that
1414
+ * wasn't there" is the kind of success that hides a typo in a server name.
1415
+ *
1416
+ * Note what this does to a name that is also one of the built-in suggestion
1417
+ * rows (GitHub, Slack, …): the *configuration* goes, and the suggestion comes
1418
+ * back with an empty url, because those rows aren't installed servers, they're
1419
+ * placeholders getMcps() merges in. That is the honest outcome — the server is
1420
+ * gone, and what's left is an offer to add one — but a caller diffing the list
1421
+ * for the name will still find it, so it should compare `url`/`command`.
1422
+ */
1423
+ removeMcp(name) {
1424
+ const saved = this.config.mcps ?? [];
1425
+ const kept = saved.filter((m) => m.name !== name);
1426
+ if (kept.length === saved.length)
1427
+ return { removed: false, mcps: this.getMcps() };
1428
+ this.config.mcps = kept;
1429
+ this.saveConfig();
1430
+ return { removed: true, mcps: this.getMcps() };
1431
+ }
338
1432
  // -------------------------------------------------------------------------
339
1433
  // Chats — several conversations, one brain
340
1434
  // -------------------------------------------------------------------------
@@ -346,51 +1440,263 @@ export class ProjectRuntime {
346
1440
  */
347
1441
  chats() {
348
1442
  const stored = readProjectState(this.info.dir).chats ?? [];
349
- return [
350
- { id: MAIN_CHAT, title: "Main", createdAt: 0 },
351
- ...stored.filter((c) => c.id !== MAIN_CHAT),
352
- ];
1443
+ let last;
1444
+ try {
1445
+ last = this.log.lastReplyIds();
1446
+ }
1447
+ catch {
1448
+ last = new Map(); // an unread dot is a nicety; the list must still load
1449
+ }
1450
+ const main = stored.find((c) => c.id === MAIN_CHAT);
1451
+ const groups = this.chatGroups();
1452
+ return this.conversations.chats().map((c) => {
1453
+ // main is stored only once it has stars or ratings to keep
1454
+ const withMain = c.id === MAIN_CHAT && main
1455
+ ? { ...c, ...(main.starred ? { starred: main.starred } : {}), ...(main.ratings ? { ratings: main.ratings } : {}) }
1456
+ : c;
1457
+ const g = c.id === MAIN_CHAT ? undefined : groups.get(c.id);
1458
+ const withGroup = g ? { ...withMain, group: g } : withMain;
1459
+ return last.has(c.id) ? { ...withGroup, lastReplyId: last.get(c.id) } : withGroup;
1460
+ });
353
1461
  }
354
- createChat(title) {
355
- const state = readProjectState(this.info.dir);
356
- const chat = {
357
- id: newId(4),
358
- // numbered, not "New chat" — the button already says New chat, and a
359
- // sidebar of identical rows tells you nothing
360
- title: title.trim().slice(0, 60) || `Chat ${(state.chats ?? []).length + 2}`,
361
- createdAt: Date.now(),
362
- };
363
- state.chats = [...(state.chats ?? []), chat];
364
- writeProjectState(this.info.dir, state);
365
- return chat;
1462
+ /** Which run, race, crew or import made each thread (ChatInfo.group). */
1463
+ chatGroups() {
1464
+ const out = new Map();
1465
+ try {
1466
+ for (const run of this.orchestra.list()) {
1467
+ const g = { kind: run.race ? "race" : "orchestra", id: run.id, title: run.goal, status: run.status, at: run.createdAt };
1468
+ // a run that answered in a thread you already had leaves that thread alone
1469
+ if (!run.inPlace && run.chat)
1470
+ out.set(run.chat, g);
1471
+ for (const t of run.tasks)
1472
+ if (t.chat)
1473
+ out.set(t.chat, g);
1474
+ }
1475
+ }
1476
+ catch { /* no runs yet */ }
1477
+ try {
1478
+ for (const crew of this.crews.list()) {
1479
+ const g = { kind: "crew", id: crew.id, title: crew.name, ...(crew.state.goal ? { status: crew.state.goal.status } : {}), at: crew.state.goal?.startedAt ?? 0 };
1480
+ if (crew.state.channel)
1481
+ out.set(crew.state.channel, g);
1482
+ for (const chat of Object.values(crew.state.threads ?? {}))
1483
+ out.set(chat, g);
1484
+ }
1485
+ }
1486
+ catch { /* no crews yet */ }
1487
+ for (const chat of Object.values(readProjectState(this.info.dir).imports ?? {})) {
1488
+ if (!out.has(chat))
1489
+ out.set(chat, { kind: "import", id: "imports", title: "Imported", at: 0 });
1490
+ }
1491
+ return out;
366
1492
  }
367
- renameChat(id, title) {
1493
+ /** Pin, archive or file a thread. Main is always first and always there. */
1494
+ setChatFlags(id, flags) {
368
1495
  if (id === MAIN_CHAT)
369
- return null; // main's name is not yours to change
1496
+ return null;
370
1497
  const state = readProjectState(this.info.dir);
371
1498
  const chat = (state.chats ?? []).find((c) => c.id === id);
372
1499
  if (!chat)
373
1500
  return null;
374
- chat.title = title.trim().slice(0, 60) || chat.title;
1501
+ for (const k of ["pinned", "archived"]) {
1502
+ if (flags[k] === undefined)
1503
+ continue;
1504
+ if (flags[k])
1505
+ chat[k] = true;
1506
+ else
1507
+ delete chat[k];
1508
+ }
1509
+ if (flags.folder !== undefined) {
1510
+ const name = String(flags.folder ?? "").replace(/\s+/g, " ").trim().slice(0, 40);
1511
+ if (name)
1512
+ chat.folder = name;
1513
+ else
1514
+ delete chat.folder;
1515
+ }
375
1516
  writeProjectState(this.info.dir, state);
376
1517
  return chat;
377
1518
  }
1519
+ /** Rate a reply up or down (0 clears). Main included. */
1520
+ rateMessage(chatId, eventId, agent, v) {
1521
+ if (!Number.isInteger(eventId) || eventId <= 0 || !agent)
1522
+ return null;
1523
+ const state = readProjectState(this.info.dir);
1524
+ state.chats = state.chats ?? [];
1525
+ let chat = state.chats.find((c) => c.id === chatId);
1526
+ if (!chat) {
1527
+ if (chatId !== MAIN_CHAT)
1528
+ return null;
1529
+ chat = { id: MAIN_CHAT, title: "Main", createdAt: 0 };
1530
+ state.chats.push(chat);
1531
+ }
1532
+ const ratings = { ...(chat.ratings ?? {}) };
1533
+ if (v === 1 || v === -1)
1534
+ ratings[String(eventId)] = { v, agent };
1535
+ else
1536
+ delete ratings[String(eventId)];
1537
+ const keys = Object.keys(ratings);
1538
+ for (const k of keys.slice(0, Math.max(0, keys.length - 500)))
1539
+ delete ratings[k]; // bounded
1540
+ if (Object.keys(ratings).length)
1541
+ chat.ratings = ratings;
1542
+ else
1543
+ delete chat.ratings;
1544
+ writeProjectState(this.info.dir, state);
1545
+ return chat.ratings ?? {};
1546
+ }
1547
+ /** Every rating in every thread, per agent: how many up, how many down. */
1548
+ ratingsByAgent() {
1549
+ const out = {};
1550
+ for (const c of readProjectState(this.info.dir).chats ?? []) {
1551
+ for (const r of Object.values(c.ratings ?? {})) {
1552
+ const a = (out[r.agent] ??= { up: 0, down: 0 });
1553
+ if (r.v === 1)
1554
+ a.up++;
1555
+ else
1556
+ a.down++;
1557
+ }
1558
+ }
1559
+ return out;
1560
+ }
1561
+ /** Star or unstar a message in a thread (main included). */
1562
+ starMessage(chatId, eventId, on) {
1563
+ if (!Number.isInteger(eventId) || eventId <= 0)
1564
+ return null;
1565
+ const state = readProjectState(this.info.dir);
1566
+ state.chats = state.chats ?? [];
1567
+ let chat = state.chats.find((c) => c.id === chatId);
1568
+ if (!chat) {
1569
+ if (chatId !== MAIN_CHAT)
1570
+ return null;
1571
+ // Main isn't stored until it has something to remember
1572
+ chat = { id: MAIN_CHAT, title: "Main", createdAt: 0 };
1573
+ state.chats.push(chat);
1574
+ }
1575
+ const set = (chat.starred ?? []).filter((n) => n !== eventId);
1576
+ if (on)
1577
+ set.push(eventId);
1578
+ chat.starred = set.slice(-200);
1579
+ if (!chat.starred.length)
1580
+ delete chat.starred;
1581
+ writeProjectState(this.info.dir, state);
1582
+ return chat.starred ?? [];
1583
+ }
1584
+ createChat(title, opts = {}) {
1585
+ const bound = opts.agentId ? this.bindable(opts.agentId, opts.model) : {};
1586
+ return this.conversations.createChat(title, bound);
1587
+ }
378
1588
  /**
379
- * Forget a conversation. Its events stay in the log — it's append-only, and
380
- * the brain is built from all of them; deleting the thread you had with an
381
- * agent shouldn't quietly rewrite what the project decided. The chat just
382
- * stops being listed.
1589
+ * May this thread be bound to this agent (and model)?
1590
+ *
1591
+ * A model bound to an agent that bakes its model into a spawned process
1592
+ * would be a setting that silently did nothing, so it is refused here —
1593
+ * where the person can act on it — rather than dropped at send time.
383
1594
  */
1595
+ bindable(agentId, model) {
1596
+ const agent = this.agents.get(agentId);
1597
+ if (!agent)
1598
+ throw new Error(`no agent "${agentId}" in this project`);
1599
+ if (!isAdapter(agent))
1600
+ throw new Error(`"${agentId}" is a bridge — it can't answer in a thread`);
1601
+ if (model && !this.switchesModelPerTurn(agentId)) {
1602
+ throw new Error(`"${agentId}" can't change model per thread — pin the model on the agent instead (loom model ${agentId} ${model}), or use a "model" agent`);
1603
+ }
1604
+ return { agentId, ...(model ? { model } : {}) };
1605
+ }
1606
+ /** Can this agent be handed a different model for one turn? */
1607
+ switchesModelPerTurn(agentId) {
1608
+ const kind = this.config.agents.find((a) => a.id === agentId)?.kind;
1609
+ return kind === "model" || Boolean(this.continuity && kind && isNativeKind(kind));
1610
+ }
1611
+ /** Bind (or unbind) who answers in a thread. */
1612
+ setChatAgent(id, agentId, model) {
1613
+ // Preserve Main's error before validating an optional target.
1614
+ if (id !== MAIN_CHAT && agentId)
1615
+ this.bindable(agentId, model);
1616
+ return this.conversations.setChatAgent(id, agentId, model);
1617
+ }
1618
+ /**
1619
+ * Ask several models the same thing at once, each in its own thread.
1620
+ *
1621
+ * With free quota, asking five models costs what asking one costs, and
1622
+ * "which of these is right" is a judgement a person makes in ten seconds.
1623
+ * So: one prompt, one thread per model, all running at the same time.
1624
+ *
1625
+ * The agents are TRANSIENT — built for the ask, not added to the roster.
1626
+ * Adding five agents to .loom/config.json to ask five questions would leave
1627
+ * the project's roster as a record of everything anyone ever compared. What
1628
+ * stays behind is the threads, which are the part worth keeping.
1629
+ */
1630
+ async askModels(text, picks, opts = {}) {
1631
+ if (!picks.length)
1632
+ throw new Error("name at least one model");
1633
+ if (picks.length > MAX_FANOUT) {
1634
+ throw new Error(`that's ${picks.length} models — ${MAX_FANOUT} at a time is the limit`);
1635
+ }
1636
+ const brief = opts.briefing === false ? "" : await this.brainBriefFor({ query: text, limit: 6 });
1637
+ const started = [];
1638
+ for (const pick of picks) {
1639
+ const provider = pick.provider ?? "openrouter";
1640
+ // The model IS the name here: a thread labelled "ask-3" tells you
1641
+ // nothing, and this is a view where which model said what is the point.
1642
+ const short = pick.model.split("/").pop() ?? pick.model;
1643
+ const chat = this.createChat(`${opts.title ?? "ask"} · ${short}`.slice(0, 60));
1644
+ const agentId = `ask:${pick.model}`;
1645
+ this.log.append({
1646
+ kind: "message",
1647
+ chat: chat.id,
1648
+ payload: { text, author: "user", ask: { model: pick.model, provider } },
1649
+ });
1650
+ const agent = new ModelAdapter(agentId, this.info.dir, { provider, model: pick.model });
1651
+ // Its events land in the log tagged with its own thread, exactly as a
1652
+ // roster agent's do — which is what makes the answers readable later.
1653
+ const offLive = agent.onStream((d) => this.liveText({ agentId, chat: chat.id, ...d }));
1654
+ const offLog = agent.onEvent((e) => {
1655
+ this.appendIfOpen({ ...e, agentId, chat: chat.id });
1656
+ });
1657
+ const off = () => {
1658
+ offLive();
1659
+ offLog();
1660
+ };
1661
+ void agent
1662
+ .send({ text, ...(brief ? { briefing: brief } : {}) })
1663
+ .catch((err) => {
1664
+ this.appendIfOpen({
1665
+ kind: "error",
1666
+ agentId,
1667
+ chat: chat.id,
1668
+ payload: { message: String(err instanceof Error ? err.message : err) },
1669
+ });
1670
+ })
1671
+ .finally(() => off());
1672
+ started.push({ chat: chat.id, model: pick.model, provider, agentId });
1673
+ }
1674
+ this.log.append({
1675
+ kind: "status",
1676
+ payload: { state: "asked_models", models: picks.map((p) => p.model), chats: started.map((s) => s.chat) },
1677
+ });
1678
+ return started;
1679
+ }
1680
+ /** What a thread has pinned, if anything. */
1681
+ chatBinding(chat) {
1682
+ if (!chat || chat === MAIN_CHAT)
1683
+ return {};
1684
+ const found = this.chats().find((c) => c.id === chat);
1685
+ if (!found?.agentId)
1686
+ return {};
1687
+ // A thread pinned to an agent that has since left the roster falls back to
1688
+ // the baton rather than failing: the conversation is still readable, and
1689
+ // the alternative is a thread nobody can type in.
1690
+ if (!this.agents.has(found.agentId))
1691
+ return {};
1692
+ return { agentId: found.agentId, ...(found.model ? { model: found.model } : {}) };
1693
+ }
1694
+ renameChat(id, title) { return this.conversations.renameChat(id, title); }
384
1695
  deleteChat(id) {
385
- if (id === MAIN_CHAT)
386
- return false; // there is always a main chat
387
- const state = readProjectState(this.info.dir);
388
- const before = (state.chats ?? []).length;
389
- state.chats = (state.chats ?? []).filter((c) => c.id !== id);
390
- if (state.chats.length === before)
391
- return false;
392
- writeProjectState(this.info.dir, state);
393
- return true;
1696
+ if (this.continuity && ([...this.turns.busySince.keys()].some(agent => this.turns.turnChat.get(agent) === id) ||
1697
+ this.queue.snapshot().items.some(item => item.chat === id)))
1698
+ throw new ContinuityError("conflict", "finish or remove queued/native turns before deleting their chat");
1699
+ return this.conversations.deleteChat(id);
394
1700
  }
395
1701
  // -------------------------------------------------------------------------
396
1702
  // Board tasks — the cards you write yourself
@@ -400,32 +1706,175 @@ export class ProjectRuntime {
400
1706
  }
401
1707
  createTask(input) {
402
1708
  const state = readProjectState(this.info.dir);
1709
+ const blockedBy = this.validBlockers(state.tasks ?? [], input.blockedBy);
403
1710
  const task = {
404
1711
  id: newId(4),
405
1712
  title: input.title.trim().slice(0, 200),
406
1713
  column: input.column ?? "working",
407
1714
  ...(input.agent ? { agent: input.agent } : {}),
1715
+ ...(blockedBy.length ? { blockedBy } : {}),
408
1716
  createdAt: Date.now(),
409
1717
  };
1718
+ applyCardMeta(task, input);
410
1719
  state.tasks = [...(state.tasks ?? []), task];
411
1720
  writeProjectState(this.info.dir, state);
412
1721
  return task;
413
1722
  }
1723
+ /** Only blockers that exist. A link to a deleted card blocks nothing forever. */
1724
+ validBlockers(tasks, ids) {
1725
+ if (!ids?.length)
1726
+ return [];
1727
+ const known = new Set(tasks.map((t) => t.id));
1728
+ return [...new Set(ids)].filter((id) => known.has(id));
1729
+ }
1730
+ /**
1731
+ * A card is blocked while any of its blockers is not yet `ready`.
1732
+ *
1733
+ * `ready` is the board's own definition of done — the last column. Deleted
1734
+ * blockers don't count (validBlockers keeps them out, and a stale link that
1735
+ * survived a race reads as done rather than blocking forever).
1736
+ */
1737
+ taskBlockers(id) {
1738
+ const tasks = readProjectState(this.info.dir).tasks ?? [];
1739
+ const task = tasks.find((t) => t.id === id);
1740
+ if (!task?.blockedBy?.length)
1741
+ return [];
1742
+ const byId = new Map(tasks.map((t) => [t.id, t]));
1743
+ return task.blockedBy
1744
+ .map((bid) => byId.get(bid))
1745
+ .filter((b) => Boolean(b) && b.column !== "ready")
1746
+ .map((b) => ({ id: b.id, title: b.title, column: b.column }));
1747
+ }
1748
+ /** task/<id>-<slug>: stable id first so a retitle doesn't orphan the branch. */
1749
+ /**
1750
+ * What opening a PR for this card would push, and what it would run.
1751
+ *
1752
+ * Asked before anything happens, because pushing publishes: the person sees
1753
+ * the branch, the commits and the exact command, and only then decides.
1754
+ */
1755
+ async taskPrPlan(id) {
1756
+ const task = (readProjectState(this.info.dir).tasks ?? []).find((t) => t.id === id);
1757
+ if (!task)
1758
+ throw new Error(`no card "${id}"`);
1759
+ const branch = this.taskBranchName(task);
1760
+ const base = (await readOut(this.info.dir, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]))
1761
+ .replace(/^origin\//, "")
1762
+ .trim() || "main";
1763
+ const exists = await readOut(this.info.dir, ["rev-parse", "--verify", "--quiet", branch]);
1764
+ if (!exists.trim()) {
1765
+ return { branch, base, commits: [], files: [], command: "", ready: false, why: `there's no ${branch} branch yet` };
1766
+ }
1767
+ const log = await readOut(this.info.dir, ["log", "--oneline", `${base}..${branch}`]);
1768
+ const commits = log.split("\n").map((l) => l.trim()).filter(Boolean);
1769
+ const diff = await readOut(this.info.dir, ["diff", "--name-only", `${base}...${branch}`]);
1770
+ const files = diff.split("\n").map((l) => l.trim()).filter(Boolean);
1771
+ const command = `gh pr create --head ${branch} --base ${base} --title ${JSON.stringify(task.title)} --body ""`;
1772
+ return {
1773
+ branch,
1774
+ base,
1775
+ commits,
1776
+ files,
1777
+ command,
1778
+ ready: commits.length > 0,
1779
+ ...(commits.length ? {} : { why: `${branch} has nothing ${base} doesn't` }),
1780
+ };
1781
+ }
1782
+ /** Push the branch and open the PR — only ever from an explicit click. */
1783
+ async openTaskPr(id) {
1784
+ const task = (readProjectState(this.info.dir).tasks ?? []).find((t) => t.id === id);
1785
+ if (!task)
1786
+ throw new Error(`no card "${id}"`);
1787
+ const plan = await this.taskPrPlan(id);
1788
+ if (!plan.ready)
1789
+ throw new Error(plan.why ?? "there's nothing to open a PR for");
1790
+ await readOut(this.info.dir, ["push", "-u", "origin", plan.branch]);
1791
+ const out = await readOut(this.info.dir, [], {
1792
+ cmd: "gh",
1793
+ args: ["pr", "create", "--head", plan.branch, "--base", plan.base, "--title", task.title, "--body", ""],
1794
+ });
1795
+ const url = (out.match(/https:\/\/\S+/) ?? [""])[0];
1796
+ if (!url)
1797
+ throw new Error(out.trim().slice(0, 300) || "gh didn't return a PR url");
1798
+ this.appendIfOpen({ kind: "status", payload: { state: "task_pr", task: task.id, branch: plan.branch, url } });
1799
+ return { url, branch: plan.branch };
1800
+ }
1801
+ taskBranchName(task) {
1802
+ const slug = task.title
1803
+ .toLowerCase()
1804
+ .replace(/[^a-z0-9]+/g, "-")
1805
+ .replace(/^-+|-+$/g, "")
1806
+ .slice(0, 40);
1807
+ return `task/${task.id}${slug ? `-${slug}` : ""}`;
1808
+ }
414
1809
  /** Move or retitle a card. Yours, so this is the real state — not a hint. */
415
1810
  updateTask(id, patch) {
416
1811
  const state = readProjectState(this.info.dir);
417
1812
  const task = (state.tasks ?? []).find((t) => t.id === id);
418
1813
  if (!task)
419
1814
  return null;
1815
+ applyCardMeta(task, patch);
1816
+ // Opt-in: dragging a card to Working checks out its branch; reaching
1817
+ // Review logs the PR command rather than running it — pushing publishes,
1818
+ // and publishing implicitly is a line Loom doesn't cross even under a
1819
+ // flag. The branch name leads with the stable id so a retitle doesn't
1820
+ // orphan it. Failures (not a repo, dirty tree) land in the Console; the
1821
+ // drag itself always succeeds — the board must not refuse to reflect
1822
+ // reality because git had opinions.
1823
+ if (this.config.git?.branchPerTask && patch.column && patch.column !== task.column) {
1824
+ const branch = this.taskBranchName(task);
1825
+ if (patch.column === "working") {
1826
+ void ensureBranch(this.info.dir, branch)
1827
+ .then(({ created }) => this.appendIfOpen({
1828
+ kind: "status",
1829
+ payload: { state: "task_branch", task: task.id, branch, created },
1830
+ }))
1831
+ .catch((err) => logbook.warn("git", `couldn't switch to ${branch}`, String(err), this.info.id));
1832
+ }
1833
+ else if (patch.column === "in-review") {
1834
+ logbook.info("git", `"${task.title}" reached review — open the PR with: gh pr create --head ${branch}`, undefined, this.info.id);
1835
+ }
1836
+ }
420
1837
  if (patch.title !== undefined)
421
1838
  task.title = patch.title.trim().slice(0, 200) || task.title;
422
1839
  if (patch.column !== undefined)
423
1840
  task.column = patch.column;
424
1841
  if (patch.agent !== undefined)
425
1842
  task.agent = patch.agent;
1843
+ for (const k of ["crew", "goal", "stage", "claimedBy"])
1844
+ if (patch[k] !== undefined)
1845
+ task[k] = String(patch[k]).slice(0, 80);
1846
+ if (patch.blockedBy !== undefined) {
1847
+ // Cycles refused at write: A→B→A makes both unbecomable forever, and the
1848
+ // person who typed it is the one who can pick which link was wrong.
1849
+ const next = this.validBlockers(state.tasks ?? [], patch.blockedBy).filter((b) => b !== id);
1850
+ if (this.wouldCycle(state.tasks ?? [], id, next)) {
1851
+ throw new Error("that dependency would make a cycle — nothing in it could ever start");
1852
+ }
1853
+ if (next.length)
1854
+ task.blockedBy = next;
1855
+ else
1856
+ delete task.blockedBy;
1857
+ }
426
1858
  writeProjectState(this.info.dir, state);
427
1859
  return task;
428
1860
  }
1861
+ /** Would `id` depending on `blockers` create a loop back to `id`? */
1862
+ wouldCycle(tasks, id, blockers) {
1863
+ const byId = new Map(tasks.map((t) => [t.id, t]));
1864
+ const stack = [...blockers];
1865
+ const seen = new Set();
1866
+ while (stack.length) {
1867
+ const cur = stack.pop();
1868
+ if (cur === id)
1869
+ return true;
1870
+ if (seen.has(cur))
1871
+ continue;
1872
+ seen.add(cur);
1873
+ for (const b of byId.get(cur)?.blockedBy ?? [])
1874
+ stack.push(b);
1875
+ }
1876
+ return false;
1877
+ }
429
1878
  deleteTask(id) {
430
1879
  const state = readProjectState(this.info.dir);
431
1880
  const before = (state.tasks ?? []).length;
@@ -437,6 +1886,10 @@ export class ProjectRuntime {
437
1886
  }
438
1887
  /** Any adapter mid-turn? (Hot reloads are deferred while work is in flight.) */
439
1888
  anyBusy() {
1889
+ // A live orchestra run counts: reloading the project would close it, and
1890
+ // closing aborts the run — a config edit must not kill a fleet mid-flight.
1891
+ if (this.orchestra.active() || this.routes.isActive() || this.turns.busySince.size > 0 || this.crews.list().some(c => c.busy))
1892
+ return true;
440
1893
  return [...this.agents.values()].some((a) => isAdapter(a) && a.busy());
441
1894
  }
442
1895
  agent(id) {
@@ -446,198 +1899,107 @@ export class ProjectRuntime {
446
1899
  return agent;
447
1900
  }
448
1901
  async ensureStarted(agentId) {
449
- const agent = this.agent(agentId);
450
- if (!this.startedAgents.has(agentId)) {
451
- await agent.start();
452
- this.startedAgents.add(agentId);
453
- }
454
- return agent;
1902
+ this.agent(agentId); // preserve the public unknown-agent error
1903
+ return this.agentLifecycle.start(agentId);
455
1904
  }
456
- /** Pre-turn porcelain snapshots, for per-prompt diff attribution. */
457
- preTurnTree = new Map();
458
- /** After a turn: log which files that prompt changed (turn_diff), then learn. */
459
- captureTurnDiff(agentId) {
460
- const before = this.preTurnTree.get(agentId);
461
- if (before === undefined) {
462
- // No snapshot (e.g. a turn with no pre-tree) — still worth reading.
463
- this.extractMemory(agentId, []);
464
- return;
1905
+ checkpoints() { return this.turns.checkpoints(); }
1906
+ async rewind(id) { return this.turns.rewind(id); }
1907
+ /** Put one file back as a checkpoint had it (see checkpoints.restoreFile). */
1908
+ async rewindFile(id, file) {
1909
+ const busy = [...this.turns.busySince.keys()];
1910
+ if (busy.length) {
1911
+ throw new Error(`${busy.join(", ")} ${busy.length === 1 ? "is" : "are"} mid-turn — stop the turn first, or the file changes underneath it`);
465
1912
  }
466
- this.preTurnTree.delete(agentId);
467
- void diffSinceSnapshot(this.info.dir, before)
468
- .then((diff) => {
469
- if (diff) {
470
- this.log.append({
471
- kind: "turn_diff",
472
- agentId,
473
- payload: {
474
- files: diff.files,
475
- added: diff.added,
476
- removed: diff.removed,
477
- patch: diff.patch,
478
- truncated: diff.truncated,
479
- },
480
- });
481
- }
482
- // Learn from the turn once we know which files it touched — the files
483
- // sharpen candidate retrieval. Runs after the diff so recentTurnFiles
484
- // isn't needed; the files are right here.
485
- this.extractMemory(agentId, (diff?.files ?? []).map((f) => f.path));
486
- })
487
- .catch(() => this.extractMemory(agentId, []));
1913
+ const out = await checkpoints.restoreFile(this.info.dir, id, file);
1914
+ this.log.append({
1915
+ kind: "checkpoint",
1916
+ payload: { id: out.restored.id, label: `${out.path} only`, at: Date.now(), reason: "rewound", files: 1, undo: out.undo.id, path: out.path },
1917
+ });
1918
+ return out;
488
1919
  }
489
- /**
490
- * Phase 2: read a finished turn for durable memory.
491
- *
492
- * Fire-and-forget on purpose. A slow or missing extractor must never delay
493
- * anything — extractFromTurn already swallows engine failures, and this is
494
- * void-ed so even an unexpected throw can't escape into the event pipeline.
495
- * Off entirely when config says so; a no-op when Claude isn't available.
496
- */
1920
+ async turnFacts(agentId) { return this.turns.turnFacts(agentId); }
1921
+ captureTurnDiff(agentId) { return this.turns.captureTurnDiff(agentId); }
1922
+ async commitTurn(agentId, files) { return this.turns.commitTurn(agentId, files); }
497
1923
  extractMemory(agentId, files) {
498
- if (this.config.brain?.extractor === "off")
499
- return;
500
- const chat = this.turnChat.get(agentId) ?? MAIN_CHAT;
501
- const turn = this.gatherTurnText(chat);
502
- if (turn.length < 40)
503
- return; // nothing substantial to learn from
504
- const model = this.config.brain?.model ?? "haiku";
505
- const engine = (p) => claudeText(`${p.system}\n\n${p.user}`, { model, timeoutMs: 60_000 });
506
- void extractFromTurn(this.brain, turn, {
507
- engine,
508
- agentId,
509
- chat,
510
- ...(files.length ? { files } : {}),
511
- eventId: this.log.lastId(),
512
- })
513
- .then((res) => {
514
- const learned = res.added.length + res.updated.length + res.forgotten.length;
515
- if (learned > 0) {
516
- this.log.append({
517
- kind: "status",
518
- payload: {
519
- state: "brain_extract",
520
- agentId,
521
- added: res.added.length,
522
- updated: res.updated.length,
523
- forgotten: res.forgotten.length,
524
- },
525
- });
526
- }
527
- })
528
- .catch(() => { });
529
- }
530
- /**
531
- * The transcript of the most recent turn in a chat: from the last human
532
- * message to now — the user's ask and what the agent did in reply.
533
- */
534
- gatherTurnText(chat) {
535
- const events = this.log.list({ chat, limit: 40 });
536
- let start = 0;
537
- for (let i = events.length - 1; i >= 0; i--) {
538
- if (events[i].kind === "message" && !events[i].agentId) {
539
- start = i;
540
- break;
541
- }
542
- }
543
- const lines = [];
544
- for (const e of events.slice(start)) {
545
- const p = e.payload;
546
- if (e.kind === "message") {
547
- lines.push(`${e.agentId ?? "user"}: ${String(p.text ?? "").slice(0, 2000)}`);
548
- }
549
- else if (e.kind === "tool_call") {
550
- lines.push(`[${e.agentId} used ${String(p.tool ?? "a tool")}] ${String(p.summary ?? "")}`.trim());
551
- }
552
- else if (e.kind === "file_edit") {
553
- lines.push(`[${e.agentId} edited ${String(p.path ?? "")}]`);
554
- }
555
- else if (e.kind === "decision") {
556
- lines.push(`decision: ${String(p.text ?? "")}`);
557
- }
558
- }
559
- return lines.join("\n").trim();
1924
+ if (!this.continuity)
1925
+ this.briefings.extractMemory(agentId, files);
560
1926
  }
561
1927
  workingTree() {
562
1928
  return workingTree(this.info.dir);
563
1929
  }
564
- // -------------------------------------------------------------------------
565
- // Unified memory — "multiple memory in one"
566
- // -------------------------------------------------------------------------
567
- /** Freshly read every connected ADE's native memory from disk. */
568
- importedMemory() {
569
- return readNativeMemory(this.info.dir, this.config);
570
- }
571
- /** The merged brain: decisions + imported ADE memories + shared context. */
572
- unifiedMemory() {
573
- return buildUnifiedMemory(this.info.name, this.log.list(), this.importedMemory());
574
- }
575
- /**
576
- * Phase 3: the brain brief for a handoff — the memories relevant to the work
577
- * in flight, compiled. Query is the recent conversation plus the files recent
578
- * turns touched; scoped to the incoming agent; low-confidence memories are
579
- * held back from injection (they stay visible in the Brain tab). Empty string
580
- * when there's nothing relevant, so callers append it unconditionally.
581
- */
582
- retrieveBrief(events, agentId) {
583
- const query = events
584
- .filter((e) => e.kind === "message")
585
- .slice(-8)
586
- .map((e) => String(e.payload.text ?? ""))
587
- .join(" ");
588
- const files = [
589
- ...new Set(events
590
- .filter((e) => e.kind === "turn_diff")
591
- .flatMap((e) => {
592
- // turn_diff stores ChangedFile[] ({status, path}); older events or
593
- // other shapes may carry bare strings. Normalise to paths.
594
- const raw = e.payload.files ?? [];
595
- return raw.map((f) => (typeof f === "string" ? f : (f?.path ?? ""))).filter(Boolean);
596
- })),
597
- ].slice(-20);
598
- if (!query.trim() && !files.length)
599
- return "";
600
- const hits = retrieve(this.brain, {
601
- ...(query.trim() ? { query } : {}),
602
- ...(files.length ? { files } : {}),
603
- agent: agentId,
604
- minConfidence: CONFIDENCE_FLOOR,
605
- limit: 14,
606
- });
607
- return compileBrief(hits.map((h) => h.memory));
608
- }
609
- /**
610
- * Pull each ADE's native memory into the shared log. Idempotent — a source
611
- * whose content hasn't changed since its last import is skipped, so this is
612
- * safe to call on connect, on demand, or on a timer.
613
- */
614
- importMemories() {
615
- const seen = new Map(); // file -> last imported hash
616
- for (const e of this.log.list({ kinds: ["memory_import"] })) {
617
- seen.set(String(e.payload.file), String(e.payload.hash));
618
- }
619
- const sources = [];
620
- let imported = 0;
621
- for (const block of this.importedMemory()) {
622
- const hash = hashContent(block.content);
623
- if (seen.get(block.file) === hash)
624
- continue;
625
- this.log.append({
626
- kind: "memory_import",
627
- agentId: block.agentId,
628
- payload: { file: block.file, kind: block.kind, chars: block.content.length, hash },
629
- });
630
- sources.push(block.file);
631
- imported += 1;
1930
+ unifiedMemory() { return this.briefings.unifiedMemory(); }
1931
+ brainBrief(opts) { return this.briefings.brainBrief(opts); }
1932
+ // How often each memory made it into a prompt — the Brain tab's "most
1933
+ // used". Counted where briefs are compiled, so it's what agents actually
1934
+ // got, not what a search happened to return. Written at most every few
1935
+ // seconds; a lost count on a crash costs nothing.
1936
+ memoryUse = null;
1937
+ memoryUseTimer = null;
1938
+ memoryUseFile() {
1939
+ return path.join(this.info.dir, ".loom", "memory-usage.json");
1940
+ }
1941
+ memoryUsage() {
1942
+ if (!this.memoryUse) {
1943
+ try {
1944
+ this.memoryUse = JSON.parse(fs.readFileSync(this.memoryUseFile(), "utf8"));
1945
+ }
1946
+ catch {
1947
+ this.memoryUse = {};
1948
+ }
632
1949
  }
633
- return { imported, sources };
1950
+ return this.memoryUse;
1951
+ }
1952
+ noteMemoriesUsed(ids) {
1953
+ if (!ids.length)
1954
+ return;
1955
+ const use = this.memoryUsage();
1956
+ const now = Date.now();
1957
+ for (const id of new Set(ids))
1958
+ use[id] = { n: (use[id]?.n ?? 0) + 1, at: now };
1959
+ if (this.memoryUseTimer)
1960
+ return;
1961
+ this.memoryUseTimer = setTimeout(() => {
1962
+ this.memoryUseTimer = null;
1963
+ try {
1964
+ fs.writeFileSync(this.memoryUseFile(), JSON.stringify(this.memoryUse));
1965
+ }
1966
+ catch {
1967
+ /* a count that didn't save is not worth a failed turn */
1968
+ }
1969
+ }, 3000);
1970
+ this.memoryUseTimer.unref?.();
634
1971
  }
1972
+ async brainBriefFor(opts) { return this.briefings.brainBriefFor(opts); }
1973
+ async searchBrain(opts) { return this.briefings.searchBrain(opts); }
1974
+ importMemories() { return this.briefings.importMemories(); }
635
1975
  /** Fire-and-notify hooks + routing + suggested handoffs, off the log. */
636
1976
  afterAgentEvent(event) {
637
1977
  this.trackCost(event);
638
- this.routes.handleAgentEvent(event);
1978
+ this.shield(event);
1979
+ const loop = this.loops.observe(event);
1980
+ if (loop)
1981
+ void this.onLoop(loop).catch((err) => logbook.warn("loops", "couldn't act on a loop", String(err), this.info.id));
1982
+ // an agent out of quota mid-task hands its work on (core/quota.ts, runtime/relay.ts)
1983
+ this.relay.observe(event);
1984
+ if (event.kind === "run_complete")
1985
+ this.shifts.poke();
1986
+ // The turn's diff is started before routing hears the turn ended: a step
1987
+ // condition reads those numbers, and a route that advanced first would
1988
+ // read the turn before this one.
639
1989
  if (event.kind === "run_complete" && event.agentId) {
640
1990
  this.captureTurnDiff(event.agentId);
1991
+ // A reply that claims the tests (or build, or lint) pass gets checked —
1992
+ // against what the turn ran, and a clean re-run — without being asked.
1993
+ if (!event.payload.error && this.proofs.wantsAuto(event))
1994
+ void this.proofs.prove(event.chat ?? MAIN_CHAT, { at: event.id, auto: true }).catch((err) => logbook.warn("proof", "couldn't check the turn", String(err), this.info.id));
1995
+ if (!this.continuity)
1996
+ void this.captureAgentDecisions(event.agentId).catch(() => { });
1997
+ }
1998
+ this.routes.handleAgentEvent(event);
1999
+ // Accumulate the turn's prose so decisions can be mined when it completes.
2000
+ if (!this.continuity && event.kind === "message" && event.agentId && !event.payload.reasoning) {
2001
+ const prev = this.turns.turnText.get(event.agentId) ?? "";
2002
+ this.turns.turnText.set(event.agentId, `${prev}\n${String(event.payload.text ?? "")}`.slice(-8000));
641
2003
  }
642
2004
  if (event.kind === "needs_input") {
643
2005
  notify({
@@ -652,7 +2014,7 @@ export class ProjectRuntime {
652
2014
  });
653
2015
  }
654
2016
  else if (event.kind === "message") {
655
- if (event.agentId)
2017
+ if (event.agentId && !this.continuity)
656
2018
  this.captureDecisions(event);
657
2019
  if (!this.routes.isActive()) {
658
2020
  // A route drives its own handoffs — suggestions would be noise.
@@ -677,6 +2039,119 @@ export class ProjectRuntime {
677
2039
  });
678
2040
  }
679
2041
  }
2042
+ // ── Structured agent decisions (Observatory Decision Explorer + Replay) ──
2043
+ decisionsFile() {
2044
+ return path.join(projectLoomDir(this.info.dir), "decisions.json");
2045
+ }
2046
+ /** An agent's declared role from config (planner/builder/reviewer/…), else its kind. */
2047
+ agentRole(agentId) {
2048
+ const cfg = this.config.agents.find((a) => a.id === agentId);
2049
+ return cfg?.role ?? cfg?.kind ?? "agent";
2050
+ }
2051
+ /** How many turns this agent has completed (for the decision's turnIndex). */
2052
+ turnCountFor(agentId) {
2053
+ return this.log.count({ kinds: ["run_complete"], agentId });
2054
+ }
2055
+ /**
2056
+ * All captured decisions for this project, newest first, in today's shape —
2057
+ * see normalizeStoredDecision for what that does to records written before
2058
+ * a decision had to say where its confidence came from.
2059
+ */
2060
+ getDecisions() {
2061
+ try {
2062
+ const raw = fs.readFileSync(this.decisionsFile(), "utf8");
2063
+ const arr = JSON.parse(raw);
2064
+ if (!Array.isArray(arr))
2065
+ return [];
2066
+ return arr.map(normalizeStoredDecision).sort((a, b) => b.timestamp - a.timestamp);
2067
+ }
2068
+ catch {
2069
+ return [];
2070
+ }
2071
+ }
2072
+ decisionStats() {
2073
+ return decisionStats(this.getDecisions());
2074
+ }
2075
+ /** Append decisions to the persisted store (kept oldest→newest on disk). */
2076
+ storeDecisions(decisions) {
2077
+ if (!decisions.length)
2078
+ return;
2079
+ const existing = this.getDecisions().sort((a, b) => a.timestamp - b.timestamp);
2080
+ const all = [...existing, ...decisions].slice(-1000); // bound the file
2081
+ try {
2082
+ fs.mkdirSync(projectLoomDir(this.info.dir), { recursive: true });
2083
+ fs.writeFileSync(this.decisionsFile(), JSON.stringify(all, null, 2));
2084
+ }
2085
+ catch {
2086
+ /* best-effort: decisions are an enrichment, never break the loop */
2087
+ }
2088
+ }
2089
+ /** Mine decisions from a completed turn, persist them, surface on the Timeline. */
2090
+ async captureAgentDecisions(agentId) {
2091
+ const turnText = this.turns.turnText.get(agentId) ?? "";
2092
+ this.turns.turnText.delete(agentId);
2093
+ if (turnText.trim().length < 100)
2094
+ return;
2095
+ const lastTurn = this.log.list({ kinds: ["run_complete"], agentId, limit: 1 })[0];
2096
+ const p = (lastTurn?.payload ?? {});
2097
+ let filesChanged = [];
2098
+ try {
2099
+ // stderr is dropped, not inherited: a project that isn't a repo yet, or
2100
+ // one with no commits, makes git print a usage wall on every completed
2101
+ // turn. The failure is already handled — it must not also be shouted
2102
+ // into the daemon's console.
2103
+ filesChanged = execSync("git diff --name-only HEAD", {
2104
+ cwd: this.info.dir,
2105
+ encoding: "utf8",
2106
+ stdio: ["ignore", "pipe", "ignore"],
2107
+ })
2108
+ .trim().split("\n").filter(Boolean);
2109
+ }
2110
+ catch { /* not a git repo, or nothing changed */ }
2111
+ // The trace this turn's spans went out under, when telemetry is on.
2112
+ // Undefined otherwise — see observability/index.ts#turnTraceId; a decision
2113
+ // that can't link to a trace must carry no trace id rather than "".
2114
+ const traceId = turnTraceId(agentId);
2115
+ const decisions = await extractDecisions({
2116
+ agentId,
2117
+ agentRole: this.agentRole(agentId),
2118
+ projectId: this.info.id,
2119
+ chatId: this.turns.turnChat.get(agentId) ?? MAIN_CHAT,
2120
+ turnIndex: this.turnCountFor(agentId),
2121
+ ...(traceId ? { traceId } : {}),
2122
+ ...(lastTurn ? { turnId: String(lastTurn.id) } : {}),
2123
+ turnText,
2124
+ // The turn's totals, carried as the TURN's — not divided between the
2125
+ // decisions mined from it, and not stamped on each as if each cost this.
2126
+ turnTokensUsed: Number(p.inputTokens ?? 0) + Number(p.outputTokens ?? 0),
2127
+ turnCostUsd: Number(p.costUsd ?? 0),
2128
+ durationMs: Number(p.durationMs ?? 0),
2129
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY,
2130
+ filesChanged,
2131
+ });
2132
+ if (this.closed || !decisions.length)
2133
+ return;
2134
+ this.storeDecisions(decisions);
2135
+ // Surface each on the Timeline / snapshots via a status event (no new EventKind,
2136
+ // and no collision with the brain's memory `decision` events). `source` rides
2137
+ // along so a renderer can say where the confidence came from — or that there
2138
+ // isn't one, which is what a heuristic decision carries.
2139
+ for (const d of decisions) {
2140
+ this.log.append({
2141
+ kind: "status",
2142
+ agentId: d.agentId,
2143
+ ...(d.chatId && d.chatId !== MAIN_CHAT ? { chat: d.chatId } : {}),
2144
+ payload: {
2145
+ state: "agent_decision",
2146
+ decisionId: d.id,
2147
+ title: d.title,
2148
+ category: d.category,
2149
+ source: d.source,
2150
+ ...(d.confidence !== undefined ? { confidence: d.confidence } : {}),
2151
+ },
2152
+ });
2153
+ }
2154
+ }
680
2155
  /**
681
2156
  * The persisted holder, unless it refers to an agent that has since been
682
2157
  * removed from the config — ghost holders are cleared, not fatal.
@@ -724,10 +2199,10 @@ export class ProjectRuntime {
724
2199
  throw new Error(`agent "${agentId}" can be watched but not driven`);
725
2200
  }
726
2201
  await this.ensureStarted(agentId);
727
- this.turnChat.set(agentId, chat);
2202
+ this.turns.turnChat.set(agentId, chat);
728
2203
  this.log.append({ kind: "message", chat, agentId, payload: { text, author: "user" } });
729
2204
  try {
730
- const reply = await bridge.ask(text);
2205
+ const reply = await withLoomAskTimeout(bridge.ask(text));
731
2206
  return { agentId, reply };
732
2207
  }
733
2208
  catch (err) {
@@ -738,47 +2213,525 @@ export class ProjectRuntime {
738
2213
  throw err;
739
2214
  }
740
2215
  }
2216
+ // ── the prompt queue (core/prompt-queue.ts) ──
2217
+ /**
2218
+ * A proxy in front of one server, so its page can talk to the app.
2219
+ *
2220
+ * One per server, reused while it points at the same place — restarting a
2221
+ * dev server on the same port keeps the preview URL stable, which matters
2222
+ * because the iframe is pointed at it.
2223
+ */
2224
+ async previewProxy(name, target) {
2225
+ const live = this.proxies.get(name);
2226
+ if (live && live.target === target)
2227
+ return live;
2228
+ if (live)
2229
+ await live.close().catch(() => { });
2230
+ const proxy = await startPreviewProxy(target);
2231
+ this.proxies.set(name, proxy);
2232
+ return proxy;
2233
+ }
2234
+ /** Live server state and output, for the socket. Returns unsubscribe. */
2235
+ /** Replies as they're written, for the socket. Returns unsubscribe. */
2236
+ onStream(cb) {
2237
+ this.streamListeners.add(cb);
2238
+ return () => this.streamListeners.delete(cb);
2239
+ }
2240
+ /** The replies being typed right now in one thread (or all of them). */
2241
+ liveNow(chat) {
2242
+ const out = [];
2243
+ for (const [agentId, s] of this.liveSoFar) {
2244
+ if (chat !== undefined && s.chat !== chat)
2245
+ continue;
2246
+ if (s.text || s.think)
2247
+ out.push({ agentId, ...s });
2248
+ }
2249
+ return out;
2250
+ }
2251
+ liveSoFar = new Map();
2252
+ liveText(f) {
2253
+ if (this.closed)
2254
+ return;
2255
+ let s = this.liveSoFar.get(f.agentId);
2256
+ if (!s || s.chat !== f.chat)
2257
+ this.liveSoFar.set(f.agentId, (s = { chat: f.chat, text: "", think: "" }));
2258
+ const off = f.reasoning ? s.think.length : s.text.length;
2259
+ if (f.reasoning)
2260
+ s.think += f.text;
2261
+ else
2262
+ s.text += f.text;
2263
+ // a runaway reply can't grow the buffer without bound
2264
+ if (s.text.length > 400_000)
2265
+ s.text = "";
2266
+ if (s.think.length > 400_000)
2267
+ s.think = "";
2268
+ const framed = { ...f, off };
2269
+ for (const cb of this.streamListeners) {
2270
+ try {
2271
+ cb(framed);
2272
+ }
2273
+ catch {
2274
+ // a viewer that breaks must not break the turn
2275
+ }
2276
+ }
2277
+ }
2278
+ /** Streamed text and tool progress as a turn produces them; what finishes lands in the log. */
2279
+ onLiveDelta(cb) {
2280
+ this.liveListeners.add(cb);
2281
+ return () => this.liveListeners.delete(cb);
2282
+ }
2283
+ /**
2284
+ * Answer a structured question an agent is waiting on (a needs_input event
2285
+ * with a requestId). The turn carries on with the answer.
2286
+ */
2287
+ async answerQuestion(agentId, chat, requestId, answers) {
2288
+ // a crew teammate or orchestra worker runs as its own instance; its card names that instance
2289
+ const agent = (this.agents.get(agentId) ?? this.crews?.liveAgent?.(agentId) ?? this.orchestra?.liveAgent?.(agentId));
2290
+ if (!agent || typeof agent.respondToUserInput !== "function")
2291
+ throw new Error(`agent "${agentId}" can't take answers to questions`);
2292
+ this.releaseQuestionHold(agentId);
2293
+ await agent.respondToUserInput(chat, requestId, answers);
2294
+ if (this.openQuestions.get(agentId)?.requestId === requestId)
2295
+ this.openQuestions.delete(agentId);
2296
+ }
2297
+ /** Structured questions agents are blocked on, by agent. */
2298
+ openQuestions = new Map();
2299
+ /** Compact an agent's native context for a chat now, instead of waiting for the harness to. */
2300
+ async compactAgent(agentId, chat = MAIN_CHAT) {
2301
+ const agent = this.agents.get(agentId);
2302
+ if (!(agent instanceof ProviderAgent))
2303
+ throw new Error(`agent "${agentId}" can't be compacted from Loom`);
2304
+ if (agent.busy() || this.turns.busySince.has(agentId))
2305
+ throw new Error(`"${agentId}" is mid-turn — wait for it to finish, then compact`);
2306
+ await this.ensureStarted(agentId);
2307
+ this.turns.turnChat.set(agentId, chat);
2308
+ await agent.compact(chat);
2309
+ }
2310
+ /**
2311
+ * A plan turn on a provider agent runs in the agent's own plan mode, which
2312
+ * changes nothing; Loom keeps the plan it proposes under plans/, as its plan
2313
+ * mode always has.
2314
+ */
2315
+ saveProposedPlan(agentId, chat, markdown) {
2316
+ if (!markdown.trim())
2317
+ return;
2318
+ try {
2319
+ const heading = /^#+\s*(.+)$/m.exec(markdown)?.[1] ?? markdown.split("\n")[0] ?? "plan";
2320
+ const slug = heading.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "plan";
2321
+ const day = new Date().toISOString().slice(0, 10);
2322
+ const dir = path.join(this.agentDir(agentId), "plans");
2323
+ fs.mkdirSync(dir, { recursive: true });
2324
+ let file = path.join(dir, `${day}-${slug}.md`);
2325
+ for (let n = 2; fs.existsSync(file); n++)
2326
+ file = path.join(dir, `${day}-${slug}-${n}.md`);
2327
+ fs.writeFileSync(file, `---\ntitle: ${JSON.stringify(heading.trim())}\nstatus: proposed\nagent: ${agentId}\n---\n\n${markdown.trim()}\n`);
2328
+ this.log.append({ kind: "status", agentId, ...(chat ? { chat } : {}),
2329
+ payload: { state: "plan_saved", path: path.relative(this.agentDir(agentId), file) } });
2330
+ }
2331
+ catch (error) {
2332
+ logbook.warn("plan", `could not save ${agentId}'s proposed plan`, String(error), this.info.id);
2333
+ }
2334
+ }
2335
+ onServerEvent(cb) {
2336
+ this.serverListeners.add(cb);
2337
+ return () => this.serverListeners.delete(cb);
2338
+ }
2339
+ onQueueChange(cb) { return this.queueCoordinator.onQueueChange(cb); }
2340
+ enqueue(input) {
2341
+ if (this.continuity) {
2342
+ this.queue.assertCanAdd(input);
2343
+ if (!this.chats().some(c => c.id === (input.chat ?? MAIN_CHAT)))
2344
+ throw new ContinuityError("invalid", "conversation is missing or deleted");
2345
+ const bound = this.chatBinding(input.chat);
2346
+ if (input.target?.kind === "orchestra")
2347
+ throw new ContinuityError("unsupported", "parallel orchestra is outside sequential native continuity");
2348
+ const target = input.target?.kind === "agent" ? input.target.agentId : bound.agentId ?? this.validHolder() ?? this.defaultAdapterId();
2349
+ const cfg = this.config.agents.find(a => a.id === target);
2350
+ if (!cfg || !isNativeKind(cfg.kind))
2351
+ throw new ContinuityError("unsupported", "queued native continuity needs a supported harness (Codex, Claude Code or OpenCode)");
2352
+ const captured = this.continuity.capture({ id: newId(16), text: input.text, conversationId: input.chat ?? MAIN_CHAT,
2353
+ agentInstanceId: target, source: input.source ?? "user", model: bound.agentId === target ? bound.model ?? null : null,
2354
+ plan: Boolean(input.plan), targetAddedTokens: 6000 });
2355
+ input = { ...input, target: { kind: "agent", agentId: target }, continuity: { requestId: captured.request.id, model: captured.request.model } };
2356
+ }
2357
+ return this.queueCoordinator.enqueue(input);
2358
+ }
2359
+ editQueued(itemId, patch) {
2360
+ if (this.continuity && (patch.text !== undefined || patch.target !== undefined || patch.plan !== undefined))
2361
+ throw new ContinuityError("unsupported", "native queued request revisions require durable supersession; remove the unsent entry and submit a new request instead");
2362
+ return this.queueCoordinator.editQueued(itemId, patch);
2363
+ }
2364
+ queueBlocker(item) { return this.queueCoordinator.queueBlocker(item); }
2365
+ watchClockConditions(q) { return this.queueCoordinator.watchClockConditions(q); }
2366
+ holdQueueFor(agentId) {
2367
+ // a night shift doesn't wait on a question: it stays open for you in its chat (and the report), and the next task goes
2368
+ if (this.shifts.active)
2369
+ return;
2370
+ return this.queueCoordinator.holdQueueFor(agentId);
2371
+ }
2372
+ releaseQuestionHold(agentId) { return this.queueCoordinator.releaseQuestionHold(agentId); }
2373
+ kickQueue() { return this.queueCoordinator.kickQueue(); }
2374
+ async drainPromptQueue() { return this.queueCoordinator.drainPromptQueue(); }
741
2375
  async sendMessage(text, agentId, opts = {}) {
742
- const source = opts.source ?? "user";
743
- const chat = opts.chat ?? MAIN_CHAT;
744
- let target = agentId ?? this.validHolder() ?? this.defaultAdapterId();
745
- const agent = this.agent(target);
746
- if (!isAdapter(agent)) {
747
- throw new Error(`agent "${target}" is a bridge (read-only) — it cannot take turns`);
2376
+ // a person speaking in a chat is a new start: nobody there is "stuck" any more
2377
+ if ((opts.source ?? "user") === "user") {
2378
+ const chat = opts.chat ?? MAIN_CHAT;
2379
+ this.loops.reset(chat);
2380
+ for (const k of [...this.nudged])
2381
+ if (k.endsWith(`\u0000${chat}`))
2382
+ this.nudged.delete(k);
748
2383
  }
749
- const holder = this.validHolder();
750
- if (holder === null) {
751
- this.baton.acquire(target);
2384
+ // a prompt for an agent that's out of quota goes to whoever is carrying its work
2385
+ const target = agentId ?? this.validHolder() ?? undefined;
2386
+ if (target && this.relay.holds(target)) {
2387
+ const routed = await this.relay.route(target, opts.chat ?? MAIN_CHAT);
2388
+ if (routed !== target)
2389
+ agentId = routed;
2390
+ }
2391
+ return this.turns.sendMessage(text, agentId, opts);
2392
+ }
2393
+ /**
2394
+ * An agent is re-running the same failing command (or the same call over and
2395
+ * over). Say so in its chat — a card with Stop / Nudge / Hand off — and push;
2396
+ * with watchdog.auto "nudge", also stop the turn and tell it to step back.
2397
+ */
2398
+ async onLoop(loop) {
2399
+ // on a night shift nobody is watching: nudge by default (an explicit setting still wins)
2400
+ const set = this.config.watchdog?.auto ?? (this.shifts.active ? "nudge" : "notify");
2401
+ if (set === "off")
2402
+ return;
2403
+ // nudge once; if it's still going in circles after that, it's yours to decide
2404
+ const key = `${loop.agentId}\u0000${loop.chat}`;
2405
+ const mode = set === "nudge" && !this.nudged.has(key) ? "nudge" : "notify";
2406
+ if (mode === "nudge")
2407
+ this.nudged.add(key);
2408
+ this.log.append({ kind: "status", agentId: loop.agentId, ...(loop.chat !== MAIN_CHAT ? { chat: loop.chat } : {}), payload: {
2409
+ state: "loop_detected", loop: loop.kind, call: loop.call, count: loop.count, ...(loop.output ? { output: loop.output } : {}),
2410
+ ...(mode === "nudge" ? { nudged: true } : {}),
2411
+ text: loop.kind === "failing" ? `${loop.agentId} ran \`${loop.call}\` ${loop.count} times this turn, failing each time`
2412
+ : `${loop.agentId} made the same call ${loop.count} times this turn: \`${loop.call}\``,
2413
+ } });
2414
+ if (mode === "nudge")
2415
+ await this.actOnLoop(loop.chat, loop.agentId, "nudge", undefined, loop);
2416
+ }
2417
+ /**
2418
+ * Blast-Radius Shield (core/shield.ts): a command an agent ran that can't be
2419
+ * taken back — a force-push, a reset --hard, a recursive delete outside the
2420
+ * project, a dropped table. Said at once, in its chat and as a push; with
2421
+ * shield.auto "stop" (the default on a night shift) the turn is stopped too.
2422
+ */
2423
+ shield(event) {
2424
+ if (!event.agentId)
2425
+ return;
2426
+ const chat = event.chat ?? MAIN_CHAT;
2427
+ if (event.kind === "run_complete") {
2428
+ for (const k of [...this.shielded])
2429
+ if (k.startsWith(`${event.agentId}\u0000${chat}\u0000`))
2430
+ this.shielded.delete(k);
2431
+ return;
2432
+ }
2433
+ if (event.kind !== "tool_call")
2434
+ return;
2435
+ const command = commandOf(event.payload);
2436
+ if (!command)
2437
+ return;
2438
+ const mode = this.config.shield?.auto ?? (this.shifts.active ? "stop" : "notify");
2439
+ if (mode === "off")
2440
+ return;
2441
+ const danger = dangerOf(command, this.agentDir(event.agentId));
2442
+ if (!danger)
2443
+ return;
2444
+ const key = `${event.agentId}\u0000${chat}\u0000${command}`;
2445
+ if (this.shielded.has(key))
2446
+ return;
2447
+ this.shielded.add(key);
2448
+ const stop = mode === "stop";
2449
+ this.log.append({ kind: "status", agentId: event.agentId, ...(chat !== MAIN_CHAT ? { chat } : {}), payload: {
2450
+ state: "shield_tripped", command: command.slice(0, 500), what: danger.what, why: danger.why, ...(stop ? { stopped: true } : {}),
2451
+ text: `${event.agentId} ran ${danger.what}: \`${command.replace(/\s+/g, " ").slice(0, 160)}\` — ${danger.why}`,
2452
+ } });
2453
+ if (stop)
2454
+ void this.interrupt({ chat, source: "route" }).catch((err) => logbook.warn("shield", "couldn't stop the turn", String(err), this.info.id));
2455
+ }
2456
+ /** The loop card's buttons: stop the turn, nudge the agent, or hand the task to another agent. */
2457
+ async actOnLoop(chat, agentId, action, to, loop) {
2458
+ const card = loop ?? this.lastLoop(chat, agentId);
2459
+ await this.interrupt({ chat, source: "route" });
2460
+ if (action === "stop")
2461
+ return { done: "stopped" };
2462
+ if (action === "nudge") {
2463
+ if (!card)
2464
+ throw new Error("no loop to nudge about in this chat");
2465
+ await this.turns.sendMessage(nudgeText(card), agentId, { chat, source: "route" });
2466
+ return { done: "nudged" };
2467
+ }
2468
+ if (!to || to === agentId)
2469
+ throw new Error("hand it to which agent?");
2470
+ const request = [...this.log.list({ chat, limit: 200 })].reverse().find((e) => e.kind === "message" && !e.agentId && String(e.payload.text ?? "").trim() && !String(e.payload.text).startsWith("[Loom]"));
2471
+ if (!request)
2472
+ throw new Error("couldn't find the request this chat was working on");
2473
+ await this.handoff(to, { source: "user" });
2474
+ const prior = this.briefings.pendingBriefings.get(to);
2475
+ this.briefings.pendingBriefings.set(to, [prior, `[Loom] "${agentId}" got stuck on this${card ? `, running \`${card.call}\` ${card.count} times (${card.kind})` : ""}. Its changes are in the working tree: read them, find what it kept getting wrong, and finish the task a different way.`].filter(Boolean).join("\n"));
2476
+ await this.turns.sendMessage(String(request.payload.text), to, { chat, source: "route" });
2477
+ return { done: "handed off", to };
2478
+ }
2479
+ lastLoop(chat, agentId) {
2480
+ const e = [...this.log.list({ chat, kinds: ["status"], limit: 100 })].reverse().find((x) => x.payload.state === "loop_detected" && x.agentId === agentId);
2481
+ if (!e)
2482
+ return undefined;
2483
+ return { agentId, chat, kind: e.payload.loop === "repeating" ? "repeating" : "failing", call: String(e.payload.call ?? ""), count: Number(e.payload.count ?? 0) };
2484
+ }
2485
+ // -------------------------------------------------------------------------
2486
+ // Named routes
2487
+ // -------------------------------------------------------------------------
2488
+ /**
2489
+ * Define or replace a named route.
2490
+ *
2491
+ * Routes were config you hand-edited: `ship` was built at init and everything
2492
+ * else meant opening .loom/config.json. Validated against the CURRENT roster
2493
+ * before saving — a route that names an agent this project doesn't have would
2494
+ * sit in the file looking runnable and fail at its first step. Saving through
2495
+ * here puts the team's pipeline in a version-controllable file instead of one
2496
+ * person's shell history.
2497
+ */
2498
+ saveRoute(name, steps) {
2499
+ const clean = name.trim().slice(0, 40);
2500
+ if (!clean)
2501
+ throw new Error("a route needs a name");
2502
+ resolveSteps(steps, this.config, (id) => {
2503
+ const live = this.agents.get(id);
2504
+ return Boolean(live && isAdapter(live));
2505
+ });
2506
+ this.config.routes = { ...(this.config.routes ?? {}), [clean]: steps };
2507
+ this.saveConfig();
2508
+ return this.config.routes;
2509
+ }
2510
+ deleteRoute(name) {
2511
+ const routes = { ...(this.config.routes ?? {}) };
2512
+ if (!(name in routes))
2513
+ throw new Error(`no route named "${name}"`);
2514
+ delete routes[name];
2515
+ this.config.routes = routes;
2516
+ this.saveConfig();
2517
+ return routes;
2518
+ }
2519
+ // -------------------------------------------------------------------------
2520
+ // Snapshots
2521
+ // -------------------------------------------------------------------------
2522
+ /**
2523
+ * Checkpoint the project's Loom-owned state: brain, board, config.
2524
+ *
2525
+ * For the moment before letting a fleet loose on something — a restorable
2526
+ * "before". Deliberately NOT the working tree (git owns files and does it
2527
+ * better) and NOT the event log (history is what happened; a restore that
2528
+ * rewrote history would be a lie with a timestamp). Restoring brings back
2529
+ * what the project knew, what was on the board, and how it was configured.
2530
+ */
2531
+ snapshot() {
2532
+ return {
2533
+ format: "loom-snapshot",
2534
+ version: 1,
2535
+ project: this.info.name,
2536
+ takenAt: Date.now(),
2537
+ brain: this.brain.export(this.info.name),
2538
+ tasks: readProjectState(this.info.dir).tasks ?? [],
2539
+ config: this.config,
2540
+ };
2541
+ }
2542
+ /**
2543
+ * Restore a snapshot. Config and board are replaced (that is what "restore"
2544
+ * means for state you own); the brain is MERGED through the same dedupe as
2545
+ * import, because memory is an append-only fold — a restore that silently
2546
+ * forgot what was learned since the snapshot would be data loss wearing a
2547
+ * seatbelt. What it knew then comes back; what it learned since stays.
2548
+ */
2549
+ restore(snap) {
2550
+ if (snap?.format !== "loom-snapshot")
2551
+ throw new Error("not a loom snapshot");
2552
+ const brain = this.brain.import(snap.brain, {
2553
+ agentId: "restore",
2554
+ eventId: this.log.lastId(),
2555
+ ts: Date.now(),
2556
+ });
2557
+ const state = readProjectState(this.info.dir);
2558
+ writeProjectState(this.info.dir, { ...state, tasks: snap.tasks });
2559
+ // `config` is readonly by reference and shared with every live subsystem,
2560
+ // so restore mutates its contents rather than swapping the object.
2561
+ for (const k of Object.keys(this.config)) {
2562
+ if (!(k in snap.config))
2563
+ delete this.config[k];
2564
+ }
2565
+ Object.assign(this.config, snap.config);
2566
+ this.saveConfig();
2567
+ // Reconcile the live agents with the restored roster, the same way
2568
+ // addAgent/removeAgent would have: drop what's gone, spawn what's missing.
2569
+ const wanted = new Map(this.config.agents.map((a) => [a.id, a]));
2570
+ for (const [id, live] of [...this.agents]) {
2571
+ if (!wanted.has(id)) {
2572
+ void this.agentLifecycle.retire(id);
2573
+ }
2574
+ }
2575
+ for (const cfg of this.config.agents) {
2576
+ if (!this.agents.has(cfg.id) && cfg.enabled !== false)
2577
+ this.spawnAgent(cfg);
2578
+ }
2579
+ this.log.append({
2580
+ kind: "status",
2581
+ payload: { state: "restored", takenAt: snap.takenAt, tasks: snap.tasks.length },
2582
+ });
2583
+ return { brain, tasks: snap.tasks.length };
2584
+ }
2585
+ /** Turns older than this are presumed hung. Generous: real turns run long. */
2586
+ static STALE_TURN_MS = 10 * 60 * 1000;
2587
+ staleSessions(now = Date.now()) { return this.turns.staleSessions(now); }
2588
+ /**
2589
+ * Put a hung session out of its misery and bring up a fresh one.
2590
+ *
2591
+ * Interrupt first — a process that responds to that wasn't hung, and gets to
2592
+ * finish dying cleanly — then stop, drop, and respawn from config, exactly as
2593
+ * a cold start would. The baton is released if the corpse held it, because a
2594
+ * lock owned by a session that no longer exists refuses everyone forever.
2595
+ */
2596
+ async reapSession(agentId) {
2597
+ const cfg = this.config.agents.find((a) => a.id === agentId);
2598
+ if (!cfg)
2599
+ throw new Error(`unknown agent "${agentId}" in project "${this.info.name}"`);
2600
+ const live = this.agents.get(agentId);
2601
+ if (live && isAdapter(live)) {
2602
+ await live.interrupt().catch(() => { });
2603
+ }
2604
+ await this.agentLifecycle.retire(agentId);
2605
+ this.turns.busySince.delete(agentId);
2606
+ if (this.validHolder() === agentId)
2607
+ this.baton.release(agentId);
2608
+ this.spawnAgent(cfg);
2609
+ this.log.append({
2610
+ kind: "status",
2611
+ agentId,
2612
+ payload: { state: "session_reaped", reason: "stale or hung session respawned" },
2613
+ });
2614
+ return { respawned: true };
2615
+ }
2616
+ // -------------------------------------------------------------------------
2617
+ // Sub-agents
2618
+ // -------------------------------------------------------------------------
2619
+ /**
2620
+ * How many subtasks may run at once in this project.
2621
+ *
2622
+ * A fan-out is the point of the feature and also the way to melt the machine:
2623
+ * each child is a real CLI process with its own model calls. The cap is per
2624
+ * project rather than global so one busy project can't starve the others.
2625
+ */
2626
+ static MAX_CONCURRENT_SUBTASKS = 4;
2627
+ /** In-flight subtasks, by the id minted when they started. */
2628
+ subtasks = new Map();
2629
+ /** Subtasks currently running, for the status payload and the cap. */
2630
+ liveSubtasks() {
2631
+ return [...this.subtasks.entries()].map(([id, s]) => ({
2632
+ id,
2633
+ parent: s.parent,
2634
+ agentId: s.agentId,
2635
+ task: s.task,
2636
+ }));
2637
+ }
2638
+ /**
2639
+ * Run a subtask on a child agent, alongside the parent's turn.
2640
+ *
2641
+ * A turn used to be all-or-nothing: one agent, one prompt, one result. "Audit
2642
+ * these twelve files" wanted twelve cheap readers, and the only way to get
2643
+ * them was twelve sequential turns, each one taking the baton off the last.
2644
+ *
2645
+ * What makes a child a child rather than another turn:
2646
+ *
2647
+ * - **It never touches the baton.** The parent still holds it, so a fan-out
2648
+ * cannot steal the conversation from the agent that started it, and the
2649
+ * human's next message still lands where they expect.
2650
+ * - **Its briefing is narrowed.** It gets the task and the project's shape,
2651
+ * not the parent's whole thread. Handing a child the full history is how
2652
+ * you pay twice for context the parent already read.
2653
+ * - **Its result is attributed and parented**, so the thread can indent it
2654
+ * under the turn that asked rather than interleaving it as a peer.
2655
+ *
2656
+ * Everything it learns still lands in the one project brain — a child that
2657
+ * discovered something and took it to the grave would be worse than no child.
2658
+ */
2659
+ async spawnSubAgent(parentAgentId, opts) {
2660
+ if (this.continuity)
2661
+ throw new ContinuityError("unsupported", "parallel subagents are outside sequential native continuity; finish this turn or use legacy mode");
2662
+ const task = opts.task?.trim();
2663
+ if (!task)
2664
+ throw new Error("a subtask needs a task");
2665
+ // A child's briefing is deliberately narrow; a 100KB "task" is the parent
2666
+ // smuggling its whole context through the one field that was scoped.
2667
+ if (task.length > 10_000) {
2668
+ throw new Error("a subtask brief tops out at 10k chars — hand files, not transcripts");
752
2669
  }
753
- else if (holder !== target) {
754
- throw new NotHolderError(target, holder);
2670
+ // The parent has to be a real agent in this project, so the thread can
2671
+ // indent under something that exists.
2672
+ this.agent(parentAgentId);
2673
+ const child = this.agent(opts.agentId);
2674
+ if (!isAdapter(child)) {
2675
+ throw new Error(`agent "${opts.agentId}" is a bridge (read-only) — it cannot run a subtask`);
755
2676
  }
756
- // A user reply to a paused route's question resumes the route.
757
- if (source === "user")
758
- this.routes.onUserMessage(target);
759
- // everything this turn produces belongs to the chat you sent from
760
- this.turnChat.set(target, chat);
2677
+ if (this.subtasks.size >= ProjectRuntime.MAX_CONCURRENT_SUBTASKS) {
2678
+ throw new Error(`${ProjectRuntime.MAX_CONCURRENT_SUBTASKS} subtasks are already running in this project`);
2679
+ }
2680
+ // Same gates as a turn. A child that ignored the budget would be a hole in
2681
+ // the ceiling the parent is standing under.
2682
+ this.enforceQuarantine(opts.agentId);
2683
+ this.enforceBudget(opts.agentId);
2684
+ const id = newId(5);
2685
+ const chat = opts.chat ?? MAIN_CHAT;
2686
+ this.subtasks.set(id, {
2687
+ parent: parentAgentId,
2688
+ agentId: opts.agentId,
2689
+ task,
2690
+ startedAt: Date.now(),
2691
+ });
761
2692
  this.log.append({
762
- kind: "message",
2693
+ kind: "subtask_started",
2694
+ agentId: opts.agentId,
763
2695
  chat,
764
- payload: { text, author: source === "route" ? "loom" : "user" },
765
- });
766
- await this.ensureStarted(target);
767
- const pendingBriefing = this.consumePendingBriefing(target);
768
- const input = pendingBriefing ? { text, briefing: pendingBriefing } : { text };
769
- // Snapshot the tree so this prompt's changes can be attributed to it.
770
- this.preTurnTree.set(target, await porcelainStatus(this.info.dir));
771
- // Fire-and-notify: the turn runs in the background; progress streams
772
- // into the log and completion lands as run_complete.
773
- void agent.send(input).catch((err) => {
774
- this.log.append({
775
- kind: "error",
776
- agentId: target,
777
- payload: { message: String(err instanceof Error ? err.message : err) },
778
- });
2696
+ payload: { subtaskId: id, parent: parentAgentId, task },
779
2697
  });
780
- return { agentId: target };
2698
+ await this.ensureStarted(opts.agentId);
2699
+ const mcp = child.capabilities.mcp ? writeMcpSession(this.healthyMcps()) : null;
2700
+ const input = {
2701
+ text: task,
2702
+ chat,
2703
+ briefing: this.subtaskBriefing(parentAgentId, opts.agentId, task),
2704
+ ...(mcp ? { mcp: { configPath: mcp.configPath, servers: mcp.servers } } : {}),
2705
+ };
2706
+ void child
2707
+ .send(input)
2708
+ .then(() => {
2709
+ this.subtasks.delete(id);
2710
+ this.appendIfOpen({
2711
+ kind: "subtask_done",
2712
+ agentId: opts.agentId,
2713
+ chat,
2714
+ payload: { subtaskId: id, parent: parentAgentId, task },
2715
+ });
2716
+ })
2717
+ .catch((err) => {
2718
+ this.subtasks.delete(id);
2719
+ this.appendIfOpen({
2720
+ kind: "subtask_failed",
2721
+ agentId: opts.agentId,
2722
+ chat,
2723
+ payload: {
2724
+ subtaskId: id,
2725
+ parent: parentAgentId,
2726
+ task,
2727
+ message: String(err instanceof Error ? err.message : err),
2728
+ },
2729
+ });
2730
+ })
2731
+ .finally(() => mcp?.cleanup());
2732
+ return { id, agentId: opts.agentId };
781
2733
  }
2734
+ subtaskBriefing(parent, childId, task) { return this.briefings.subtaskBriefing(parent, childId, task); }
782
2735
  defaultAdapterId() {
783
2736
  const cfg = (this.config.defaultAgent &&
784
2737
  this.config.agents.find((a) => a.id === this.config.defaultAgent)) ||
@@ -787,114 +2740,108 @@ export class ProjectRuntime {
787
2740
  throw new Error(`project "${this.info.name}" has no full-duplex adapters`);
788
2741
  return cfg.id;
789
2742
  }
790
- // -------------------------------------------------------------------------
791
- // Handoff
792
- // -------------------------------------------------------------------------
793
- /** Briefings are injected with the first turn after a handoff. */
794
- pendingBriefings = new Map();
795
- consumePendingBriefing(agentId) {
796
- const briefing = this.pendingBriefings.get(agentId);
797
- this.pendingBriefings.delete(agentId);
798
- return briefing;
799
- }
2743
+ consumePendingBriefing(agentId) { return this.briefings.consumePendingBriefing(agentId); }
2744
+ async retryTurn(toAgentId) { return this.turns.retryTurn(toAgentId); }
800
2745
  /**
801
- * Explicit baton pass: interrupt the current holder if mid-turn, project
802
- * the log into the target's namespaced memory, arm the one-shot briefing.
803
- * A *manual* handoff cancels any active route — the human outranks it.
2746
+ * Carry the outgoing agent's branch into the incoming agent's worktree.
2747
+ *
2748
+ * Only with both `worktreePerAgent` and `mergeOnHandoff` on, only when the
2749
+ * two really are separate checkouts, and never when it would have to guess:
2750
+ * see core/worktree-merge.ts for what it refuses. The outcome is a value so
2751
+ * the handoff event and the briefing can both say what happened.
804
2752
  */
2753
+ async mergeForHandoff(from, to) {
2754
+ if (!this.config.git?.worktreePerAgent || !this.config.git?.mergeOnHandoff)
2755
+ return null;
2756
+ const into = this.agentDir(to);
2757
+ const source = this.agentDir(from);
2758
+ if (into === this.info.dir || source === into)
2759
+ return null;
2760
+ try {
2761
+ return await mergeAgentWork({
2762
+ into,
2763
+ branch: `agent/${from}`,
2764
+ sourceDir: source,
2765
+ message: `Loom: ${from} → ${to}`,
2766
+ });
2767
+ }
2768
+ catch (err) {
2769
+ // A merge that can't run must not take the handoff down with it.
2770
+ logbook.warn("git", `merge on handoff ${from} → ${to} failed`, String(err), this.info.id);
2771
+ return null;
2772
+ }
2773
+ }
805
2774
  async handoff(to, opts = {}) {
2775
+ if (this.continuity?.store.activeReceipts().some(r => r.execution === "unknown"))
2776
+ throw new ContinuityError("recovery_required", "reconcile uncertain native writers before handoff or merge");
806
2777
  const target = this.agent(to);
807
2778
  if (!isAdapter(target)) {
808
2779
  throw new Error(`cannot hand the baton to "${to}" — bridges are read-only by design`);
809
2780
  }
2781
+ // Handing the baton to an agent that can't afford a turn is the same
2782
+ // refusal as sending it one — and it has to be caught HERE, before the
2783
+ // current holder is interrupted, or a route would strand the baton on an
2784
+ // agent that will refuse every prompt it gets.
2785
+ this.enforceQuarantine(to);
2786
+ this.enforceBudget(to);
810
2787
  if ((opts.source ?? "user") === "user")
811
2788
  this.routes.onManualHandoff();
812
2789
  // Audit trail: snapshot the outgoing holder's working-tree state into the
813
2790
  // handoff event, so "who left what uncommitted" is always answerable.
814
2791
  let handoffMeta = { projected: true };
815
2792
  const holder = this.validHolder();
2793
+ let merge = null;
816
2794
  if (holder && holder !== to) {
817
2795
  const current = this.agent(holder);
818
2796
  if (isAdapter(current)) {
2797
+ if (this.continuity && this.turns.busySince.has(holder) && !current.busy())
2798
+ throw new ContinuityError("conflict", "outgoing turn is still preparing or finalizing; wait or stop before interrupt-switching");
819
2799
  if (current.busy())
820
2800
  await current.interrupt();
821
2801
  const diff = await current.diff().catch(() => "");
822
2802
  if (diff)
823
2803
  handoffMeta = { ...handoffMeta, dirty: true, diff: diff.slice(0, 2000) };
824
2804
  }
2805
+ // After the outgoing agent has stopped (its last commit is in), before
2806
+ // the briefing is written — so the briefing can carry the result.
2807
+ merge = await this.mergeForHandoff(holder, to);
2808
+ if (merge)
2809
+ handoffMeta = { ...handoffMeta, merge };
825
2810
  }
826
- // Refresh the shared brain from every ADE's native memory before handing
827
- // off, so the incoming agent inherits what the others knew.
828
- this.importMemories();
829
- const events = this.log.list({ limit: PROJECTION_WINDOW });
830
- const input = {
831
- projectName: this.info.name,
832
- config: this.config,
833
- events,
834
- targetAgentId: to,
835
- fromAgentId: holder,
836
- };
837
- // Template by default; LLM-distilled when the project opts in — always
838
- // falling back to the template so a broken Claude never blocks a handoff.
839
- const distillStart = Date.now();
840
- const rendered = await renderProjection(input, this.config.projection);
841
- // Phase 3: the memories relevant to the work in flight, retrieved and
842
- // compiled — this is the part the recency-window projection can't do. Query
843
- // is the recent conversation plus the files recent turns touched; scoped to
844
- // this chat and to the incoming agent; low-confidence memories are held back
845
- // from injection (they're still visible in the Brain tab).
846
- const brainBrief = this.retrieveBrief(events, to);
847
- // Append the unified cross-ADE memory so the incoming agent sees the
848
- // whole brain, not just this project's log.
849
- const unified = this.unifiedMemory();
850
- const parts = [rendered.content];
851
- if (brainBrief)
852
- parts.push(brainBrief);
853
- if (unified.sources.length > 0)
854
- parts.push(unified.document);
855
- const enriched = parts.join("\n\n---\n");
856
- await target.injectMemory(enriched);
857
- writeMemoryFile(this.info.dir, to, enriched); // idempotent with default impl
858
- this.pendingBriefings.set(to, buildBriefing(input));
859
- if (rendered.mode === "llm") {
860
- this.log.append({
861
- kind: "status",
862
- payload: { state: "projection", mode: "llm", ms: Date.now() - distillStart },
863
- });
2811
+ const bridgeIds = this.config.agents.map((cfg) => cfg.id).filter((id) => {
2812
+ const agent = this.agents.get(id);
2813
+ return agent && !isAdapter(agent) && id !== to;
2814
+ });
2815
+ const mergeNote = merge ? describeMerge(merge, holder ?? "the previous agent", to) : "";
2816
+ if (this.continuity) {
2817
+ // New context is assembled from the selected chat at dispatch, never
2818
+ // from global history or private bridge memory on a picker click.
2819
+ this.briefings.pendingBriefings.delete(to);
2820
+ const { from } = this.baton.handoff(to, { ...handoffMeta, projected: false, continuity: 1 });
2821
+ await this.ensureStarted(to);
2822
+ return { from, ...(merge ? { merge } : {}) };
864
2823
  }
865
- // Bridges (GUI agents) are passive observers — keep their shared-context
866
- // files fresh on every hop so e.g. Antigravity always sees the weave.
867
- // (Always template views: N bridges × LLM calls per hop would be waste.)
868
- for (const cfg of this.config.agents) {
869
- const bystander = this.agents.get(cfg.id);
870
- if (!bystander || isAdapter(bystander) || cfg.id === to)
871
- continue;
872
- // Bridges get the retrieved brain brief too — they can't take a system
873
- // prompt, but their shared-context file is the only memory they have, so
874
- // it shouldn't be the one view without the learned memories in it.
875
- const bridgeBrief = this.retrieveBrief(events, cfg.id);
876
- const bridgeView = bridgeBrief
877
- ? `${buildProjection({ ...input, targetAgentId: cfg.id })}\n\n---\n${bridgeBrief}`
878
- : buildProjection({ ...input, targetAgentId: cfg.id });
879
- await bystander.injectMemory(bridgeView).catch(() => { });
2824
+ const prepared = await this.briefings.prepareHandoff(to, holder, mergeNote, bridgeIds);
2825
+ if (this.closed || this.agents.get(to) !== target)
2826
+ throw new Error("handoff target is no longer active");
2827
+ await target.injectMemory(prepared.memory);
2828
+ if (this.closed || this.agents.get(to) !== target)
2829
+ throw new Error("handoff target is no longer active");
2830
+ writeMemoryFile(this.info.dir, to, prepared.memory);
2831
+ this.briefings.pendingBriefings.set(to, prepared.briefing);
2832
+ if (prepared.mode === "llm") {
2833
+ this.log.append({ kind: "status", payload: { state: "projection", mode: "llm", ms: prepared.elapsedMs } });
880
2834
  }
2835
+ for (const bridge of prepared.bridges) {
2836
+ await this.agents.get(bridge.agentId)?.injectMemory(bridge.memory).catch(() => { });
2837
+ }
2838
+ if (this.closed || this.agents.get(to) !== target)
2839
+ throw new Error("handoff target is no longer active");
881
2840
  const { from } = this.baton.handoff(to, handoffMeta);
882
2841
  await this.ensureStarted(to);
883
- return { from };
884
- }
885
- async interrupt(opts = {}) {
886
- if ((opts.source ?? "user") === "user")
887
- this.routes.onManualInterrupt();
888
- const holder = this.validHolder();
889
- if (!holder)
890
- return { interrupted: null };
891
- const agent = this.agent(holder);
892
- if (isAdapter(agent) && agent.busy()) {
893
- await agent.interrupt();
894
- return { interrupted: holder };
895
- }
896
- return { interrupted: null };
2842
+ return { from, ...(merge ? { merge } : {}) };
897
2843
  }
2844
+ async interrupt(opts = {}) { return this.turns.interrupt(opts); }
898
2845
  // -------------------------------------------------------------------------
899
2846
  // Routing
900
2847
  // -------------------------------------------------------------------------
@@ -904,7 +2851,42 @@ export class ProjectRuntime {
904
2851
  * list of agent ids/roles. Undefined → the "ship" route if defined, else
905
2852
  * every adapter in config order.
906
2853
  */
2854
+ /**
2855
+ * Opt-in: checkpoint before a route starts.
2856
+ *
2857
+ * A route is exactly the moment you let a fleet loose — several agents,
2858
+ * several turns, no human between hops. With safety.snapshotBeforeRoutes on,
2859
+ * the brain+board+config land in .loom/snapshots/pre-route-<ts>.json first,
2860
+ * so `loom restore` has a "before" without anyone having remembered to take
2861
+ * one. Bounded: the newest five are kept, older ones pruned — a safety net,
2862
+ * not an archive.
2863
+ */
2864
+ snapshotBeforeRoute() {
2865
+ if (!this.config.safety?.snapshotBeforeRoutes)
2866
+ return;
2867
+ try {
2868
+ const dir = path.join(projectLoomDir(this.info.dir), "snapshots");
2869
+ fs.mkdirSync(dir, { recursive: true });
2870
+ const file = path.join(dir, `pre-route-${Date.now()}.json`);
2871
+ fs.writeFileSync(file, JSON.stringify(this.snapshot(), null, 2) + "\n");
2872
+ const old = fs
2873
+ .readdirSync(dir)
2874
+ .filter((f) => f.startsWith("pre-route-"))
2875
+ .sort()
2876
+ .slice(0, -5);
2877
+ for (const f of old)
2878
+ fs.rmSync(path.join(dir, f), { force: true });
2879
+ logbook.info("routes", `snapshot taken before the route — ${path.basename(file)}`, undefined, this.info.id);
2880
+ }
2881
+ catch (err) {
2882
+ // A safety net that blocks the route it protects is worse than none.
2883
+ logbook.warn("routes", "pre-route snapshot failed", String(err), this.info.id);
2884
+ }
2885
+ }
907
2886
  async startRoute(opts) {
2887
+ if (this.continuity)
2888
+ throw new ContinuityError("unsupported", "autonomous route continuity is not verified; select a native agent directly");
2889
+ this.snapshotBeforeRoute();
908
2890
  if (typeof opts.spec === "string" && opts.spec.trim() === "auto") {
909
2891
  return this.routes.startDynamic(opts.task, {
910
2892
  ...(opts.router ? { router: opts.router } : {}),
@@ -955,18 +2937,61 @@ export class ProjectRuntime {
955
2937
  async status() {
956
2938
  const holder = this.validHolder();
957
2939
  const agents = await Promise.all(this.config.agents.map(async (cfg) => {
958
- const agent = this.agent(cfg.id);
2940
+ // The picker shows a tick next to the active model; "" means the
2941
+ // adapter's own default, which is the honest baseline.
2942
+ const model = cfg.options?.model ?? "";
2943
+ const live = this.agents.get(cfg.id);
2944
+ if (!live) {
2945
+ // Switched off: still in the roster, just not spawned. Its tier has
2946
+ // to come from somewhere other than the instance, and switching an
2947
+ // agent off must not change what it *is*. ADES first (the catalog
2948
+ // people pick from), then the factory registry for the kinds ADES
2949
+ // deliberately omits. Defaulting to "adapter" would be wrong for
2950
+ // exactly those: a disabled bridge would advertise itself as an
2951
+ // adapter to every surface that filters on this field.
2952
+ const spec = ADES.find((a) => a.kind === cfg.kind);
2953
+ const tier = spec?.tier ?? tierForKind(cfg.kind) ?? "adapter";
2954
+ return {
2955
+ id: cfg.id,
2956
+ kind: cfg.kind,
2957
+ role: cfg.role,
2958
+ tier,
2959
+ available: false,
2960
+ busy: false,
2961
+ holdsBaton: false,
2962
+ model,
2963
+ permissions: permissionFor(cfg.kind, cfg.options),
2964
+ ...(cfg.instructions ? { instructions: cfg.instructions } : {}),
2965
+ ...sampling(cfg),
2966
+ ...(cfg.avatar ? { avatar: cfg.avatar } : {}),
2967
+ // "not spawned" is not "switched off". An agent whose CLI is missing
2968
+ // is still enabled in config, and reporting it as disabled made the
2969
+ // project-settings toggle render off — clicking it then wrote the
2970
+ // value it already had, so the agent could never be turned back on.
2971
+ enabled: cfg.enabled !== false,
2972
+ };
2973
+ }
959
2974
  return {
960
2975
  id: cfg.id,
961
2976
  kind: cfg.kind,
962
2977
  role: cfg.role,
963
- tier: agent.capabilities.tier,
964
- available: await agent.available().catch(() => false),
965
- busy: isAdapter(agent) ? agent.busy() : false,
2978
+ tier: live.capabilities.tier,
2979
+ available: this.continuity && isNativeKind(cfg.kind)
2980
+ ? (await this.harnesses.ensure(cfg.id)).available
2981
+ : await live.available().catch(() => false),
2982
+ ...(this.continuity && isNativeKind(cfg.kind) ? { cliVersion: this.harnesses.get(cfg.id)?.version ?? null } : {}),
2983
+ busy: isAdapter(live) ? live.busy() || Boolean(this.continuity && this.turns.busySince.has(cfg.id)) : false,
2984
+ // which chat that turn is in, so a client shows Stop only where it applies
2985
+ ...(isAdapter(live) && (live.busy() || this.turns.busySince.has(cfg.id)) ? { chat: this.turns.turnChat.get(cfg.id) ?? MAIN_CHAT } : {}),
966
2986
  holdsBaton: holder === cfg.id,
967
- // The picker shows a tick next to the active model; "" means the
968
- // adapter's own default, which is the honest baseline.
969
- model: cfg.options?.model ?? "",
2987
+ model,
2988
+ permissions: permissionFor(cfg.kind, cfg.options),
2989
+ enabled: true,
2990
+ ...(cfg.instructions ? { instructions: cfg.instructions } : {}),
2991
+ ...sampling(cfg),
2992
+ ...(cfg.avatar ? { avatar: cfg.avatar } : {}),
2993
+ ...(isNativeKind(cfg.kind) ? { context: this.nativeUsage.context(cfg.id), limits: this.nativeUsage.limitsFor(cfg.kind) } : {}),
2994
+ ...((f) => (f ? { forecast: f } : {}))(this.accounting.forecast(cfg.id)),
970
2995
  };
971
2996
  }));
972
2997
  const recent = this.log.list({ limit: 50 });
@@ -975,11 +3000,16 @@ export class ProjectRuntime {
975
3000
  .reverse()
976
3001
  .find((e) => e.kind === "message" && !e.agentId);
977
3002
  const lastNeedsInput = [...recent].reverse().find((e) => e.kind === "needs_input");
978
- const needsInput = Boolean(lastNeedsInput && (!lastUserMsg || lastNeedsInput.id > lastUserMsg.id));
3003
+ const reqId = lastNeedsInput && lastNeedsInput.payload.requestId;
3004
+ // a question answered on its card (no message typed) is answered all the same
3005
+ const answeredOnCard = Boolean(reqId && recent.some((e) => e.id > lastNeedsInput.id && e.kind === "status" &&
3006
+ e.payload.state === "question_answered" && e.payload.requestId === reqId));
3007
+ const needsInput = Boolean(lastNeedsInput && !answeredOnCard && (!lastUserMsg || lastNeedsInput.id > lastUserMsg.id));
979
3008
  return {
980
3009
  id: this.info.id,
981
3010
  name: this.info.name,
982
3011
  dir: this.info.dir,
3012
+ ...(this.continuity ? { continuity: true } : {}),
983
3013
  holder,
984
3014
  agents,
985
3015
  lastEvent,
@@ -990,27 +3020,278 @@ export class ProjectRuntime {
990
3020
  chats: this.chats(),
991
3021
  route: this.routes.state(),
992
3022
  routeNames: ["auto", ...Object.keys(this.config.routes ?? {})],
993
- costUsd: this.costs.totalUsd,
3023
+ costUsd: this.accounting.costs.totalUsd,
3024
+ // Paused agents belong in the status payload, not only in state on disk.
3025
+ // Without this the UI cannot show that an alert has taken an agent out of
3026
+ // rotation — the pause was real and completely invisible, which reads as
3027
+ // "the self-heal did nothing".
3028
+ quarantine: this.quarantined(),
3029
+ orchestra: this.orchestraSummary(),
3030
+ };
3031
+ }
3032
+ /**
3033
+ * What every agent in this project is doing right now — for the fleet view.
3034
+ *
3035
+ * One row per roster agent (its thread, whether it's mid-turn, its last
3036
+ * step) and one per orchestra task (worker sessions don't live in the
3037
+ * roster). "Last step" is the newest event from that agent in that thread,
3038
+ * summarised to a line: the tool it ran, the file it touched, what it said.
3039
+ */
3040
+ activity() {
3041
+ const recent = this.log.list({ limit: 400 });
3042
+ const chats = new Map(this.chats().map((c) => [c.id, c.title]));
3043
+ const lastOf = (agentId, chat) => {
3044
+ for (let i = recent.length - 1; i >= 0; i--) {
3045
+ const e = recent[i];
3046
+ if (e.agentId !== agentId)
3047
+ continue;
3048
+ if (chat !== undefined && (e.chat ?? MAIN_CHAT) !== chat)
3049
+ continue;
3050
+ return { kind: e.kind, ts: e.ts, chat: e.chat ?? MAIN_CHAT, line: activityLine(e) };
3051
+ }
3052
+ return null;
3053
+ };
3054
+ const agents = this.config.agents.map((cfg) => {
3055
+ const live = this.agents.get(cfg.id);
3056
+ const chat = this.turns.turnChat.get(cfg.id);
3057
+ const last = lastOf(cfg.id, chat);
3058
+ return {
3059
+ id: cfg.id,
3060
+ kind: cfg.kind,
3061
+ role: cfg.role,
3062
+ busy: Boolean(live && isAdapter(live) && (live.busy() || this.turns.busySince.has(cfg.id))),
3063
+ since: this.turns.busySince.get(cfg.id) ?? null,
3064
+ permissions: permissionFor(cfg.kind, cfg.options),
3065
+ holdsBaton: this.validHolder() === cfg.id,
3066
+ chat: chat ?? last?.chat ?? null,
3067
+ chatTitle: chats.get(chat ?? last?.chat ?? "") ?? null,
3068
+ last,
3069
+ };
3070
+ });
3071
+ const run = this.orchestra.active() ?? this.orchestra.list()[0];
3072
+ const orchestra = run
3073
+ ? {
3074
+ id: run.id,
3075
+ goal: run.goal,
3076
+ status: run.status,
3077
+ chat: run.chat,
3078
+ orchestrator: run.orchestrator.agent,
3079
+ tasks: run.tasks.map((t) => ({
3080
+ id: t.id,
3081
+ title: t.title,
3082
+ agent: t.agent,
3083
+ status: t.status,
3084
+ chat: t.chat,
3085
+ attempts: t.attempts,
3086
+ files: t.files?.length ?? 0,
3087
+ last: lastOf(t.agent, t.chat),
3088
+ })),
3089
+ }
3090
+ : null;
3091
+ return {
3092
+ project: { id: this.info.id, name: this.info.name },
3093
+ agents,
3094
+ orchestra,
3095
+ crews: this.crews.list().filter(c => c.state.goal && !["completed", "failed", "stopped"].includes(c.state.goal.status)).map(c => ({
3096
+ id: c.id, name: c.name, goal: c.state.goal.text, status: c.state.goal.status, channel: c.state.channel, active: c.active,
3097
+ cards: c.state.goal.cards.map(card => ({ id: card.id, title: card.title, stage: card.stage, teammate: card.builder })),
3098
+ })),
3099
+ subtasks: this.liveSubtasks(),
3100
+ route: this.routes.state(),
3101
+ };
3102
+ }
3103
+ /** The team member running this daemon (set by Team Link), for commit trailers. */
3104
+ memberLogin = null;
3105
+ /** Loom Teams, Phase 2: this project's team coordinator (set by Team Link). */
3106
+ coordinator = null;
3107
+ /** Loom Teams, Phase 3: this project's share of the team brain (set by Team Link). */
3108
+ teamBrain = null;
3109
+ /** The effective loom.team.json while shared with a team (D37); null when solo. */
3110
+ teamPolicy = null;
3111
+ crewSpendLedger = { day: "", after: 0, total: 0, crews: new Map() };
3112
+ crewSpendToday(crew) {
3113
+ const day = new Date().toISOString().slice(0, 10), start = Date.parse(`${day}T00:00:00Z`);
3114
+ if (this.crewSpendLedger.day !== day)
3115
+ this.crewSpendLedger = { day, after: 0, total: 0, crews: new Map() };
3116
+ const ledger = this.crewSpendLedger;
3117
+ // a new day starts its ledger at the day's first event, not at the first event ever written
3118
+ for (const event of this.log.list({ kinds: ["status"], since: ledger.after, ...(ledger.after === 0 ? { sinceTs: start } : {}) })) {
3119
+ const id = event.payload.crew?.crewId;
3120
+ const cost = Number(event.payload.costUsd);
3121
+ if (id && event.ts >= start && event.payload.state === "turn_cost" && Number.isFinite(cost) && cost > 0) {
3122
+ ledger.total += cost;
3123
+ ledger.crews.set(id, (ledger.crews.get(id) ?? 0) + cost);
3124
+ }
3125
+ }
3126
+ ledger.after = this.log.lastId();
3127
+ return crew ? ledger.crews.get(crew) ?? 0 : ledger.total;
3128
+ }
3129
+ /**
3130
+ * An agent's options under the team policy (D38): its permission mode capped
3131
+ * at the ceiling. Plan mode can't be known when an agent is built, so
3132
+ * `bypassRequiresPlan` treats it as off — the stricter reading.
3133
+ */
3134
+ policyOptions(cfg) {
3135
+ const opts = { ...(cfg.options ?? {}) };
3136
+ if (this.runnerMode) {
3137
+ // A runner is its own trust tier (D70): agents bypass inside the sandbox,
3138
+ // capped by the team's runners.permissions rather than the laptop ceiling.
3139
+ const wanted = opts.permissions ? permissionFor(cfg.kind, opts) : "bypass";
3140
+ const ceiling = this.teamPolicy?.runners.permissions ?? "bypass";
3141
+ const rank = { ask: 0, auto: 1, bypass: 2 };
3142
+ opts.permissions = rank[wanted] > rank[ceiling] ? ceiling : wanted;
3143
+ if (this.teamPolicy)
3144
+ opts.permissions = capCrewPermission(this.teamPolicy.crews ?? DEFAULT_CREW_POLICY, cfg.role, opts.permissions);
3145
+ if (this.teamPolicy) {
3146
+ const reason = unsupportedReason(cfg.kind, opts.permissions);
3147
+ if (reason)
3148
+ throw new Error(`team policy requires ${opts.permissions} for ${cfg.id}, but ${cfg.kind} cannot enforce it: ${reason}`);
3149
+ }
3150
+ return opts;
3151
+ }
3152
+ if (!this.teamPolicy)
3153
+ return opts;
3154
+ const wanted = permissionFor(cfg.kind, opts);
3155
+ const capped = cappedPermission(this.teamPolicy, wanted, false);
3156
+ const roleCap = capCrewPermission(this.teamPolicy.crews ?? DEFAULT_CREW_POLICY, cfg.role, capped);
3157
+ const unsupported = unsupportedReason(cfg.kind, roleCap);
3158
+ if (unsupported)
3159
+ throw new Error(`team policy requires ${roleCap} for ${cfg.id}, but ${cfg.kind} cannot enforce it: ${unsupported}`);
3160
+ if (roleCap !== wanted)
3161
+ opts.permissions = roleCap;
3162
+ return opts;
3163
+ }
3164
+ /** Phase 5: this project is a runner's workspace for one goal (D70). */
3165
+ runnerMode = false;
3166
+ /** Adapter kinds usable on this machine: the roster's plus installed CLIs. */
3167
+ usableKinds() {
3168
+ const roster = this.config.agents.filter((a) => a.enabled !== false && tierForKind(a.kind) === "adapter").map((a) => a.kind);
3169
+ return [...new Set([...roster, ...this.installedKinds])];
3170
+ }
3171
+ /**
3172
+ * One question to one agent, outside any thread: the Phase 4 review (D60).
3173
+ * Runs in `dir` (a throwaway directory, so a reviewer that forgets it's
3174
+ * read-only can't touch the project), returns everything it said, and
3175
+ * counts its cost like any turn.
3176
+ */
3177
+ async askAgent(kind, dir, text, opts = {}) {
3178
+ const cfg = this.config.agents.find((a) => a.kind === kind) ?? { id: `${kind}-review`, kind, role: "reviewer" };
3179
+ const agent = createAgent({ ...cfg, id: `${cfg.id}-review`, options: { ...this.policyOptions(cfg), loomProject: this.info.id } }, dir);
3180
+ if (!isAdapter(agent))
3181
+ throw new Error(`"${kind}" can't answer questions headless`);
3182
+ let said = "";
3183
+ const off = agent.onEvent((e) => {
3184
+ const p = e.payload;
3185
+ if (e.kind === "message" && !p.reasoning && p.role !== "user")
3186
+ said += `\n${String(p.text ?? "")}`;
3187
+ if (e.kind === "status" && p.state === "turn_cost") {
3188
+ this.trackCost({ id: -1, ts: Date.now(), kind: e.kind, agentId: agent.id, payload: p });
3189
+ }
3190
+ });
3191
+ const timeout = opts.timeoutMs ?? 15 * 60_000;
3192
+ let timer;
3193
+ try {
3194
+ await agent.start();
3195
+ await Promise.race([
3196
+ agent.send({ text }),
3197
+ new Promise((_, reject) => {
3198
+ timer = setTimeout(() => reject(new Error(`${kind} didn't answer within ${Math.round(timeout / 60_000)} min`)), timeout);
3199
+ }),
3200
+ ]);
3201
+ return said.trim();
3202
+ }
3203
+ finally {
3204
+ if (timer)
3205
+ clearTimeout(timer);
3206
+ off();
3207
+ if (agent.busy())
3208
+ await agent.interrupt().catch(() => { });
3209
+ await agent.stop().catch(() => { });
3210
+ }
3211
+ }
3212
+ /** Share this project with a team, or record an explicit opt-out (null). */
3213
+ setTeam(share) {
3214
+ this.config.team = share ? { teamId: share.teamId, repo: share.repo } : { optOut: true };
3215
+ this.saveConfig();
3216
+ }
3217
+ chatOf(agentId) { return this.turns.chatOf(agentId); }
3218
+ /** The live (or latest) orchestra run, compact — for status payloads. */
3219
+ orchestraSummary() {
3220
+ const run = this.orchestra.active() ?? this.orchestra.list()[0];
3221
+ if (!run)
3222
+ return null;
3223
+ return {
3224
+ id: run.id,
3225
+ goal: run.goal,
3226
+ status: run.status,
3227
+ chat: run.chat,
3228
+ // Whether that thread is the run's own or one it borrowed \u2014 a borrowed
3229
+ // one stops answering for the run when the run ends.
3230
+ ...(run.inPlace ? { inPlace: true } : {}),
3231
+ orchestrator: run.orchestrator.agent,
3232
+ tasks: run.tasks.length,
3233
+ done: run.tasks.filter((t) => t.status === "done").length,
3234
+ running: run.tasks.filter((t) => t.status === "running").length,
3235
+ /**
3236
+ * One row per task, small enough to ride on every status poll.
3237
+ *
3238
+ * `tasks` above is a count, which is all the tab dot needed. It left
3239
+ * every other surface unable to answer questions the daemon knows the
3240
+ * answer to: which agent owns this thread (a task thread used to claim
3241
+ * to be whoever held the baton), whether a thread is still running, and
3242
+ * where a task's work went.
3243
+ */
3244
+ threads: run.tasks.map((t) => ({
3245
+ id: t.id,
3246
+ title: t.title,
3247
+ chat: t.chat,
3248
+ agent: t.agent,
3249
+ kind: t.kind,
3250
+ status: t.status,
3251
+ })),
994
3252
  };
995
3253
  }
996
3254
  async close() {
997
- for (const id of this.startedAgents) {
998
- await this.agent(id).stop().catch(() => { });
3255
+ await this.orchestra.shutdown().catch(() => { });
3256
+ this.relay.stop();
3257
+ this.shifts.close();
3258
+ await this.crews.shutdown().catch(() => { });
3259
+ this.closed = true;
3260
+ this.harnesses.stop();
3261
+ this.live.close();
3262
+ this.briefings.close();
3263
+ if (this.mcpTimer) {
3264
+ clearInterval(this.mcpTimer);
3265
+ this.mcpTimer = null;
999
3266
  }
1000
- this.startedAgents.clear();
3267
+ await this.agentLifecycle.close();
3268
+ // A dev server outlives the daemon that started it unless we say otherwise,
3269
+ // and an orphan holding port 3000 is a bad thing to leave behind.
3270
+ if (this.queueCoordinator.clockTimer) {
3271
+ clearInterval(this.queueCoordinator.clockTimer);
3272
+ this.queueCoordinator.clockTimer = null;
3273
+ }
3274
+ await this.servers.closeAll().catch(() => { });
3275
+ for (const proxy of this.proxies.values())
3276
+ await proxy.close().catch(() => { });
3277
+ this.proxies.clear();
1001
3278
  this.brain.close(); // unsubscribes before the log drops its listeners
1002
3279
  this.log.close();
1003
3280
  }
1004
- }
1005
- export function relativeToProject(projectDir, p) {
1006
- return path.isAbsolute(p) ? path.relative(projectDir, p) : p;
1007
- }
1008
- function configMtimeOf(projectDir) {
1009
- try {
1010
- return fs.statSync(path.join(projectDir, ".loom", "config.json")).mtimeMs;
1011
- }
1012
- catch {
1013
- return 0;
3281
+ closed = false;
3282
+ /**
3283
+ * Append unless the runtime has been closed.
3284
+ *
3285
+ * The async tails need this: a subtask completion or a turn error can resolve
3286
+ * after close() — stopping an agent does not cancel a promise already in
3287
+ * flight — and appending then throws into an unhandled rejection against a
3288
+ * sqlite handle that no longer exists. The record is not lost so much as
3289
+ * meaningless: the project this event belonged to is gone from memory.
3290
+ */
3291
+ appendIfOpen(event) {
3292
+ if (this.closed)
3293
+ return;
3294
+ this.log.append(event);
1014
3295
  }
1015
3296
  }
1016
3297
  //# sourceMappingURL=runtime.js.map