@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
@@ -9,10 +9,35 @@
9
9
  * live events : GET /event (SSE)
10
10
  *
11
11
  * Surface verified against opencode 1.17.20 — see docs/integration-notes.md.
12
+ *
13
+ * Native continuity (see core/continuity): a continuity turn runs on the
14
+ * session its binding names, or a new one it reports as `turn_started`; a
15
+ * bound session the server no longer has is NativeSessionMissing, before
16
+ * anything is sent. The prompt's admission is the acceptance evidence
17
+ * (`native_turn_accepted`), and the turn is over — and quiescent — when
18
+ * `/api/session/active` no longer lists the session. Verified against
19
+ * opencode 1.18.31 (`/api/session/{id}/model` switches the model in place,
20
+ * `session.next.compaction.*` reports compaction).
12
21
  */
13
- import { execFile, spawn } from "node:child_process";
14
- import { readProjectState, writeProjectState } from "../core/registry.js";
15
- import { AdapterBase, agentEnv, cliAvailable, fetchJson, frameBriefing, freePort, waitFor } from "./base.js";
22
+ import { attachedImages } from "../providers/attachments.js";
23
+ import { execFile, spawnContained, stopWindowsTree, stopWindowsPid } from "../core/process.js";
24
+ import { windowsJobReady } from "../core/windows-jobs.js";
25
+ import { createHash } from "node:crypto";
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+ import { AdapterBase, agentEnv, cliAvailable, cliOutput, fetchJson, firstLine, frameBriefing, freePort, interruptProcess, waitFor } from "./base.js";
29
+ import { requestApproval } from "../core/approvals.js";
30
+ import { logbook } from "../core/logbook.js";
31
+ import { permissionFor } from "../core/permissions.js";
32
+ import { NativeDispatchRejected, NativeQuiescenceUnknown, NativeSessionMissing } from "../core/continuity/contracts.js";
33
+ /** OPENCODE_IDLE_MS overrides it (tests); otherwise the agent's idleMinutes, else 30 minutes. */
34
+ function idleMsFor(options) {
35
+ const env = Number(process.env.LOOM_OPENCODE_IDLE_MS);
36
+ if (Number.isFinite(env) && env >= 0 && process.env.LOOM_OPENCODE_IDLE_MS !== undefined)
37
+ return env;
38
+ const minutes = typeof options.idleMinutes === "number" && options.idleMinutes >= 0 ? options.idleMinutes : 30;
39
+ return minutes * 60_000;
40
+ }
16
41
  /** "providerID/modelID" → ModelRef body for session create ({providerID, id}). */
17
42
  export function parseModelRef(model) {
18
43
  const idx = model.indexOf("/");
@@ -20,59 +45,226 @@ export function parseModelRef(model) {
20
45
  return null;
21
46
  return { providerID: model.slice(0, idx), id: model.slice(idx + 1) };
22
47
  }
48
+ /** Errors that come from something already in the session's history, not from this turn. */
49
+ const POISONED_HISTORY = /media must contain valid base64|invalid (?:image|base64)|could not process image|image (?:is )?(?:too large|not supported)/i;
50
+ function turnError(error) {
51
+ return String(error.message ?? error.data?.message ?? "opencode turn failed").slice(0, 500);
52
+ }
53
+ /** The prompt body, with attached pictures as file attachments so OpenCode sees them, not just their path. */
54
+ function promptWithFiles(text, dir) {
55
+ // inline as a data URL: a file:// URL to a folder with a space in it ("Extreme SSD") reached the model as empty media
56
+ const files = attachedImages(text, dir).map((im) => ({ uri: `data:${im.mime};base64,${fs.readFileSync(im.abs).toString("base64")}`, name: path.basename(im.rel) }));
57
+ return files.length ? { text, files } : { text };
58
+ }
59
+ /** A turn's tokens for run_complete: totals, plus the cached and reasoning shares when there are any. */
60
+ function usagePayload(u) {
61
+ return {
62
+ inputTokens: u.input,
63
+ outputTokens: u.output,
64
+ ...(u.cached ? { cachedInputTokens: u.cached } : {}),
65
+ ...(u.reasoning ? { reasoningTokens: u.reasoning } : {}),
66
+ };
67
+ }
68
+ /**
69
+ * The model to use when the project pins none: opencode's free house model if
70
+ * the server offers it, else any free opencode-hosted model, else nothing
71
+ * (opencode's own default — the old behaviour).
72
+ */
73
+ export function pickDefaultModel(ids) {
74
+ if (ids.includes("opencode/big-pickle"))
75
+ return "opencode/big-pickle";
76
+ return ids.find((id) => id.startsWith("opencode/") && id.endsWith("-free"));
77
+ }
78
+ /**
79
+ * Permissions (core/permissions.ts) for the spawned server, via opencode's
80
+ * inline-config variable — the user's own opencode.json is never touched.
81
+ */
82
+ export function opencodePermissionEnv(options) {
83
+ // Only bypass changes anything. "ask" is refused for opencode upstream (see
84
+ // core/permissions.ts): its headless session API ignored a deny-everything
85
+ // config and the read-only `plan` agent alike — a file write went through
86
+ // both (verified on opencode 1.18.31) — so a read-only switch here would lie.
87
+ if (permissionFor("opencode", options) !== "bypass")
88
+ return {};
89
+ return {
90
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
91
+ permission: { edit: "allow", bash: "allow", webfetch: "allow", external_directory: "allow" },
92
+ }),
93
+ };
94
+ }
23
95
  export class OpenCodeAdapter extends AdapterBase {
24
96
  child = null;
25
97
  baseUrl = null;
26
98
  sseAbort = null;
27
99
  options;
28
100
  started = false;
101
+ /** npm builds expose /session + /global/health; newer builds use /api. */
102
+ modernApi = false;
103
+ /**
104
+ * Before a turn: whatever a previous turn left in flight (an interrupted
105
+ * reply's parts, a prompt's echo) is never coming back, and the dedupe sets
106
+ * only need recent ids — so they're trimmed, not kept for the agent's life.
107
+ */
108
+ trimTurnState() {
109
+ this.textParts.clear();
110
+ this.reasoningParts.clear();
111
+ const keep = (set, max = 500) => {
112
+ if (set.size <= max)
113
+ return;
114
+ let drop = set.size - max;
115
+ for (const k of set.keys()) {
116
+ if (drop-- <= 0)
117
+ break;
118
+ set.delete(k);
119
+ }
120
+ };
121
+ keep(this.roles);
122
+ keep(this.emittedText);
123
+ keep(this.toolsDone);
124
+ }
125
+ /** Stops an idle server Loom started (see idleMinutes). */
126
+ idleTimer = null;
127
+ cancelIdle() {
128
+ if (this.idleTimer)
129
+ clearTimeout(this.idleTimer);
130
+ this.idleTimer = null;
131
+ }
132
+ /**
133
+ * After a turn: stop the server we spawned once nothing has used it for a
134
+ * while. Never one we were pointed at (baseUrl), never mid-turn, and never
135
+ * while a question it asked is still waiting for an answer.
136
+ */
137
+ armIdle() {
138
+ this.cancelIdle();
139
+ const ms = idleMsFor(this.options);
140
+ if (!ms || this.options.baseUrl || !this.child)
141
+ return;
142
+ this.idleTimer = setTimeout(() => {
143
+ this.idleTimer = null;
144
+ if (this._busy || this.questions.size || !this.child || !this.started)
145
+ return void this.armIdle();
146
+ logbook.info("opencode", `stopping idle opencode serve for "${this.id}" after ${Math.round(ms / 60_000)} min; the next turn restarts it`);
147
+ void this.sleep().catch(() => { });
148
+ }, ms);
149
+ this.idleTimer.unref?.();
150
+ }
151
+ /** Stop the idle server quietly: not a failure, and not the agent stopping. */
152
+ async sleep() {
153
+ this.started = false;
154
+ this.sseAbort?.abort();
155
+ this.sseAbort = null;
156
+ const child = this.child;
157
+ this.child = null;
158
+ if (!child)
159
+ return;
160
+ if (process.platform === "win32")
161
+ await stopWindowsTree(child);
162
+ else
163
+ await interruptProcess(child, true);
164
+ this.emit({ kind: "status", payload: { state: "idle_stopped" } });
165
+ }
166
+ api(route) {
167
+ return `${this.baseUrl}${this.modernApi ? "" : "/api"}${route}`;
168
+ }
169
+ promptBody(text) {
170
+ if (!this.modernApi)
171
+ return { prompt: promptWithFiles(text, this.projectDir) };
172
+ const ref = this.options.model ? parseModelRef(this.options.model) : null;
173
+ return {
174
+ parts: [{ type: "text", text }, ...attachedImages(text, this.projectDir).map(im => ({
175
+ type: "file", mime: im.mime, filename: path.basename(im.abs), url: `data:${im.mime};base64,${fs.readFileSync(im.abs).toString("base64")}`,
176
+ }))],
177
+ ...(ref ? { model: { providerID: ref.providerID, modelID: ref.id } } : {}),
178
+ ...(this.options.agent ? { agent: this.options.agent } : {}),
179
+ };
180
+ }
181
+ async messageDetail(sid, mid) {
182
+ const detail = await fetchJson(this.api(`/session/${sid}/message/${mid}`)).catch(() => null);
183
+ if (this.modernApi && detail?.info)
184
+ return { ...detail.info, content: detail.parts };
185
+ return detail;
186
+ }
187
+ // Token usage from the assistant message, stashed to ride run_complete.
188
+ lastUsage = null;
189
+ // The provider/model the assistant message actually ran, for the gen_ai span.
190
+ lastModel = null;
29
191
  /** text parts per in-flight assistant message */
30
192
  textParts = new Map();
31
193
  roles = new Map();
32
194
  /** assistant messages whose text already went to the log (SSE path) */
33
195
  emittedText = new Set();
196
+ /** The session a continuity turn is running on; the agent's own otherwise. */
197
+ activeSid;
198
+ /** interrupt() was asked for during this turn. */
199
+ interrupted = false;
200
+ /** Tools opencode started this turn, by call id (1.18 reports name and result separately). */
201
+ toolCalls = new Map();
202
+ /** A compaction was already reported as done (both the step event and the session event can say so). */
203
+ compactionReported = false;
34
204
  constructor(id, projectDir, options = {}) {
35
205
  super(id, "opencode", projectDir);
36
206
  this.options = options;
37
207
  }
38
208
  get sessionId() {
39
- return readProjectState(this.projectDir).agents[this.id]?.sessionId;
209
+ return this.nativeState.read().sessionId;
40
210
  }
41
211
  set sessionId(value) {
42
- const state = readProjectState(this.projectDir);
43
- state.agents[this.id] = { ...state.agents[this.id], sessionId: value };
44
- writeProjectState(this.projectDir, state);
212
+ this.nativeState.patch({ sessionId: value });
45
213
  }
46
214
  async available() {
47
215
  if (this.options.baseUrl)
48
216
  return true;
49
217
  return cliAvailable("opencode");
50
218
  }
219
+ async selfCheck() {
220
+ if (this.options.baseUrl)
221
+ return [{ name: "server", ok: true, detail: `uses the opencode server at ${this.options.baseUrl}` }];
222
+ const v = await cliOutput("opencode", ["--version"]);
223
+ if (v?.code !== 0)
224
+ return [{ name: "installed", ok: false, detail: "opencode not found on this machine — install it" }];
225
+ const checks = [{ name: "installed", ok: true, detail: `opencode ${firstLine(v.out)}` }];
226
+ const a = await cliOutput("opencode", ["auth", "list"]);
227
+ const providers = (a?.out ?? "")
228
+ .split("\n")
229
+ .map((l) => /[●•]\s+(.+?)(?:\s{2,}|\s+(?:api|oauth|wellknown)\s*$|$)/.exec(l)?.[1]?.trim())
230
+ .filter((x) => !!x)
231
+ // the environment section names the variable too ("OpenRouter OPENROUTER_API_KEY")
232
+ .map((x) => x.replace(/\s+[A-Z][A-Z0-9_]{2,}$/, ""))
233
+ .filter((x, i, all) => all.indexOf(x) === i);
234
+ checks.push({
235
+ name: "providers",
236
+ // opencode's own free models need no sign-in, so none is a note, not a failure
237
+ ok: true,
238
+ detail: providers.length ? `signed in to ${providers.join(", ")}` : "no providers signed in — its free models still work",
239
+ });
240
+ return checks;
241
+ }
51
242
  /** Kill a serve child left behind by a previous daemon (verified by cmdline). */
52
243
  async reapOrphanServe() {
53
- const state = readProjectState(this.projectDir);
54
- const pid = Number(state.agents[this.id]?.servePid ?? 0);
244
+ const pid = Number(this.nativeState.read().servePid ?? 0);
55
245
  if (!pid)
56
246
  return;
57
247
  const cmd = await new Promise((resolve) => {
58
- execFile("ps", ["-p", String(pid), "-o", "command="], (err, stdout) => resolve(err ? "" : stdout.trim()));
248
+ const windows = process.platform === "win32";
249
+ execFile(windows ? "powershell.exe" : "ps", windows ? ["-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`] : ["-p", String(pid), "-o", "command="], { timeout: 8000 }, (err, stdout) => resolve(err ? "" : stdout.trim()));
59
250
  });
60
- if (/opencode serve/.test(cmd)) {
251
+ if (/opencode(?:\.exe)?["\s]+serve\b/i.test(cmd)) {
61
252
  try {
62
- process.kill(pid, "SIGTERM");
253
+ if (process.platform === "win32")
254
+ await stopWindowsPid(pid);
255
+ else
256
+ process.kill(pid, "SIGTERM");
63
257
  this.emit({ kind: "status", payload: { state: "reaped_orphan_serve", pid } });
64
258
  }
65
259
  catch {
66
260
  // already gone
67
261
  }
68
262
  }
69
- delete state.agents[this.id]?.servePid;
70
- writeProjectState(this.projectDir, state);
263
+ if (this.nativeState.read().servePid === pid)
264
+ this.nativeState.patch({ servePid: undefined });
71
265
  }
72
266
  recordServePid(pid) {
73
- const state = readProjectState(this.projectDir);
74
- state.agents[this.id] = { ...state.agents[this.id], servePid: pid };
75
- writeProjectState(this.projectDir, state);
267
+ this.nativeState.patch({ servePid: pid });
76
268
  }
77
269
  async start() {
78
270
  if (this.started)
@@ -84,22 +276,41 @@ export class OpenCodeAdapter extends AdapterBase {
84
276
  await this.reapOrphanServe();
85
277
  const port = await freePort();
86
278
  this.baseUrl = `http://127.0.0.1:${port}`;
87
- const child = spawn("opencode", ["serve", "--port", String(port), "--hostname", "127.0.0.1", ...(this.options.extraArgs ?? [])], { cwd: this.projectDir, stdio: "ignore", env: agentEnv() });
279
+ const child = spawnContained("opencode", ["serve", "--port", String(port), "--hostname", "127.0.0.1", ...(this.options.extraArgs ?? [])], { cwd: this.projectDir, stdio: "ignore", env: { ...agentEnv(), ...opencodePermissionEnv(this.options) } });
88
280
  this.child = child;
281
+ child.on("error", (error) => this.emit({ kind: "error", payload: { message: `opencode serve could not start: ${error.message}` } }));
89
282
  this.recordServePid(child.pid);
90
- child.on("close", (code) => {
91
- if (this.started) {
92
- this.emit({ kind: "error", payload: { message: `opencode serve exited (${code})` } });
93
- this.started = false;
283
+ if (process.platform === "win32")
284
+ await windowsJobReady(child);
285
+ child.on("close", (code, signal) => {
286
+ if (!this.started)
287
+ return;
288
+ this.started = false;
289
+ // Killed by a signal with no turn running is the daemon shutting down
290
+ // (or restarting) around it — not a failure, and not worth a red card
291
+ // in the thread. The next send starts a fresh server either way.
292
+ if (code === null && !this._busy) {
293
+ this.emit({ kind: "status", payload: { state: "stopped", signal: signal ?? null } });
294
+ return;
94
295
  }
296
+ this.emit({ kind: "error", payload: { message: `opencode serve exited (${code ?? signal})` } });
95
297
  });
96
298
  }
97
299
  await waitFor(async () => {
98
- await fetchJson(`${this.baseUrl}/api/health`);
300
+ try {
301
+ await fetchJson(`${this.baseUrl}/api/health`);
302
+ this.modernApi = false;
303
+ }
304
+ catch {
305
+ await fetchJson(`${this.baseUrl}/global/health`);
306
+ this.modernApi = true;
307
+ }
99
308
  return true;
100
- });
309
+ }, { timeoutMs: process.platform === "win32" ? 60_000 : 30_000 });
101
310
  await this.assertModelAvailable();
102
- await this.ensureSession();
311
+ // No session yet: an ordinary turn makes (or reuses) the agent's own when it
312
+ // sends, and a continuity turn runs on its binding's. Creating one here left
313
+ // an empty session behind every time a continuity project started.
103
314
  this.startSse();
104
315
  this.started = true;
105
316
  this.emit({
@@ -108,12 +319,10 @@ export class OpenCodeAdapter extends AdapterBase {
108
319
  });
109
320
  }
110
321
  get sessionModel() {
111
- return readProjectState(this.projectDir).agents[this.id]?.sessionModel;
322
+ return this.nativeState.read().sessionModel;
112
323
  }
113
324
  set sessionModel(value) {
114
- const state = readProjectState(this.projectDir);
115
- state.agents[this.id] = { ...state.agents[this.id], sessionModel: value };
116
- writeProjectState(this.projectDir, state);
325
+ this.nativeState.patch({ sessionModel: value });
117
326
  }
118
327
  /**
119
328
  * A session whose model can't be resolved is a silent trap: opencode
@@ -122,13 +331,21 @@ export class OpenCodeAdapter extends AdapterBase {
122
331
  * server can actually run — and fail loudly with suggestions instead.
123
332
  */
124
333
  async assertModelAvailable() {
125
- if (!this.options.model)
126
- return;
127
- const res = await fetchJson(`${this.baseUrl}/api/model`).catch(() => null);
128
- const models = Array.isArray(res?.data) ? res.data : [];
334
+ const res = await fetchJson(this.modernApi ? `${this.baseUrl}/provider` : `${this.baseUrl}/api/model`).catch(() => null);
335
+ const connected = Array.isArray(res?.connected) ? res.connected : [];
336
+ const models = this.modernApi && Array.isArray(res?.all)
337
+ ? res.all.filter(p => connected.includes(String(p.id))).flatMap(p => Object.keys((p.models ?? {})).map(id => ({ providerID: p.id, id })))
338
+ : Array.isArray(res?.data) ? res.data : [];
129
339
  if (!models.length)
130
340
  return; // endpoint unavailable — don't block
131
341
  const ids = models.map((m) => `${String(m.providerID)}/${String(m.id)}`);
342
+ if (!this.options.model) {
343
+ // No pin: opencode's own default is often a model its Console won't
344
+ // serve headless ("Model is unavailable" on the first turn, verified on
345
+ // opencode 1.18.31). Pick one the server says it can run instead.
346
+ this.options.model = pickDefaultModel(ids);
347
+ return;
348
+ }
132
349
  if (ids.includes(this.options.model))
133
350
  return;
134
351
  const base = this.options.model.split("/").pop().split("-")[0].toLowerCase();
@@ -145,7 +362,7 @@ export class OpenCodeAdapter extends AdapterBase {
145
362
  const modelChanged = (this.options.model ?? undefined) !== this.sessionModel;
146
363
  if (existing && !modelChanged) {
147
364
  try {
148
- await fetchJson(`${this.baseUrl}/api/session/${existing}`);
365
+ await fetchJson(this.api(`/session/${existing}`));
149
366
  return existing;
150
367
  }
151
368
  catch {
@@ -160,7 +377,7 @@ export class OpenCodeAdapter extends AdapterBase {
160
377
  }
161
378
  if (this.options.agent)
162
379
  body.agent = this.options.agent;
163
- const res = await fetchJson(`${this.baseUrl}/api/session`, {
380
+ const res = await fetchJson(this.api("/session"), {
164
381
  method: "POST",
165
382
  headers: { "content-type": "application/json" },
166
383
  body: JSON.stringify(body),
@@ -215,7 +432,22 @@ export class OpenCodeAdapter extends AdapterBase {
215
432
  const type = String(evt.type ?? "");
216
433
  // Payload wrapping varies across opencode builds: {properties} or {data}.
217
434
  const props = (evt.properties ?? evt.data ?? evt);
218
- const mySession = this.sessionId;
435
+ const mySession = this.activeSid ?? this.sessionId;
436
+ if (type.startsWith("session.next.tool.") || type.startsWith("session.next.compaction.") || type === "session.compacted") {
437
+ if (props.sessionID && props.sessionID !== mySession)
438
+ return;
439
+ this.handleSessionNext(type, props);
440
+ return;
441
+ }
442
+ // opencode 1.18+ streams the reply as session.next.{text,reasoning}.delta,
443
+ // one fragment each; the finished message still arrives the usual way.
444
+ if (/^session\.next\.(text|reasoning)\.delta$/.test(type)) {
445
+ if (props.sessionID && props.sessionID !== mySession)
446
+ return;
447
+ if (typeof props.delta === "string")
448
+ this.streamText(props.delta, type.includes("reasoning"));
449
+ return;
450
+ }
219
451
  if (type === "message.part.updated") {
220
452
  const part = (props.part ?? {});
221
453
  if (part.sessionID && part.sessionID !== mySession)
@@ -225,18 +457,54 @@ export class OpenCodeAdapter extends AdapterBase {
225
457
  if (partType === "text" && typeof part.text === "string") {
226
458
  if (!this.textParts.has(messageID))
227
459
  this.textParts.set(messageID, new Map());
228
- this.textParts.get(messageID).set(String(part.id ?? "p"), part.text);
460
+ const parts = this.textParts.get(messageID);
461
+ const partId = String(part.id ?? "p");
462
+ const prev = parts.get(partId) ?? "";
463
+ parts.set(partId, part.text);
464
+ // The reply as it grows. Each update carries the part's whole text so
465
+ // far (newer builds add the delta too); only the assistant's, never
466
+ // the echo of your own prompt, which arrives as a text part as well.
467
+ if (this.roles.get(messageID) === "assistant") {
468
+ const delta = typeof props.delta === "string" ? props.delta : part.text.startsWith(prev) ? part.text.slice(prev.length) : "";
469
+ if (delta)
470
+ this.streamText(delta);
471
+ }
472
+ }
473
+ else if (partType === "reasoning" && typeof part.text === "string") {
474
+ // its thinking, kept (not only streamed): the finished part replaces any earlier copy
475
+ if (this.roles.get(messageID) !== "user")
476
+ this.reasoningParts.set(`${messageID}:${String(part.id ?? "r")}`, part.text);
229
477
  }
230
478
  else if (partType === "tool") {
231
479
  const state = (part.state ?? {});
232
- if (String(state.status ?? "") === "completed") {
480
+ const st = String(state.status ?? "");
481
+ const key = String(part.callID ?? part.id ?? "");
482
+ if ((st === "completed" || st === "error") && !this.toolsDone.has(key)) {
483
+ if (key)
484
+ this.toolsDone.add(key);
485
+ const input = state.input && typeof state.input === "object" ? state.input : undefined;
486
+ const output = typeof state.output === "string" ? state.output : "";
487
+ const error = st === "error" ? String(state.error ?? "failed") : "";
488
+ const tool = String(part.tool ?? "tool");
489
+ // a bash call that ran to the end "completed" even when its command failed: the exit code says which
490
+ const meta = state.metadata;
491
+ const exitCode = typeof meta?.exit === "number" ? meta.exit : undefined;
233
492
  this.emit({
234
493
  kind: "tool_call",
235
494
  payload: {
236
- tool: String(part.tool ?? "tool"),
237
- summary: String(state.title ?? part.tool ?? "tool"),
495
+ tool,
496
+ summary: String(state.title ?? tool),
497
+ ok: st === "completed" && (exitCode === undefined || exitCode === 0),
498
+ ...(exitCode !== undefined ? { exitCode } : {}),
499
+ ...(tool === "bash" && typeof input?.command === "string" ? { command: input.command.slice(0, 2_000) } : {}),
500
+ ...(input ? { input: Object.fromEntries(Object.entries(input).slice(0, 20).map(([k, v]) => [k, typeof v === "string" && v.length > 600 ? `${v.slice(0, 600)}\u2026` : v])) } : {}),
501
+ ...(output.trim() ? { preview: output.length > 4000 ? `\u2026\n${output.slice(-4000)}` : output } : {}),
502
+ ...(error ? { error: error.slice(0, 1500) } : {}),
238
503
  },
239
504
  });
505
+ // its todo list is its plan
506
+ if (tool === "todowrite" && Array.isArray(input?.todos))
507
+ this.emitPlan(input.todos);
240
508
  }
241
509
  }
242
510
  else if (partType === "patch") {
@@ -255,8 +523,17 @@ export class OpenCodeAdapter extends AdapterBase {
255
523
  const role = String(info.role ?? "");
256
524
  if (role)
257
525
  this.roles.set(messageID, role);
526
+ // the echo of our own prompt (briefing and all) is never emitted: don't keep it
527
+ if (role === "user")
528
+ this.textParts.delete(messageID);
258
529
  const time = (info.time ?? {});
259
530
  if (role === "assistant" && time.completed) {
531
+ const thought = [...this.reasoningParts.entries()].filter(([k]) => k.startsWith(`${messageID}:`)).map(([, v]) => v).join("\n").trim();
532
+ for (const k of [...this.reasoningParts.keys()])
533
+ if (k.startsWith(`${messageID}:`))
534
+ this.reasoningParts.delete(k);
535
+ if (thought)
536
+ this.emit({ kind: "message", payload: { text: thought, reasoning: true } });
260
537
  const parts = this.textParts.get(messageID);
261
538
  if (parts && parts.size) {
262
539
  const text = [...parts.values()].join("").trim();
@@ -274,19 +551,209 @@ export class OpenCodeAdapter extends AdapterBase {
274
551
  }
275
552
  return;
276
553
  }
277
- if (/^(permission|question)(\.v2)?\.asked$/.test(type)) {
554
+ if (type === "todo.updated") {
555
+ if (props.sessionID && props.sessionID !== mySession)
556
+ return;
557
+ if (Array.isArray(props.todos))
558
+ this.emitPlan(props.todos);
559
+ return;
560
+ }
561
+ if (/^question(\.v2)?\.asked$/.test(type)) {
562
+ if (props.sessionID && props.sessionID !== mySession)
563
+ return;
564
+ this.askQuestion(props, type.includes(".v2"));
565
+ return;
566
+ }
567
+ // Answered or dismissed somewhere else (opencode's own TUI, a timeout): fold the card.
568
+ if (/^question(\.v2)?\.(replied|rejected)$/.test(type)) {
569
+ const id = String(props.requestID ?? props.id ?? "");
570
+ if (id && this.questions.delete(`oc-q:${id}`)) {
571
+ this.emit({ kind: "status", payload: { state: "question_answered", requestId: `oc-q:${id}`, ...(type.endsWith("rejected") ? { answers: {} } : {}) } });
572
+ }
573
+ return;
574
+ }
575
+ if (/^permission(\.v2)?\.asked$/.test(type)) {
278
576
  if (props.sessionID && props.sessionID !== mySession)
279
577
  return;
280
- const detail = props.title ??
281
- props.text ??
282
- props.permission?.title ??
283
- type;
284
- this.emit({ kind: "needs_input", payload: { question: String(detail).slice(0, 500) } });
578
+ void this.askPermission(props, type.includes(".v2"));
579
+ }
580
+ }
581
+ reasoningParts = new Map();
582
+ toolsDone = new Set();
583
+ /** An image a tool returned (a file: URI, or data: base64 filed under .loom/attachments), as a path the thread can show. */
584
+ keepImage(uri, mime) {
585
+ try {
586
+ if (uri.startsWith("file://")) {
587
+ const abs = decodeURIComponent(uri.slice(7));
588
+ const rel = path.relative(this.projectDir, abs);
589
+ return [{ path: rel.startsWith("..") ? abs : rel.split(path.sep).join("/"), mime }];
590
+ }
591
+ const m = /^data:([^;,]+);base64,(.+)$/s.exec(uri);
592
+ if (!m)
593
+ return [];
594
+ const buf = Buffer.from(m[2], "base64");
595
+ if (!buf.length || buf.length > 12 * 1024 * 1024)
596
+ return [];
597
+ const ext = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/svg+xml": "svg" }[m[1]] ?? "png";
598
+ const rel = path.join(".loom", "attachments", `${createHash("sha1").update(buf).digest("hex").slice(0, 12)}.${ext}`);
599
+ const abs = path.join(this.projectDir, rel);
600
+ if (!fs.existsSync(abs)) {
601
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
602
+ fs.writeFileSync(abs, buf);
603
+ }
604
+ return [{ path: rel.split(path.sep).join("/"), mime }];
605
+ }
606
+ catch {
607
+ return [];
608
+ }
609
+ }
610
+ /** opencode's todos as Loom's plan checklist. */
611
+ emitPlan(todos) {
612
+ const plan = todos.map((t) => ({ step: String(t.content ?? t.text ?? ""),
613
+ status: t.status === "completed" ? "completed" : t.status === "in_progress" ? "inProgress" : "pending" })).filter((t) => t.step);
614
+ if (plan.length)
615
+ this.emit({ kind: "status", payload: { state: "plan_updated", plan } });
616
+ }
617
+ // ---- opencode asking you things ---------------------------------------------
618
+ /** Open questions by Loom request id → opencode's request, so an answer can find its way back. */
619
+ questions = new Map();
620
+ /**
621
+ * opencode's question tool: one or more questions, each with labelled
622
+ * options, maybe several picks, maybe your own words. The card in the thread
623
+ * answers it through respondToUserInput.
624
+ */
625
+ askQuestion(props, _v2) {
626
+ const id = String(props.id ?? "");
627
+ const list = Array.isArray(props.questions) ? props.questions : [];
628
+ if (!id || !list.length)
629
+ return;
630
+ const questions = list.map((q, i) => ({
631
+ id: String(i),
632
+ header: String(q.header ?? "Question").slice(0, 40),
633
+ question: String(q.question ?? ""),
634
+ options: (Array.isArray(q.options) ? q.options : []).map((o) => typeof o === "string" ? { label: o, description: "" } : { label: String(o.label ?? ""), description: String(o.description ?? "") }),
635
+ multiSelect: q.multiple === true,
636
+ allowCustomAnswer: q.custom !== false,
637
+ }));
638
+ const requestId = `oc-q:${id}`;
639
+ this.questions.set(requestId, { id, sid: String(props.sessionID ?? this.activeSid ?? this.sessionId ?? ""), count: questions.length });
640
+ this.emit({
641
+ kind: "needs_input",
642
+ payload: { question: questions.map((q) => q.question).join("\n").slice(0, 500), questions, requestId, responseMode: "tool" },
643
+ });
644
+ }
645
+ /** Answer an open opencode question. answers: { [questionIndex]: label | labels }; none at all dismisses it. */
646
+ async respondToUserInput(_chat, requestId, answers) {
647
+ const open = this.questions.get(requestId);
648
+ if (!open || !this.baseUrl)
649
+ throw new Error(`no open opencode question "${requestId}"`);
650
+ const picks = Array.from({ length: open.count }, (_v, i) => {
651
+ const a = answers[String(i)];
652
+ return (Array.isArray(a) ? a : a == null || a === "" ? [] : [a]).map((x) => String(x));
653
+ });
654
+ const dismiss = picks.every((p) => !p.length);
655
+ // the session-scoped route on current builds, the global one on older
656
+ const routes = this.modernApi ? [`${this.baseUrl}/question/${encodeURIComponent(open.id)}/${dismiss ? "reject" : "reply"}`] : [
657
+ `${this.baseUrl}/api/session/${encodeURIComponent(open.sid)}/question/${encodeURIComponent(open.id)}/${dismiss ? "reject" : "reply"}`,
658
+ `${this.baseUrl}/question/${encodeURIComponent(open.id)}/${dismiss ? "reject" : "reply"}`,
659
+ ];
660
+ let last = "";
661
+ for (const url of routes) {
662
+ const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" },
663
+ body: JSON.stringify(dismiss ? {} : { answers: picks }), signal: AbortSignal.timeout(15_000) }).catch((e) => ({ ok: false, status: 0, text: async () => e.message }));
664
+ if (res.ok) {
665
+ this.questions.delete(requestId);
666
+ this.emit({ kind: "status", payload: { state: "question_answered", requestId, answers } });
667
+ return;
668
+ }
669
+ last = `${res.status} ${(await res.text().catch(() => "")).slice(0, 160)}`;
670
+ if (res.status !== 404)
671
+ break;
672
+ }
673
+ throw new Error(`opencode didn't take the answer (${last})`);
674
+ }
675
+ /**
676
+ * opencode asking to run a command, edit a file, fetch a URL: the same
677
+ * approval card Loom shows for any agent, and opencode hears once / always / no.
678
+ */
679
+ async askPermission(props, v2) {
680
+ const id = String(props.id ?? "");
681
+ if (!id || !this.baseUrl)
682
+ return;
683
+ const sid = String(props.sessionID ?? this.activeSid ?? this.sessionId ?? "");
684
+ const tool = String((v2 ? props.action : props.permission) ?? "permission");
685
+ const targets = (Array.isArray(v2 ? props.resources : props.patterns) ? (v2 ? props.resources : props.patterns) : []).map(String);
686
+ const meta = (props.metadata ?? {});
687
+ const summary = String(meta.command ?? meta.description ?? meta.filePath ?? meta.url ?? (targets.length ? `${tool} ${targets.join(", ")}` : tool)).slice(0, 300);
688
+ const decision = await requestApproval({
689
+ project: this.options.loomProject ?? "",
690
+ agent: this.id, tool, input: { ...meta, ...(targets.length ? { targets } : {}) }, summary, sessionOption: true,
691
+ });
692
+ const reply = decision.behavior !== "allow" ? "reject" : decision.scope === "session" ? "always" : "once";
693
+ const body = JSON.stringify({ reply, ...(decision.message ? { message: decision.message } : {}) });
694
+ for (const url of this.modernApi ? [`${this.baseUrl}/permission/${encodeURIComponent(id)}/reply`] : [`${this.baseUrl}/api/session/${encodeURIComponent(sid)}/permission/${encodeURIComponent(id)}/reply`, `${this.baseUrl}/permission/${encodeURIComponent(id)}/reply`]) {
695
+ const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, signal: AbortSignal.timeout(15_000) }).catch(() => null);
696
+ if (res?.ok)
697
+ return;
698
+ if (res && res.status !== 404)
699
+ break;
700
+ }
701
+ logbook.warn(`agent:${this.id}`, "opencode didn't take the permission reply", id);
702
+ }
703
+ /** opencode 1.18's tool and compaction events, for a session already known to be ours. */
704
+ handleSessionNext(type, props) {
705
+ const callId = String(props.callID ?? "");
706
+ if (type === "session.next.tool.called") {
707
+ const input = (props.input ?? {});
708
+ const title = String(input.description ?? input.command ?? input.filePath ?? input.path ?? input.pattern ?? props.tool ?? "tool");
709
+ this.toolCalls.set(callId, { tool: String(props.tool ?? "tool"), title: title.slice(0, 200), input });
710
+ return;
711
+ }
712
+ if (type === "session.next.tool.success" || type === "session.next.tool.failed") {
713
+ const call = this.toolCalls.get(callId) ?? { tool: "tool", title: "tool" };
714
+ this.toolCalls.delete(callId);
715
+ // the same call also arrives as a message part on these builds: log it once
716
+ if (callId && this.toolsDone.has(callId))
717
+ return;
718
+ if (callId)
719
+ this.toolsDone.add(callId);
720
+ const err = (props.error ?? {});
721
+ // the result: text blocks are its output, file blocks with an image type are pictures it made or read
722
+ const content = Array.isArray(props.content) ? props.content : [];
723
+ const output = content.filter((c) => c.type === "text" && typeof c.text === "string").map((c) => String(c.text)).join("\n");
724
+ const images = content.filter((c) => c.type === "file" && /^image\//.test(String(c.mime ?? ""))).flatMap((c) => this.keepImage(String(c.uri ?? ""), String(c.mime)));
725
+ // bash "succeeds" when its command ran, failing or not: the exit code it reports says which
726
+ const structured = (props.structured ?? {});
727
+ const exitRaw = structured.exit ?? structured.metadata?.exit;
728
+ const exitCode = typeof exitRaw === "number" ? exitRaw : undefined;
729
+ const input = call.input ? Object.fromEntries(Object.entries(call.input).slice(0, 20).map(([k, v]) => [k, typeof v === "string" && v.length > 600 ? `${v.slice(0, 600)}\u2026` : v])) : undefined;
730
+ this.emit({
731
+ kind: "tool_call",
732
+ payload: { tool: call.tool, summary: call.title, ok: !type.endsWith("failed") && (exitCode === undefined || exitCode === 0),
733
+ ...(exitCode !== undefined ? { exitCode } : {}),
734
+ ...(call.tool === "bash" && typeof call.input?.command === "string" ? { command: String(call.input.command).slice(0, 2_000) } : {}),
735
+ ...(input && Object.keys(input).length ? { input } : {}),
736
+ ...(output.trim() ? { preview: output.length > 4000 ? `\u2026\n${output.slice(-4000)}` : output } : {}),
737
+ ...(images.length ? { images } : {}),
738
+ ...(type.endsWith("failed") ? { error: String(err.message ?? "failed").slice(0, 1500) } : {}) },
739
+ });
740
+ if (call.tool === "todowrite" && Array.isArray(call.input?.todos))
741
+ this.emitPlan(call.input.todos);
742
+ return;
743
+ }
744
+ if (type === "session.next.compaction.started") {
745
+ this.compactionReported = false;
746
+ this.emit({ kind: "status", payload: { state: "compacting", reason: String(props.reason ?? "auto") } });
747
+ return;
748
+ }
749
+ if ((type === "session.next.compaction.ended" || type === "session.compacted") && !this.compactionReported) {
750
+ this.compactionReported = true;
751
+ this.emit({ kind: "status", payload: { state: "native_compacted", session: String(props.sessionID ?? this.activeSid ?? "") } });
285
752
  }
286
753
  }
287
754
  /** All messages in the session, oldest first (info objects). */
288
755
  async listMessages(sid) {
289
- const res = await fetchJson(`${this.baseUrl}/api/session/${sid}/message`);
756
+ const res = await fetchJson(this.api(`/session/${sid}/message`));
290
757
  const data = (res.data ?? res);
291
758
  const items = Array.isArray(data) ? data : [];
292
759
  return items
@@ -301,9 +768,23 @@ export class OpenCodeAdapter extends AdapterBase {
301
768
  * fact holds across two consecutive polls (nothing new started).
302
769
  * `/wait` is tried first but returns 503 on 1.17.
303
770
  */
771
+ /** Is opencode still running this session? Null when this opencode can't say (older builds). */
772
+ async sessionActive(sid) {
773
+ const res = await fetchJson(this.modernApi ? this.api("/session/status") : this.api("/session/active")).catch(() => null);
774
+ const active = res ? (res.data ?? res) : null;
775
+ if (!active || typeof active !== "object" || Array.isArray(active))
776
+ return null;
777
+ if (!Object.prototype.hasOwnProperty.call(active, sid))
778
+ return false;
779
+ if (!this.modernApi)
780
+ return true;
781
+ const type = active[sid]?.type;
782
+ return type === "idle" ? false : type === "busy" || type === "retry" ? true : null;
783
+ }
304
784
  async waitForTurn(sid, baseline, timeoutMs) {
305
785
  try {
306
- await fetchJson(`${this.baseUrl}/api/session/${sid}/wait`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }, timeoutMs);
786
+ if (!this.modernApi)
787
+ await fetchJson(`${this.baseUrl}/api/session/${sid}/wait`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }, timeoutMs);
307
788
  }
308
789
  catch {
309
790
  // 503 "not available yet" on 1.17 — fall through to polling.
@@ -311,6 +792,13 @@ export class OpenCodeAdapter extends AdapterBase {
311
792
  const deadline = Date.now() + timeoutMs;
312
793
  let stableId = null;
313
794
  while (Date.now() < deadline) {
795
+ // Interrupted: done as soon as opencode stops running it, answer or not
796
+ // (one that never answered would otherwise be waited on for the hour).
797
+ if (this.interrupted && (await this.sessionActive(sid)) !== true) {
798
+ const after = await this.listMessages(sid).catch(() => []);
799
+ const last = after.filter((m) => (m.type ?? m.role) === "assistant" && !baseline.has(String(m.id))).at(-1);
800
+ return last ?? null;
801
+ }
314
802
  const messages = await this.listMessages(sid).catch(() => []);
315
803
  const newest = messages[messages.length - 1];
316
804
  const newAssistants = messages.filter((m) => (m.type ?? m.role) === "assistant" &&
@@ -321,88 +809,372 @@ export class OpenCodeAdapter extends AdapterBase {
321
809
  newest.time?.completed &&
322
810
  newAssistants.length > 0;
323
811
  if (turnLooksDone) {
324
- // Errors are terminal immediately; otherwise require stability
325
- // across two polls so multi-step turns aren't cut short.
812
+ // Errors are terminal immediately. A finished *step* is not a finished
813
+ // turn: one that ended in tool calls is followed by another, and a slow
814
+ // model can take longer than a poll to start it — so a session opencode
815
+ // still lists as active, or a step that finished on tool calls, keeps
816
+ // waiting. Otherwise require stability across two polls.
326
817
  if (newest.finish === "error" || newest.error)
327
818
  return newest;
328
- if (stableId === String(newest.id))
819
+ const midTurn = /tool/i.test(String(newest.finish ?? "")) || (await this.sessionActive(sid)) === true;
820
+ if (midTurn)
821
+ stableId = null;
822
+ else if (stableId === String(newest.id))
329
823
  return newest;
330
- stableId = String(newest.id);
824
+ else
825
+ stableId = String(newest.id);
331
826
  }
332
827
  else {
333
828
  stableId = null;
334
829
  }
335
- await new Promise((r) => setTimeout(r, 3000));
830
+ await new Promise((r) => setTimeout(r, typeof this.options.pollMs === "number" ? this.options.pollMs : 3000));
336
831
  }
337
832
  return null;
338
833
  }
339
834
  async send(input) {
835
+ if (input.continuity)
836
+ return this.sendContinuity(input);
837
+ this.cancelIdle();
340
838
  if (!this.started)
341
839
  await this.start();
342
840
  if (this._busy)
343
841
  throw new Error(`opencode agent "${this.id}" is busy`);
344
842
  this._busy = true;
843
+ this.trimTurnState();
844
+ this.interrupted = false;
345
845
  const started = Date.now();
346
846
  const timeoutMs = 60 * 60 * 1000;
347
847
  try {
348
- const sid = await this.ensureSession();
349
- const baseline = new Set((await this.listMessages(sid).catch(() => [])).map((m) => String(m.id)));
350
- // No per-prompt system field in the API, so the handoff briefing rides in
351
- // the prompt — framed as an unmissable authoritative block (frameBriefing)
352
- // rather than a loose preamble.
353
- const text = input.briefing ? `${frameBriefing(input.briefing)}\n\n${input.text}` : input.text;
354
- await fetchJson(`${this.baseUrl}/api/session/${sid}/prompt`, {
355
- method: "POST",
356
- headers: { "content-type": "application/json" },
357
- body: JSON.stringify({ prompt: { text } }),
358
- });
359
- const turn = await this.waitForTurn(sid, baseline, timeoutMs);
360
- if (!turn) {
361
- this.emit({ kind: "error", payload: { message: "turn timed out waiting for opencode" } });
362
- }
363
- else {
364
- const turnId = String(turn.id);
365
- // Fetch the full message: parts (in case SSE missed them) + errors.
366
- const detail = await fetchJson(`${this.baseUrl}/api/session/${sid}/message/${turnId}`).catch(() => null);
367
- const info = (detail?.data ?? detail ?? turn) ?? turn;
368
- if (info.finish === "error" || info.error) {
369
- const err = (info.error ?? {});
370
- this.emit({
371
- kind: "error",
372
- payload: {
373
- message: String(err.message ?? "opencode turn failed").slice(0, 500),
374
- },
375
- });
848
+ // One retry, in a fresh session, when opencode refuses the session's own
849
+ // history: a single bad picture in it (media that isn't valid base64)
850
+ // is replayed on every later turn and would lock this agent out for good.
851
+ for (let attempt = 0;; attempt++) {
852
+ const sid = await this.ensureSession();
853
+ const baseline = new Set((await this.listMessages(sid).catch(() => [])).map((m) => String(m.id)));
854
+ // No per-prompt system field in the API, so the handoff briefing rides in
855
+ // the prompt — framed as an unmissable authoritative block (frameBriefing)
856
+ // rather than a loose preamble.
857
+ const text = input.briefing ? `${frameBriefing(input.briefing)}\n\n${input.text}` : input.text;
858
+ const admission = await fetch(this.api(`/session/${sid}/${this.modernApi ? "prompt_async" : "prompt"}`), {
859
+ method: "POST",
860
+ headers: { "content-type": "application/json" },
861
+ body: JSON.stringify(this.promptBody(text)),
862
+ signal: AbortSignal.timeout(30_000),
863
+ });
864
+ if (!admission.ok)
865
+ throw new Error(`opencode prompt failed (${admission.status}): ${(await admission.text()).slice(0, 500)}`);
866
+ await admission.arrayBuffer();
867
+ const turn = await this.waitForTurn(sid, baseline, timeoutMs);
868
+ if (!turn && this.interrupted) {
869
+ this.emit({ kind: "status", payload: { state: "interrupted" } });
376
870
  }
377
- else if (!this.emittedText.has(turnId)) {
378
- const content = Array.isArray(info.content) ? info.content : [];
379
- const text = content
380
- .filter((p) => p.type === "text" && typeof p.text === "string")
381
- .map((p) => String(p.text))
382
- .join("")
383
- .trim();
384
- if (text) {
385
- this.emit({ kind: "message", payload: { text } });
386
- this.emittedText.add(turnId);
871
+ else if (!turn) {
872
+ this.emit({ kind: "error", payload: { message: "turn timed out waiting for opencode" } });
873
+ }
874
+ else {
875
+ const turnId = String(turn.id);
876
+ // Fetch the full message: parts (in case SSE missed them) + errors.
877
+ const detail = await this.messageDetail(sid, turnId);
878
+ const info = (detail?.data ?? detail ?? turn) ?? turn;
879
+ if (info.finish === "error" || info.error) {
880
+ const err = (info.error ?? {});
881
+ const message = turnError(err);
882
+ if (attempt === 0 && POISONED_HISTORY.test(message)) {
883
+ this.sessionId = undefined;
884
+ this.emit({ kind: "status", payload: { state: "session_reset", reason: `opencode refused this session's history (${message}); carrying on in a fresh session` } });
885
+ continue;
886
+ }
887
+ this.emit({ kind: "error", payload: { message } });
387
888
  }
889
+ else if (!this.emittedText.has(turnId)) {
890
+ const content = Array.isArray(info.content) ? info.content : [];
891
+ const text = content
892
+ .filter((p) => p.type === "text" && typeof p.text === "string")
893
+ .map((p) => String(p.text))
894
+ .join("")
895
+ .trim();
896
+ if (text) {
897
+ this.emit({ kind: "message", payload: { text } });
898
+ this.emittedText.add(turnId);
899
+ }
900
+ }
901
+ const cost = Number(info.cost ?? 0);
902
+ if (cost > 0) {
903
+ this.emit({ kind: "status", payload: { state: "turn_cost", costUsd: cost } });
904
+ }
905
+ // OpenCode assistant messages carry token usage; capture it (cache
906
+ // reads/writes count as input) so it isn't dropped.
907
+ const tk = (info.tokens ?? {});
908
+ const cache = (tk.cache ?? {});
909
+ this.lastUsage = {
910
+ input: (tk.input ?? 0) + (cache.read ?? 0) + (cache.write ?? 0),
911
+ output: (tk.output ?? 0) + (tk.reasoning ?? 0),
912
+ cached: cache.read ?? 0,
913
+ reasoning: tk.reasoning ?? 0,
914
+ };
915
+ const mid = info.modelID;
916
+ const pid = info.providerID;
917
+ if (typeof mid === "string" && mid)
918
+ this.lastModel = typeof pid === "string" && pid ? `${pid}/${mid}` : mid;
388
919
  }
389
- const cost = Number(info.cost ?? 0);
390
- if (cost > 0) {
391
- this.emit({ kind: "status", payload: { state: "turn_cost", costUsd: cost } });
920
+ break;
921
+ }
922
+ this.emit({
923
+ kind: "run_complete",
924
+ payload: {
925
+ durationMs: Date.now() - started,
926
+ ...((this.lastModel ?? this.options.model) ? { model: this.lastModel ?? this.options.model } : {}),
927
+ ...(this.lastUsage ? usagePayload(this.lastUsage) : {}),
928
+ },
929
+ });
930
+ this.lastUsage = null;
931
+ this.lastModel = null;
932
+ }
933
+ finally {
934
+ this._busy = false;
935
+ this.armIdle();
936
+ }
937
+ }
938
+ /**
939
+ * A continuity turn (see the header): the binding's session, the admission as
940
+ * acceptance, and the session leaving `/api/session/active` as the end.
941
+ * Failures before the prompt is admitted are NativeDispatchRejected — nothing
942
+ * reached opencode — and a lost acknowledgement is never retried.
943
+ */
944
+ async sendContinuity(input) {
945
+ const c = input.continuity;
946
+ if (this._busy)
947
+ throw new NativeDispatchRejected(`opencode agent "${this.id}" is busy`);
948
+ this.cancelIdle();
949
+ this._busy = true;
950
+ this.trimTurnState();
951
+ this.interrupted = false;
952
+ this.beginContinuity(input);
953
+ const started = Date.now();
954
+ let admitted = false;
955
+ try {
956
+ if (!this.started) {
957
+ try {
958
+ await this.start();
959
+ }
960
+ catch (err) {
961
+ throw new NativeDispatchRejected(`opencode didn't start: ${err.message}`);
392
962
  }
393
963
  }
394
- this.emit({ kind: "run_complete", payload: { durationMs: Date.now() - started } });
964
+ const sid = await this.continuitySession(c.nativeSessionId, input.model);
965
+ this.activeSid = sid;
966
+ this.emit({ kind: "status", payload: { state: "turn_started", session: sid } });
967
+ if (this.interrupted)
968
+ throw new NativeDispatchRejected("interrupted before the turn started");
969
+ const baseline = new Set((await this.listMessages(sid).catch(() => [])).map((m) => String(m.id)));
970
+ const text = [c.context, input.briefing, input.text].filter(Boolean).join("\n\n");
971
+ let res;
972
+ try {
973
+ res = await fetch(this.api(`/session/${encodeURIComponent(sid)}/${this.modernApi ? "prompt_async" : "prompt"}`), {
974
+ method: "POST",
975
+ headers: { "content-type": "application/json" },
976
+ body: JSON.stringify(this.modernApi ? { ...this.promptBody(text), ...(input.model ? { model: { providerID: parseModelRef(input.model)?.providerID, modelID: parseModelRef(input.model)?.id } } : {}) } : { prompt: promptWithFiles(text, this.projectDir) }),
977
+ signal: AbortSignal.timeout(30_000),
978
+ });
979
+ }
980
+ catch (err) {
981
+ // The request may or may not have reached the server: unknown, not rejected.
982
+ throw new Error(`opencode prompt request failed: ${err.message}; native outcome is unknown`);
983
+ }
984
+ if (!res.ok) {
985
+ const body = await res.text().catch(() => "");
986
+ throw new NativeDispatchRejected(`opencode refused the prompt (${res.status}): ${body.slice(0, 300)}`);
987
+ }
988
+ if (this.modernApi && res.status !== 204)
989
+ throw new Error(`opencode prompt returned unexpected admission status ${res.status}; native outcome is unknown`);
990
+ const ack = (await res.json().catch(() => ({}))).data;
991
+ admitted = true;
992
+ this.emit({ kind: "status", payload: { state: "native_turn_accepted", session: sid, ...(ack?.id ? { messageId: String(ack.id) } : {}) } });
993
+ const idle = await this.waitIdle(sid, baseline, this.turnTimeoutMs());
994
+ if (!idle) {
995
+ this.emit({ kind: "error", payload: { message: "opencode was still working when the turn timed out" } });
996
+ throw new NativeQuiescenceUnknown("opencode still lists the session as active; quiescence unknown");
997
+ }
998
+ if (this.interrupted) {
999
+ this.emit({ kind: "status", payload: { state: "interrupted" } });
1000
+ return;
1001
+ }
1002
+ const messages = await this.listMessages(sid).catch(() => []);
1003
+ const turn = [...messages].reverse().find((m) => (m.type ?? m.role) === "assistant" && !baseline.has(String(m.id)));
1004
+ if (!turn) {
1005
+ const message = "opencode finished without a reply";
1006
+ this.emit({ kind: "error", payload: { message } });
1007
+ throw new Error(message);
1008
+ }
1009
+ const failed = await this.reportTurn(sid, turn);
1010
+ if (failed)
1011
+ throw new Error(`opencode reported a failed turn: ${failed}`);
1012
+ this.emit({
1013
+ kind: "run_complete",
1014
+ payload: {
1015
+ durationMs: Date.now() - started,
1016
+ session: sid,
1017
+ ...((this.lastModel ?? this.options.model) ? { model: this.lastModel ?? this.options.model } : {}),
1018
+ ...(this.lastUsage ? usagePayload(this.lastUsage) : {}),
1019
+ },
1020
+ });
1021
+ }
1022
+ catch (err) {
1023
+ if (!admitted && !(err instanceof NativeSessionMissing) && !(err instanceof NativeDispatchRejected) && !/native outcome is unknown/.test(String(err.message))) {
1024
+ throw new NativeDispatchRejected(err.message);
1025
+ }
1026
+ throw err;
395
1027
  }
396
1028
  finally {
1029
+ this.lastUsage = null;
1030
+ this.lastModel = null;
1031
+ this.activeSid = undefined;
397
1032
  this._busy = false;
1033
+ this.endContinuity();
1034
+ this.armIdle();
1035
+ }
1036
+ }
1037
+ turnTimeoutMs() {
1038
+ const t = this.options.turnTimeoutMs;
1039
+ return typeof t === "number" && t > 0 ? t : 60 * 60 * 1000;
1040
+ }
1041
+ /** The session a continuity turn runs on: its binding's, or a new one. Nothing is sent yet. */
1042
+ async continuitySession(bound, turnModel) {
1043
+ const want = turnModel ?? this.options.model;
1044
+ const ref = want ? parseModelRef(want) : null;
1045
+ if (bound) {
1046
+ let res;
1047
+ for (let attempt = 0;; attempt++) {
1048
+ try {
1049
+ res = await fetch(this.api(`/session/${encodeURIComponent(bound)}`), { signal: AbortSignal.timeout(15_000) });
1050
+ break;
1051
+ }
1052
+ catch (err) {
1053
+ // A stale keep-alive socket can reset between native turns. Only
1054
+ // this read-only preflight is retried; prompt admission never is.
1055
+ if (attempt === 0)
1056
+ continue;
1057
+ throw new NativeDispatchRejected(`opencode server unreachable: ${err.message}`);
1058
+ }
1059
+ }
1060
+ if (res.status === 404)
1061
+ throw new NativeSessionMissing(`opencode no longer has session ${bound}`);
1062
+ if (!res.ok)
1063
+ throw new NativeDispatchRejected(`opencode answered ${res.status} for session ${bound}`);
1064
+ const info = ((await res.json().catch(() => ({}))).data ?? {});
1065
+ const cur = (info.model ?? {});
1066
+ // A model change keeps the session, as it does for the other harnesses.
1067
+ if (!this.modernApi && ref && (cur.providerID !== ref.providerID || cur.id !== ref.id)) {
1068
+ try {
1069
+ await fetchJson(`${this.baseUrl}/api/session/${encodeURIComponent(bound)}/model`, {
1070
+ method: "POST",
1071
+ headers: { "content-type": "application/json" },
1072
+ body: JSON.stringify({ model: ref }),
1073
+ });
1074
+ }
1075
+ catch (err) {
1076
+ throw new NativeDispatchRejected(`opencode couldn't switch the session to ${want}: ${err.message}`);
1077
+ }
1078
+ }
1079
+ return bound;
398
1080
  }
1081
+ const body = {};
1082
+ if (ref)
1083
+ body.model = ref;
1084
+ if (this.options.agent)
1085
+ body.agent = this.options.agent;
1086
+ try {
1087
+ const created = await fetchJson(this.api("/session"), {
1088
+ method: "POST",
1089
+ headers: { "content-type": "application/json" },
1090
+ body: JSON.stringify(this.modernApi ? {} : body),
1091
+ });
1092
+ const id = String((created.data ?? created).id ?? "");
1093
+ if (!id)
1094
+ throw new Error("no session id in the reply");
1095
+ return id;
1096
+ }
1097
+ catch (err) {
1098
+ throw new NativeDispatchRejected(`opencode couldn't create a session: ${err.message}`);
1099
+ }
1100
+ }
1101
+ /**
1102
+ * Wait until opencode no longer lists the session as active. Before it has
1103
+ * been seen running, an idle session only counts as done once a new
1104
+ * completed assistant message exists — the prompt may not have started yet.
1105
+ */
1106
+ async waitIdle(sid, baseline, timeoutMs) {
1107
+ const pollMs = typeof this.options.pollMs === "number" ? this.options.pollMs : 500;
1108
+ const deadline = Date.now() + timeoutMs;
1109
+ let sawRunning = false;
1110
+ while (Date.now() < deadline) {
1111
+ const active = await this.sessionActive(sid);
1112
+ if (active === true)
1113
+ sawRunning = true;
1114
+ else if (active === false) {
1115
+ if (sawRunning || this.interrupted)
1116
+ return true;
1117
+ const messages = await this.listMessages(sid).catch(() => []);
1118
+ if (messages.some((m) => (m.type ?? m.role) === "assistant" && !baseline.has(String(m.id)) && m.time?.completed))
1119
+ return true;
1120
+ }
1121
+ await new Promise((r) => setTimeout(r, pollMs));
1122
+ }
1123
+ return false;
1124
+ }
1125
+ /**
1126
+ * Emit a finished assistant message's text (unless the stream already did),
1127
+ * its cost, and stash its usage and model for run_complete. Returns the
1128
+ * error message when opencode says the turn failed.
1129
+ */
1130
+ async reportTurn(sid, turn) {
1131
+ const turnId = String(turn.id);
1132
+ const detail = await this.messageDetail(sid, turnId);
1133
+ const info = (detail?.data ?? detail ?? turn) ?? turn;
1134
+ if (info.finish === "error" || info.error) {
1135
+ const err = (info.error ?? {});
1136
+ const message = turnError(err);
1137
+ this.emit({ kind: "error", payload: { message } });
1138
+ return message;
1139
+ }
1140
+ if (!this.emittedText.has(turnId)) {
1141
+ const content = Array.isArray(info.content) ? info.content : [];
1142
+ const text = content
1143
+ .filter((p) => p.type === "text" && typeof p.text === "string")
1144
+ .map((p) => String(p.text))
1145
+ .join("")
1146
+ .trim();
1147
+ if (text) {
1148
+ this.emit({ kind: "message", payload: { text } });
1149
+ this.emittedText.add(turnId);
1150
+ }
1151
+ }
1152
+ const cost = Number(info.cost ?? 0);
1153
+ if (cost > 0)
1154
+ this.emit({ kind: "status", payload: { state: "turn_cost", costUsd: cost } });
1155
+ const tk = (info.tokens ?? {});
1156
+ const cache = (tk.cache ?? {});
1157
+ this.lastUsage = {
1158
+ input: (tk.input ?? 0) + (cache.read ?? 0) + (cache.write ?? 0),
1159
+ output: (tk.output ?? 0) + (tk.reasoning ?? 0),
1160
+ cached: cache.read ?? 0,
1161
+ reasoning: tk.reasoning ?? 0,
1162
+ };
1163
+ const mid = info.modelID, pid = info.providerID;
1164
+ const model = (info.model ?? {});
1165
+ if (typeof mid === "string" && mid)
1166
+ this.lastModel = typeof pid === "string" && pid ? `${pid}/${mid}` : mid;
1167
+ else if (typeof model.id === "string")
1168
+ this.lastModel = typeof model.providerID === "string" ? `${model.providerID}/${model.id}` : model.id;
1169
+ return null;
399
1170
  }
400
1171
  async interrupt() {
401
- const sid = this.sessionId;
1172
+ this.interrupted = true;
1173
+ const sid = this.activeSid ?? this.sessionId;
402
1174
  if (!sid || !this.baseUrl)
403
1175
  return;
404
1176
  try {
405
- await fetchJson(`${this.baseUrl}/api/session/${sid}/interrupt`, {
1177
+ await fetchJson(this.api(`/session/${sid}/${this.modernApi ? "abort" : "interrupt"}`), {
406
1178
  method: "POST",
407
1179
  headers: { "content-type": "application/json" },
408
1180
  body: "{}",
@@ -413,11 +1185,15 @@ export class OpenCodeAdapter extends AdapterBase {
413
1185
  }
414
1186
  }
415
1187
  async stop() {
1188
+ this.cancelIdle();
416
1189
  this.started = false;
417
1190
  this.sseAbort?.abort();
418
1191
  this.sseAbort = null;
419
1192
  if (this.child) {
420
- this.child.kill("SIGTERM");
1193
+ if (process.platform === "win32")
1194
+ await stopWindowsTree(this.child);
1195
+ else
1196
+ await interruptProcess(this.child, true);
421
1197
  this.child = null;
422
1198
  }
423
1199
  this.emit({ kind: "status", payload: { state: "stopped" } });