@openchambery/web 1.19.21-beta.1 → 2.0.0-beta.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 (195) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-Bh06xinl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-f2agCc4q.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-ChD4WD2v.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-D7SG0ueZ.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-CjCw1X4n.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-C2DgbXJ3.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-lubDPKMs.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-CzgW3-Cb.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-BKc-qXbQ.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-BTkP7aYI.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/multirun-BvSG94mW.js +4 -0
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/projectMeta-DOe49dM_.js +3 -0
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-PpnZiwcW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BGjzNuQl.js → runtimeEndpointReset-B6BxhvR0.js} +6 -6
  52. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +2 -0
  53. package/dist/assets/{sessionLookup-CbbzalcH.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/src-BzX5UI-c.css +2 -0
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/textarea-3XR8sUfy.js +1 -0
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-BJBcy-84.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +1 -0
  63. package/dist/assets/{useEffectiveDirectory-CzHynoi_.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-DzV9N6G-.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-Bi9ASKr_.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BtGYjSW5.js +0 -3
  168. package/dist/assets/MarkdownRenderer-AwDKOCvD.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-B6fGRYsg.js +0 -1
  170. package/dist/assets/MobileSurface-BY7eErHN.js +0 -2
  171. package/dist/assets/SettingsView-lcRi35Y3.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CcIuekQ3.js +0 -7
  174. package/dist/assets/insertionBoundaries-BocNNP9Q.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-CFLmpNC5.js +0 -169
  178. package/dist/assets/main-Ckj_IpUy.js +0 -2
  179. package/dist/assets/miniChat-Dq1CTKNs.js +0 -2
  180. package/dist/assets/mobile-DlJ0Yr7h.js +0 -2
  181. package/dist/assets/multirun-BGUKIJys.js +0 -4
  182. package/dist/assets/pl-DW2XJGC4.js +0 -15
  183. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  184. package/dist/assets/projectMeta-Bft2t6GF.js +0 -3
  185. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  186. package/dist/assets/renderElectronMiniChatApp-CTbXbB0Y.js +0 -2
  187. package/dist/assets/renderMobileApp-DLewUqoE.js +0 -28
  188. package/dist/assets/sessionChangeSummary-DiqwX3vZ.js +0 -2
  189. package/dist/assets/src-rS3zCcsC.css +0 -2
  190. package/dist/assets/textarea-DpK2Z0Gp.js +0 -1
  191. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  192. package/dist/assets/useDesktopWindowControlsLayout-3Bm0rXCQ.js +0 -1
  193. package/dist/assets/useMobileNavigationStore-BIyCYi_z.js +0 -413
  194. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  195. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
package/dist/index.html CHANGED
@@ -552,7 +552,7 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-Ckj_IpUy.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-CCE_4y5F.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -566,14 +566,14 @@
566
566
  <link rel="modulepreload" crossorigin href="/assets/persistence-Ce3AnpEr.js">
567
567
  <link rel="modulepreload" crossorigin href="/assets/desktopHosts-B1ixEylr.js">
568
568
  <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-4JmFUw8r.js">
569
- <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
569
+ <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-client-BOe2qpQb.js">
570
570
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
571
- <link rel="modulepreload" crossorigin href="/assets/platform-0w_Q9_ji.js">
572
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-xjqssKke.js">
571
+ <link rel="modulepreload" crossorigin href="/assets/platform-CWTIp02q.js">
572
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-DiF6JmBP.js">
573
573
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
574
574
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
575
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-PpnZiwcW.js">
576
- <link rel="stylesheet" crossorigin href="/assets/src-rS3zCcsC.css">
575
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BMvqa5we.js">
576
+ <link rel="stylesheet" crossorigin href="/assets/src-BzX5UI-c.css">
577
577
  </head>
578
578
  <body class="h-full bg-background text-foreground">
579
579
  <!-- Keep the startup layer outside #root. React replaces every child of
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-Dq1CTKNs.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-BpjxVqgg.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -88,14 +88,14 @@
88
88
  <link rel="modulepreload" crossorigin href="/assets/persistence-Ce3AnpEr.js">
89
89
  <link rel="modulepreload" crossorigin href="/assets/desktopHosts-B1ixEylr.js">
90
90
  <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-4JmFUw8r.js">
91
- <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
91
+ <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-client-BOe2qpQb.js">
92
92
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
93
- <link rel="modulepreload" crossorigin href="/assets/platform-0w_Q9_ji.js">
94
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-xjqssKke.js">
93
+ <link rel="modulepreload" crossorigin href="/assets/platform-CWTIp02q.js">
94
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-DiF6JmBP.js">
95
95
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
96
96
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
97
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-PpnZiwcW.js">
98
- <link rel="stylesheet" crossorigin href="/assets/src-rS3zCcsC.css">
97
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BMvqa5we.js">
98
+ <link rel="stylesheet" crossorigin href="/assets/src-BzX5UI-c.css">
99
99
  </head>
100
100
  <body class="h-full bg-background text-foreground">
101
101
  <div id="root" class="h-full">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-DlJ0Yr7h.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-nMJCaNq1.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -77,14 +77,14 @@
77
77
  <link rel="modulepreload" crossorigin href="/assets/persistence-Ce3AnpEr.js">
78
78
  <link rel="modulepreload" crossorigin href="/assets/desktopHosts-B1ixEylr.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-4JmFUw8r.js">
80
- <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
80
+ <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-client-BOe2qpQb.js">
81
81
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
82
- <link rel="modulepreload" crossorigin href="/assets/platform-0w_Q9_ji.js">
83
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-xjqssKke.js">
82
+ <link rel="modulepreload" crossorigin href="/assets/platform-CWTIp02q.js">
83
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-DiF6JmBP.js">
84
84
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
85
85
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
86
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-PpnZiwcW.js">
87
- <link rel="stylesheet" crossorigin href="/assets/src-rS3zCcsC.css">
86
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BMvqa5we.js">
87
+ <link rel="stylesheet" crossorigin href="/assets/src-BzX5UI-c.css">
88
88
  </head>
89
89
  <body class="h-full bg-background text-foreground">
90
90
  <div id="root" class="h-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.19.21-beta.1",
3
+ "version": "2.0.0-beta.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -27,7 +27,7 @@
27
27
  "@clack/prompts": "^1.1.0",
28
28
  "@earendil-works/pi-agent-core": "0.84.4",
29
29
  "@octokit/rest": "^22.0.1",
30
- "@opencode-ai/sdk": "1.18.4",
30
+ "@opencode/client": "2.0.12",
31
31
  "@simplewebauthn/server": "13.3.1",
32
32
  "adm-zip": "^0.5.16",
33
33
  "better-sqlite3": "^12.10.0",
package/server/index.js CHANGED
@@ -69,6 +69,7 @@ import { createMessageQueueRuntime } from './lib/message-queue/runtime.js';
69
69
  import { resolveMessageQueueDbPath } from './lib/message-queue/resolve-db-path.js';
70
70
  import { createOpenChamberEventBroadcaster } from './lib/opencode/feature-routes-runtime.js';
71
71
  import { createSessionGoalRuntime } from './lib/session-goal/runtime.js';
72
+ import { createSessionMetadataStore } from './lib/session-metadata/session-metadata-store.js';
72
73
  import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
73
74
  import { createScheduledTaskRunHistoryStore } from './lib/scheduled-tasks/run-history-store.js';
74
75
  import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js';
@@ -445,6 +446,7 @@ let openCodeApiDetectionTimer = null;
445
446
  let lastOpenCodeError = null;
446
447
  let lastOpenCodeLaunchDiagnostics = null;
447
448
  let isOpenCodeReady = false;
449
+ let v1Migration = null;
448
450
  let openCodeNotReadySince = 0;
449
451
  let isExternalOpenCode = false;
450
452
  let exitOnShutdown = true;
@@ -614,6 +616,7 @@ const openCodeEnvRuntime = createOpenCodeEnvRuntime({
614
616
  const applyLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.applyLoginShellEnvSnapshot(...args);
615
617
  const getLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.getLoginShellEnvSnapshot(...args);
616
618
  const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args);
619
+ const ensurePinnedOpenCode2CliEnv = (...args) => openCodeEnvRuntime.ensurePinnedOpenCode2CliEnv(...args);
617
620
  const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args);
618
621
  const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args);
619
622
  const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args);
@@ -713,6 +716,8 @@ let questionAutoDelegateRuntime = null;
713
716
  /** Filled inside main() once the SQLite session index is constructed. */
714
717
  let sessionIndexServiceRef = null;
715
718
 
719
+ const sessionMetadataStore = createSessionMetadataStore({ dataDir: OPENCHAMBER_DATA_DIR });
720
+
716
721
  const sessionGoalRuntime = createSessionGoalRuntime({
717
722
  buildOpenCodeUrl,
718
723
  getOpenCodeAuthHeaders,
@@ -726,6 +731,23 @@ const sessionGoalRuntime = createSessionGoalRuntime({
726
731
  onGoalPaused: (sessionId, directory) => (
727
732
  questionAutoDelegateRuntime?.pauseForSessionTree?.(sessionId, directory)
728
733
  ),
734
+ readSessionMetadata: (sessionId) => sessionMetadataStore.get(sessionId),
735
+ persistSessionGoal: async (sessionId, _directory, goal) => {
736
+ const metadata = await sessionMetadataStore.setSessionMetadata(sessionId, {
737
+ openchamber: { goal },
738
+ });
739
+ // Broadcast only. Feeding this write back into processPayload would re-arm
740
+ // the loop the runtime is already inside.
741
+ try {
742
+ broadcastGlobalUiEvent({
743
+ type: 'openchamber:session-metadata',
744
+ properties: { sessionID: sessionId, metadata },
745
+ });
746
+ } catch (error) {
747
+ console.warn('[session-metadata] broadcast failed:', error?.message ?? error);
748
+ }
749
+ return goal;
750
+ },
729
751
  emitGoalNotification: async ({ sessionId, directory, status, goal }) => {
730
752
  // The goal settle notification replaces the per-turn ready notifications
731
753
  // (suppressed while the goal is active) — so it obeys the same toggle.
@@ -934,6 +956,7 @@ const serverUtilsRuntime = createServerUtilsRuntime({
934
956
  }
935
957
  return snapshot.PATH;
936
958
  },
959
+ getStoredSessionMetadata: () => sessionMetadataStore.getAll(),
937
960
  });
938
961
 
939
962
  const setOpenCodePort = (...args) => serverUtilsRuntime.setOpenCodePort(...args);
@@ -1003,6 +1026,7 @@ Object.defineProperties(openCodeLifecycleState, {
1003
1026
  lastOpenCodeError: { get: () => lastOpenCodeError, set: (value) => { lastOpenCodeError = value; } },
1004
1027
  lastOpenCodeLaunchDiagnostics: { get: () => lastOpenCodeLaunchDiagnostics, set: (value) => { lastOpenCodeLaunchDiagnostics = value; } },
1005
1028
  isOpenCodeReady: { get: () => isOpenCodeReady, set: (value) => { isOpenCodeReady = value; } },
1029
+ v1Migration: { get: () => v1Migration, set: (value) => { v1Migration = value; } },
1006
1030
  openCodeNotReadySince: { get: () => openCodeNotReadySince, set: (value) => { openCodeNotReadySince = value; } },
1007
1031
  isExternalOpenCode: { get: () => isExternalOpenCode, set: (value) => { isExternalOpenCode = value; } },
1008
1032
  isShuttingDown: { get: () => isShuttingDown, set: (value) => { isShuttingDown = value; } },
@@ -1047,6 +1071,7 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
1047
1071
  normalizeApiPrefix,
1048
1072
  applyOpencodeBinaryFromSettings,
1049
1073
  ensureOpencodeCliEnv,
1074
+ ensurePinnedOpenCode2CliEnv,
1050
1075
  ensureLocalOpenCodeServerPassword,
1051
1076
  resolveManagedOpenCodeLaunchSpec,
1052
1077
  setOpenCodePort,
@@ -1059,6 +1084,26 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
1059
1084
  getManagedOpenCodeShellEnvSnapshot: getLoginShellEnvSnapshot,
1060
1085
  managedCapabilitiesRuntime,
1061
1086
  getActiveSessionCount,
1087
+ // Most-recently-used directories first: OpenCode initializes each directory
1088
+ // lazily on first request (seconds on large session stores), so the
1089
+ // lifecycle warms these right after readiness — before the UI's first
1090
+ // interactive request would otherwise pay that cost.
1091
+ getWarmupDirectories: async () => {
1092
+ const settings = await readSettingsFromDiskMigrated().catch(() => null);
1093
+ if (!settings) return [];
1094
+ const directories = [];
1095
+ if (typeof settings.lastDirectory === 'string' && settings.lastDirectory) {
1096
+ directories.push(settings.lastDirectory);
1097
+ }
1098
+ const projects = Array.isArray(settings.projects) ? [...settings.projects] : [];
1099
+ projects.sort((a, b) => (b?.lastOpenedAt ?? 0) - (a?.lastOpenedAt ?? 0));
1100
+ for (const project of projects) {
1101
+ if (typeof project?.path === 'string' && project.path) {
1102
+ directories.push(project.path);
1103
+ }
1104
+ }
1105
+ return [...new Set(directories)];
1106
+ },
1062
1107
  });
1063
1108
 
1064
1109
  const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args);
@@ -1081,6 +1126,36 @@ const scheduledTasksRuntime = createScheduledTasksRuntime({
1081
1126
  getSmallModelService,
1082
1127
  waitForOpenCodeReady,
1083
1128
  runHistoryStore: scheduledTaskRunHistoryStore,
1129
+ // Same Host store seam as session-goal / manual UI metadata writes.
1130
+ readSessionMetadata: (sessionId) => sessionMetadataStore.get(sessionId),
1131
+ persistSessionGoal: async (sessionId, _directory, goal) => {
1132
+ const metadata = await sessionMetadataStore.setSessionMetadata(sessionId, {
1133
+ openchamber: { goal },
1134
+ });
1135
+ try {
1136
+ broadcastGlobalUiEvent({
1137
+ type: 'openchamber:session-metadata',
1138
+ properties: { sessionID: sessionId, metadata },
1139
+ });
1140
+ } catch (error) {
1141
+ console.warn('[session-metadata] broadcast failed:', error?.message ?? error);
1142
+ }
1143
+ return metadata;
1144
+ },
1145
+ // Arm the goal loop after first persist only — do not feed progress writes
1146
+ // back through processPayload (session-goal runtime already broadcasts those).
1147
+ onGoalPersisted: ({ sessionID, directory, metadata }) => {
1148
+ sessionGoalRuntime.processPayload({
1149
+ type: 'session.updated',
1150
+ properties: {
1151
+ info: {
1152
+ id: sessionID,
1153
+ directory: directory || '',
1154
+ metadata,
1155
+ },
1156
+ },
1157
+ }, directory || '');
1158
+ },
1084
1159
  emitTaskRunEvent: (event) => {
1085
1160
  for (const client of uiOpenChamberEventClients) {
1086
1161
  try {
@@ -1444,11 +1519,13 @@ async function main(options = {}) {
1444
1519
  return {
1445
1520
  openCodePort,
1446
1521
  openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode),
1522
+ openCodeStarting: Boolean(!isOpenCodeReady && !lastOpenCodeError),
1447
1523
  openCodeSecureConnection: isOpenCodeConnectionSecure(),
1448
1524
  openCodeAuthSource: openCodeAuthSource || null,
1449
1525
  openCodeApiPrefix: '',
1450
1526
  openCodeApiPrefixDetected: true,
1451
1527
  isOpenCodeReady,
1528
+ v1Migration,
1452
1529
  lastOpenCodeError,
1453
1530
  lastOpenCodeLaunchDiagnostics,
1454
1531
  opencodeBinaryResolved: resolvedOpencodeBinary || null,
@@ -1653,6 +1730,36 @@ async function main(options = {}) {
1653
1730
  isUnsafeSkillRelativePath,
1654
1731
  buildOpenCodeUrl,
1655
1732
  getOpenCodeAuthHeaders,
1733
+ getIsExternalOpenCode: () => isExternalOpenCode,
1734
+ sessionMetadataStore,
1735
+ // Same store seam as sessionGoalRuntime / scheduledTasksRuntime.
1736
+ readSessionMetadata: (sessionId) => sessionMetadataStore.get(sessionId),
1737
+ persistSessionGoal: async (sessionId, _directory, goal) => {
1738
+ const metadata = await sessionMetadataStore.setSessionMetadata(sessionId, {
1739
+ openchamber: { goal },
1740
+ });
1741
+ try {
1742
+ broadcastGlobalUiEvent({
1743
+ type: 'openchamber:session-metadata',
1744
+ properties: { sessionID: sessionId, metadata },
1745
+ });
1746
+ } catch (error) {
1747
+ console.warn('[session-metadata] broadcast failed:', error?.message ?? error);
1748
+ }
1749
+ return metadata;
1750
+ },
1751
+ onSessionMetadataWritten: ({ sessionID, directory, metadata }) => {
1752
+ sessionGoalRuntime.processPayload({
1753
+ type: 'session.updated',
1754
+ properties: {
1755
+ info: {
1756
+ id: sessionID,
1757
+ directory: directory || '',
1758
+ metadata,
1759
+ },
1760
+ },
1761
+ }, directory || '');
1762
+ },
1656
1763
  getSmallModelService,
1657
1764
  getOpenCodePort: () => openCodePort,
1658
1765
  buildAugmentedPath,
@@ -55,7 +55,7 @@ admission keeps the worker and confirms the exact message ID before reporting
55
55
  success. Attachment deduplication uses ordered content digests; reordering
56
56
  attachments is a different request.
57
57
 
58
- The in-app Assistant is an OpenChamber-owned **contact**. OpenCode is only the LLM gateway (`POST /api/openchamber/llm/chat/completions`) using already-connected `{providerID, modelID}`. Composer send runs `@earendil-works/pi-agent-core` with `thinkingLevel: 'off'`. When the assistant has an effective workspace, the harness attaches pi's four coding tools (`read`, `write`, `edit`, `bash`) via `NodeExecutionEnv` in that directory, applies `defaultPrompt`, and merges skills from `~/.claude/skills`, `~/.agents/skills`, plus the project's `.claude/skills` and `.agents/skills` (project wins on name). OpenChamber API tools remain (`new_conversation`, `clear_chat_history`, `list_projects`, `list_sessions`, `get_assistant_settings`, `update_default_prompt`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`, `watch_session`, `read_session`, `steer_session`, `stop_session`, `archive_session`, `delete_session`). It does not use `SessionPrompt` / `promptAsync` as the conversation engine. The LLM gateway throwaway session still denies OpenCode tools (`session.create` PermissionRuleset deny-all + `promptAsync` tools map + hidden `openchamber-llm` agent `permission: { "*": deny }`); file and shell work runs in-process through pi. The contact system prompt includes **full argument schemas** for both pi coding tools and OpenChamber contact tools. Skill directory entries are instructions loaded via `read`, not native/MCP tool names.
58
+ The in-app Assistant is an OpenChamber-owned **contact**. OpenCode is only the LLM gateway (`POST /api/openchamber/llm/chat/completions`) using already-connected `{providerID, modelID}`. Composer send runs `@earendil-works/pi-agent-core` with `thinkingLevel: 'off'`. When the assistant has an effective workspace, the harness attaches pi's four coding tools (`read`, `write`, `edit`, `bash`) via `NodeExecutionEnv` in that directory, applies `defaultPrompt`, and merges skills from `~/.claude/skills`, `~/.agents/skills`, plus the project's `.claude/skills` and `.agents/skills` (project wins on name). OpenChamber API tools remain (`new_conversation`, `clear_chat_history`, `list_projects`, `list_sessions`, `get_assistant_settings`, `update_default_prompt`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`, `watch_session`, `read_session`, `steer_session`, `stop_session`, `archive_session`, `delete_session`). It does not use `SessionPrompt` / `session.prompt` as the conversation engine. The LLM gateway throwaway session still denies OpenCode tools (`session.create` PermissionRuleset deny-all + `session.prompt` tools map + hidden `openchamber-llm` agent `permission: { "*": deny }`); file and shell work runs in-process through pi. The contact system prompt includes **full argument schemas** for both pi coding tools and OpenChamber contact tools. Skill directory entries are instructions loaded via `read`, not native/MCP tool names.
59
59
 
60
60
  User contact turns retain all eighteen OpenChamber operation tools alongside the four
61
61
  pi tools. Skill discovery contributes instructions to this combined tool set.
@@ -99,29 +99,29 @@ and keep their existing error result handling.
99
99
 
100
100
  **Assign work (this PR)**
101
101
 
102
- - `assign_session` is an OpenChamber AgentTool on the contact harness. It creates or reuses a visible OpenCode/OpenChamber session on a **registered project path** (Settings projects / `getAllowedRoots()`), optionally an **existing** Chat worktree/branch via `getWorktrees`, then kicks the coding prompt into that session with `session.create` + `session.promptAsync` (same worker path as `POST /api/openchamber/conversations`).
102
+ - `assign_session` is an OpenChamber AgentTool on the contact harness. It creates or reuses a visible OpenCode/OpenChamber session on a **registered project path** (Settings projects / `getAllowedRoots()`), optionally an **existing** Chat worktree/branch via `getWorktrees`, then kicks the coding prompt into that session with `session.create` + `session.prompt` (same worker path as `POST /api/openchamber/conversations`).
103
103
  - That worker session owns substantive implementation. The contact owns communication and coordination, with direct pi tools for small, bounded tasks. Assign sessions are **not** archived Assistant bindings and must not use `assistant-workspaces`.
104
104
  - **Reuse / continue existing session:** `assign_session` with `sessionID` + coding prompt continues that worker. Before prompt, the service resolves directory/project from **authoritative** session metadata (session index directory → `session.get` payload → registered project match). User-supplied `projectPath`/`directory` are lookup **hints only** and never override scope. Missing/unregistered/failed session lookup **fails closed** (not empty success). Prior worker model/config semantics stay: omit model args to follow the session model when still connected.
105
- - **`watch_session`:** listen-only attach for an existing session (`sessionID` required). No `promptAsync`. Emits the same session card slot and reuses `assistant_contact_watch` + `reportAssignedSession` / reconcile / resume. Baseline card/watch status comes from live `session.get` (+ messages when needed) via `mapSessionToWatchStatus` so an already-terminal idle is **not** treated as a fresh complete transition (no false resume). In-flight baseline (`busy`/`question`) keeps the watch until real settle. A plain user `@session:id` (or JSON title/sessionID/directory) reference is **context only** — it does not auto-call `watch_session`; the model must hear an explicit listen/watch/monitor intent.
106
- - **`stop_session`:** calls real OpenCode `session.abort` for `sessionID` after the same authoritative directory resolve. Returns the true abort result — transport/`not_found`/upstream errors stay errors (never claim stopped on failure). Abort is registered in a private per-session inflight map before `await abort` so concurrent SSE `complete`/`error` cannot schedule a `resume=true` continuation ahead of `resume=false`. On successful abort the service marks the watch/card `cancelled` even when upstream emits no idle/error event. Idle/error events still flow through the existing watch reporter when a watch exists.
105
+ - **`watch_session`:** listen-only attach for an existing session (`sessionID` required). No `session.prompt`. Emits the same session card slot and reuses `assistant_contact_watch` + `reportAssignedSession` / reconcile / resume. Baseline card/watch status comes from live `session.get` (+ messages when needed) via `mapSessionToWatchStatus` so an already-terminal idle is **not** treated as a fresh complete transition (no false resume). In-flight baseline (`busy`/`question`) keeps the watch until real settle. A plain user `@session:id` (or JSON title/sessionID/directory) reference is **context only** — it does not auto-call `watch_session`; the model must hear an explicit listen/watch/monitor intent.
106
+ - **`stop_session`:** calls real OpenCode `session.interrupt` for `sessionID` after the same authoritative directory resolve. Returns the true abort result — transport/`not_found`/upstream errors stay errors (never claim stopped on failure). Abort is registered in a private per-session inflight map before `await abort` so concurrent SSE `complete`/`error` cannot schedule a `resume=true` continuation ahead of `resume=false`. On successful abort the service marks the watch/card `cancelled` even when upstream emits no idle/error event. Idle/error events still flow through the existing watch reporter when a watch exists.
107
107
  - **Worker model selection:** optional tool args `providerID` / `modelID` / `model` resolve against the live connected OpenCode catalog (`loadConnectedCatalog` / `GET /provider` + `GET /config/providers`). Explicit selection must match uniquely — missing or ambiguous names fail closed (`model_not_found` / `model_ambiguous`) with **no silent fallback**. Omitting model args on a **new** worker keeps the contact assistant's default provider/model. **Reusing `sessionID` without an explicit model** follows that session's last model (`session.get` → `session.model` `{ providerID, id|modelID }`, else newest user/assistant `info.model`) when it is still an exact providerID+modelID match in the connected catalog (`source: 'session'`); missing session model, catalog miss, or lookup failure **degrades** to the assistant default (`source: 'assistant'`) and never fails assign once the session itself resolved. Selecting a worker model **never** mutates the contact assistant row.
108
108
  - Every user or automatic contact turn reloads the current instance's **Connected OpenCode models** block (exact providerID, modelID, providerName, name, acceptsImages), plus settings-backed favorite models in saved order and recent models newest first. Only connected catalog entries qualify as preference candidates; variants remain separate from model IDs. Catalog and preference read failures remain explicit unknown states, distinct from successful empty lists; either read can succeed independently. The route projects only favoriteModels/recentModels from the strict settings reader, never arbitrary settings or credentials. No catalog is cached across turns or runtime switches. Informal `model` aliases normalize punctuation/spacing and require a unique catalog match; explicit `modelID` stays exact. Ambiguous aliases never silently prefer a favorite/provider. This server-owned context is shared by web, desktop, and mobile clients of that instance.
109
- - **Current-turn attachments:** server-authoritative `userParts` file parts from this contact turn are forwarded into worker `promptAsync` parts by default (including “just open a session” intents). The model must not base64-encode or invent local paths. History images are **not** unbounded-forwarded. Image parts require a vision-capable catalog entry (`acceptsImages`); otherwise assign fails with `image_not_supported` before create. Sensitive file bodies are never logged.
110
- - **Create-then-prompt failure:** if `session.create` succeeds and `promptAsync` fails, assign best-effort `session.delete`s the new worker so empty sessions are not left behind. Reused `sessionID` paths never delete on prompt failure.
109
+ - **Current-turn attachments:** server-authoritative `userParts` file parts from this contact turn are forwarded into worker `session.prompt` parts by default (including “just open a session” intents). The model must not base64-encode or invent local paths. History images are **not** unbounded-forwarded. Image parts require a vision-capable catalog entry (`acceptsImages`); otherwise assign fails with `image_not_supported` before create. Sensitive file bodies are never logged.
110
+ - **Create-then-prompt failure:** if `session.create` succeeds and `session.prompt` fails, assign best-effort `session.delete`s the new worker so empty sessions are not left behind. Reused `sessionID` paths never delete on prompt failure.
111
111
  - Successful `assign_session` and `watch_session` return `terminate: true` to stop the mutation loop. The harness then requests a tools-disabled model summary. Prerequisite tools (`list_projects` / `list_sessions`) may still run before assign/watch/stop in the same turn; multi-operation user intents remain ordered **across turns** after a successful assign/watch.
112
112
  - Same contact-turn assign gate (tools instance lifetime only, not cross-turn): identical args after success replay the cached success result (no second worker); different args after success, or parallel mismatched args, fail closed with a validation error. The idempotent key includes worker model selection and current-turn attachment scope. Failure clears the gate so a corrected retry may run. Parallel identical args share one in-flight promise.
113
113
  - After a successful assign or watch, the service persists the existing session card into the contact transcript (user bubbles first, then the card) and watches that worker session in `assistant_contact_watch` (watch baselines the live status). The user-facing outcome is the session card plus any short **spoken** preamble; English tool-result confirms (`Opened a coding session.`, `Watching that coding session.`, `Created assistant…`, `Sent to…`) for card/side-effect tools are **not** written into the transcript.
114
114
  - Existing OpenCode SSE on the same `globalEventHub` as notifications (`session.idle`, `session.error`, `session.status`, `question.asked`, `permission.asked`) updates **the same card** (`busy` → `complete` / `error` / `question` / `cancelled`). **No canned settle transcript bubbles** (`oc.settle.complete` / `error` / `question` are not written). On **complete, error, or cancelled (user interrupt)** when the watch status actually changed, the service schedules an async contact-lane **continuation** (`inContactTurnLane` + `runContactTurn`) per related assistant: no admitted user row (internal `userText` only), active contact turn (green dot / 3-dot), stable resume id `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`, bounded last worker assistant text (~2000 chars; fetch failure still resumes with status only). Cancelled continuations include `reason: user_interrupted` so the contact can acknowledge the user's abort. The contact Agent can perform read-only lookups and summarize in the user's language, but cannot reassign or mutate a session during this notification — the user sees that Agent summary, not 「会话已完成/会话失败」. `question` updates the card only (worker waiting on the user). `stop_session` cancellation does not schedule this continuation. Session-goal settle (`emitGoalNotification`) uses the same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成 (`changed=false` → no second resume). Same complete status re-reported is idempotent.
115
- - Restart or a missed `session.idle` cannot leave the watch stuck: boot and the existing 60s reconcile timer `listInFlightWatches` and poll OpenCode `session.get` / `session.messages`. Idle, `time.completed`, or a missing session settles the same card (`busy` → 完成) and triggers the same contact continuation (no `oc.settle.complete` bubble). `session.error` or last-assistant `info.error` settles 失败 + resume once; last-assistant `MessageAbortedError` settles `cancelled` + the interrupt notify once. Transport / non-404 get errors leave the watch in flight (failure is not empty success). One failed watch does not block unrelated watches. Reconcile reuses `reportAssignedSession`, so an error watch is never rewritten as complete. Tombstoned assistants skip resume.
115
+ - Restart or a missed `session.idle` cannot leave the watch stuck: boot and the existing 60s reconcile timer `listInFlightWatches` and poll OpenCode `session.get` / `message.list`. Idle, `time.completed`, or a missing session settles the same card (`busy` → 完成) and triggers the same contact continuation (no `oc.settle.complete` bubble). `session.error` or last-assistant `info.error` settles 失败 + resume once; last-assistant `MessageAbortedError` settles `cancelled` + the interrupt notify once. Transport / non-404 get errors leave the watch in flight (failure is not empty success). One failed watch does not block unrelated watches. Reconcile reuses `reportAssignedSession`, so an error watch is never rewritten as complete. Tombstoned assistants skip resume.
116
116
  - Worker sessions stamp `metadata.openchamber.assigned` (`from: 'contact'`, `assistantID`, `name`). They must **not** use `openchamber.assistant.assistantID` — that marker hides archived Assistant bindings from Chat.
117
117
  - `AssistantDTO.assignedSessionIDs` lists in-flight (`busy`/`question`) watches for assigned worker sessions. Session-card busy stays on the card; it does **not** drive the list green dot.
118
118
  - **Contact active turn (server authoritative):** process-local metadata tracks admission → `queued` → `running` → settled (removed). `AssistantDTO.working` is true only while that assistant has an unsettled contact turn. `AssistantDTO.activeContactTurn` is `{ turnID, messageID, status: 'queued'|'running', admittedAt }` or `null` so APP restart / reconnect can rehydrate list green dots and the contact 3-dot row from the snapshot HTTP GET. Spoken bubbles and cards persist to SQLite as soon as they publish (`done:true` / `onCard`) so another device or APP restart can GET them while the turn is still running. Token-level SSE deltas remain live paint only. A server process restart clears in-memory activity (no permanent green; tools are not auto-rerun) but already-published bubbles/cards stay in the transcript. Per-assistant lanes isolate turns across assistants/runtimes. Stale `contact-turn-end` for an older `turnID` must not clear a newer active turn.
119
119
  - **List latest-message preview (server authoritative):** every `AssistantDTO` path (`snapshot` / `create` / `update` / other `output` rows) carries `latestMessagePreview: { messageID, ordinal, createdAt, role: 'user'|'assistant', text, fallbackKind: 'image'|'file'|'session'|'assistant'|'schedule'|null } | null`. Source is the contact transcript only (not `defaultPrompt` / `updatedAt`). Order is the same keyset as contact pages: newest `(ordinal DESC, message_id DESC)`. Visible roles are `user`, `assistant`, and `peer` (peer maps to `role: 'user'` because inbound DMs are user-visible). Internal assigned-session settle markers (`oc.settle.*`) are excluded in the SQL candidate probe (and again in `buildContactMessagePreview`). Durable error bubbles keep their real body text. Plain text is sanitized and capped at `CONTACT_PREVIEW_MAX_CHARS` (500) with no data-URL/base64 bodies. File-only rows set `fallbackKind` `image`/`file`; pure cards set `session`/`assistant`/`schedule`. Empty transcript → `null`. **Snapshot cost:** one prepared statement reused per assistant (`assistant_id` + indexed `ORDER BY ordinal DESC, message_id DESC LIMIT candidateLimit`, catalog ≤100) collecting only those bounded candidates, then **one** parts `IN` query — not a window/`ROW_NUMBER` over full multi-assistant history and not HTTP N+1. SQL already drops pure settle tails so they cannot exhaust the LIMIT and hide an older real bubble; remaining non-preview empty rows may still consume a candidate slot within that same LIMIT. Admission, assistant completion, peer DM, card insert, wipe, and clear-memory already bump domain revision / `openchamber:assistants-changed`; clients re-GET snapshot for the new preview. Clear-memory keeps transcript rows so the preview still reflects the last bubble; clear-history / reset remove rows so the preview becomes the wipe confirm (or `null` after a full unbounded wipe with no remaining user).
120
120
  - No registered project → clear `project_required` (ask the user to configure a project). Never fall back to managed assistant-workspaces.
121
121
  - Assign must not go through the peer DM channel.
122
- - `assignWork` (service) resolves the worker model via the connected catalog (`capabilities.input.image` preferred), forwards tool `variant` (cross-model drops contact variant), passes AbortSignal into create/prompt/delete/catalog, and supplies `lookupMessage` over real SDK exact lookup (`v2.session.message` → `session.message` → bounded `session.messages`). Ambiguous prompt admission returns `prompt_ambiguous` with `sessionID`/`messageID` and does **not** delete the worker; the contact-turn assign gate retains that identity so a same-args retry reuses the session and cannot blind-create another worker this turn.
122
+ - `assignWork` (service) resolves the worker model via the connected catalog (`capabilities.input.image` preferred), forwards tool `variant` (cross-model drops contact variant), passes AbortSignal into create/prompt/delete/catalog, and supplies `lookupMessage` over real SDK exact lookup (`v2.session.message` → `session.message` → bounded `message.list`). Ambiguous prompt admission returns `prompt_ambiguous` with `sessionID`/`messageID` and does **not** delete the worker; the contact-turn assign gate retains that identity so a same-args retry reuses the session and cannot blind-create another worker this turn.
123
123
 
124
- - Share/queue may still use `promptAsync` on Assistant bindings.
124
+ - Share/queue may still use `session.prompt` on Assistant bindings.
125
125
 
126
126
  **Registered projects + sessions (this PR)**
127
127
 
@@ -183,7 +183,7 @@ and keep their existing error result handling.
183
183
 
184
184
  - `message_assistant` is the contact tool for natural language (给 PeerQA 说一声 / tell PeerQA). It calls in-process `deliverPeerMessage` and confirms with a short bubble. No card required. The user never types `/dm`.
185
185
  - `POST /:fromAssistantID/contact/dm` `{ toAssistantID, text, parts? }` delivers into B’s contact transcript as `role: 'peer'` with `fromAssistantID` / `fromAssistantName`. The harness/API sends peer messages.
186
- - Read-only: no `session.promptAsync`, no session/file/worktree mutation, no tools on the recipient. Assign is not delivered here.
186
+ - Read-only: no `session.prompt`, no session/file/worktree mutation, no tools on the recipient. Assign is not delivered here.
187
187
  - Peer rows are excluded from `contactHistoryForLlm` so they cannot become harness turns.
188
188
  - This is the inbox primitive for later summon. Do not invent a second inbox.
189
189
 
@@ -243,13 +243,13 @@ and keep their existing error result handling.
243
243
 
244
244
  ## History coverage
245
245
 
246
- Snapshots expose the latest 50 `historySessionIDs` in chronological order and `historySessionCount` for the full count. The messages pagination endpoint owns complete history, including the current binding. A successful direct, shared, or queued OpenCode admission writes its user message and provisional parts to the Assistant SQLite mirror before the HTTP operation settles; this covered admission row survives binding replacement, restart, and an unavailable OpenCode history endpoint. Matching live parts replace provisional parts by message ID. Other event mirrors retain raw JSON as uncovered (`covered=0`) provisional fallback rows: they remain visible in archived history until an explicit shrink signal. Authoritative OpenCode backfill upserts covered rows and reconciles authoritative parts, rejects entries whose embedded session ID belongs to another session, and never deletes provisional mirrors merely because a successful page omitted them. Pagination `complete` only records that the OpenCode cursor was exhausted; it does not authorize deleting uncovered event mirrors. The only shrink whitelist is explicit `message.removed`, an authoritative `session.messages` 404 (`session-missing`), or assistant deletion. Late archived `message.updated` / `message.part.updated` and session lifecycle events (`session.idle`, `session.error`, `session.status` busy/retry/idle) call `invalidateBackfill` to reopen that session's cursor/complete without clearing covered flags. `history-state.js` owns the pure backfill reducer (`invalidate` → preserve; `page` → complete iff next cursor empty, always preserve; `session-missing` → discard provisional). Backfill persists the OpenCode `x-next-cursor` header verbatim and derives completion from its presence. A demand request commits up to three upstream pages and returns committed archived mirror rows (covered and provisional) with a stable scan-boundary cursor when more work remains. Startup begins one non-blocking current-binding backfill page. Archived rows preserve their stored directory; a legacy null directory triggers an authoritative session lookup of `data.directory` and `data.project.worktree`, with containment validation before persistence. Unresolved legacy directories remain `null` in persisted and historical response data. Ordinary mirror upserts preserve covered rows and never blanket-invalidate a session; explicit authoritative upserts elevate coverage; session-wide `invalidateCoverage` preserves every already-covered user row, including admitted rows whose local marker was replaced by live OpenCode info, while resetting other coverage on archive/re-archive; message-scoped coverage invalidation runs only after structural part deletes that require re-backfill. Removing an assistant tombstones the row and deletes its session history, message/part mirrors, and backfill state in one transaction. Schema v11 adds the mirror coverage marker.
246
+ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `historySessionCount` for the full count. The messages pagination endpoint owns complete history, including the current binding. A successful direct, shared, or queued OpenCode admission writes its user message and provisional parts to the Assistant SQLite mirror before the HTTP operation settles; this covered admission row survives binding replacement, restart, and an unavailable OpenCode history endpoint. Matching live parts replace provisional parts by message ID. Other event mirrors retain raw JSON as uncovered (`covered=0`) provisional fallback rows: they remain visible in archived history until an explicit shrink signal. Authoritative OpenCode backfill upserts covered rows and reconciles authoritative parts, rejects entries whose embedded session ID belongs to another session, and never deletes provisional mirrors merely because a successful page omitted them. Pagination `complete` only records that the OpenCode cursor was exhausted; it does not authorize deleting uncovered event mirrors. The only shrink whitelist is explicit `message.removed`, an authoritative `message.list` 404 (`session-missing`), or assistant deletion. Late archived `message.updated` / `message.part.updated` and session lifecycle events (`session.idle`, `session.error`, `session.status` busy/retry/idle) call `invalidateBackfill` to reopen that session's cursor/complete without clearing covered flags. `history-state.js` owns the pure backfill reducer (`invalidate` → preserve; `page` → complete iff next cursor empty, always preserve; `session-missing` → discard provisional). Backfill persists the OpenCode `x-next-cursor` header verbatim and derives completion from its presence. A demand request commits up to three upstream pages and returns committed archived mirror rows (covered and provisional) with a stable scan-boundary cursor when more work remains. Startup begins one non-blocking current-binding backfill page. Archived rows preserve their stored directory; a legacy null directory triggers an authoritative session lookup of `data.directory` and `data.project.worktree`, with containment validation before persistence. Unresolved legacy directories remain `null` in persisted and historical response data. Ordinary mirror upserts preserve covered rows and never blanket-invalidate a session; explicit authoritative upserts elevate coverage; session-wide `invalidateCoverage` preserves every already-covered user row, including admitted rows whose local marker was replaced by live OpenCode info, while resetting other coverage on archive/re-archive; message-scoped coverage invalidation runs only after structural part deletes that require re-backfill. Removing an assistant tombstones the row and deletes its session history, message/part mirrors, and backfill state in one transaction. Schema v11 adds the mirror coverage marker.
247
247
 
248
248
  `assistants` owns Assistant configuration, session bindings, and a durable assistant-owned OpenCode message/part mirror in `assistants.sqlite`. Routes register before the generic OpenCode proxy.
249
249
 
250
- `GET /api/openchamber/assistants/capability` returns `supported`, the persisted `enabled` switch, domain revision, and relay-derived stable `serverInstanceID`. Fresh databases default `enabled` to off; Settings flips the switch, and product navigation entries stay hidden until it is on. `PUT /api/openchamber/assistants/settings` changes the global switch with `expectedRevision`. CRUD and snapshots live under `/api/openchamber/assistants`. Session routes are `POST /:id/session/ensure`, `/:id/session/new`, and `/:id/session/compact`; composer sends use `POST /:id/messages` (contact harness, not `promptAsync`); contact history is `GET /:id/contact/messages` (full transcript, including rows before a clear-memory boundary); contact reset wipe is `POST /:id/contact/reset`; shared contact read watermark is `POST /:id/contact/read`; session cards are `POST /:id/contact/cards`; read-only inter-assistant DMs are `POST /:fromID/contact/dm`; assistant-owned scheduled tasks are `GET /:id/scheduled-tasks`. Shares still use `POST /:id/share` and `GET /share-operations/:operationID`.
250
+ `GET /api/openchamber/assistants/capability` returns `supported`, the persisted `enabled` switch, domain revision, relay-derived stable `serverInstanceID`, and explicit v2 gaps `sharingAvailable`, `archiveMetadataAvailable`, and `sessionMetadataAvailable` (all `false`). Fresh databases default `enabled` to off; Settings flips the switch, and product navigation entries stay hidden until it is on. `PUT /api/openchamber/assistants/settings` changes the global switch with `expectedRevision`. CRUD and snapshots live under `/api/openchamber/assistants`. Session routes are `POST /:id/session/ensure`, `/:id/session/new`, and `/:id/session/compact`; composer sends use `POST /:id/messages` (contact harness, not `session.prompt`); contact history is `GET /:id/contact/messages` (full transcript, including rows before a clear-memory boundary); contact reset wipe is `POST /:id/contact/reset`; shared contact read watermark is `POST /:id/contact/read`; session cards are `POST /:id/contact/cards`; read-only inter-assistant DMs are `POST /:fromID/contact/dm`; assistant-owned scheduled tasks are `GET /:id/scheduled-tasks`. Shares still use `POST /:id/share` and `GET /share-operations/:operationID`.
251
251
 
252
- `AssistantDTO.mode` remains `continuous` or `stateless` (default `continuous`) for share/queue OpenCode session binding. It is **not** a user-facing memory setting: Settings no longer exposes the continuous/stateless toggle, and new assistants stay continuous contact history with server-side LLM trim. `historySessionIDs` lists prior OpenCode session bindings in chronological order (oldest first, capped). Continuous Assistants keep one long-lived current session across composer turns. Stateless Assistants create and bind a fresh OpenCode session for every composer send, share admission, and queued delivery so the model executes without prior transcript context; prior sessions remain available in OpenCode history. The Assistant surface follows the current binding, so each stateless turn rematerializes onto that fresh session. `AssistantDTO.workspacePath` retains the configuration value: `null` selects the managed workspace and a string selects a registered project path. `AssistantDTO.managedWorkspacePath` always carries the canonical managed target at `dataDir/assistant-workspaces/<assistantID>`, including for project-configured Assistants. `AssistantDTO.effectiveWorkspacePath` carries the resolved OpenCode session directory. `AssistantDTO.variant` is nullable and reaches OpenCode `promptAsync` with provider, model, agent, and system prompt. `SessionBinding` is `{ sessionID, directory, sessionGeneration }` with the effective directory. `ensure` and `new` return `SessionBinding`; `compact` returns `{ binding, summarized: true }`; messages return `{ binding, messageID, admitted: true }`; shares return top-level `{ operationID, assistantID, sessionID, messageID, state, phase, attempt, leaseExpiresAt, errorCode }`. `contracts.js` exports serialization fixtures for these DTOs. Binding replacement uses generation CAS, so concurrent creation retains one winner while orphan sessions stay available in OpenCode history. Workspace changes create and bind a session in the new directory. Session metadata carries `openchamber.assistant.assistantID` and `name` as the authoritative ownership marker. New OpenCode sessions use the fixed human title `[Assistant] ${name}` and are archived via `session.update({ time: { archived } })` immediately after create and before binding is returned, so ordinary active lists never flash them; metadata isolation still applies if archive fails after create. The session index and global session lists hide system sessions by this metadata (never by title prefix). Assistant history and direct source open remain the entry points.
252
+ `AssistantDTO.mode` remains `continuous` or `stateless` (default `continuous`) for share/queue OpenCode session binding. It is **not** a user-facing memory setting: Settings no longer exposes the continuous/stateless toggle, and new assistants stay continuous contact history with server-side LLM trim. `historySessionIDs` lists prior OpenCode session bindings in chronological order (oldest first, capped). Continuous Assistants keep one long-lived current session across composer turns. Stateless Assistants create and bind a fresh OpenCode session for every composer send, share admission, and queued delivery so the model executes without prior transcript context; prior sessions remain available in OpenCode history. The Assistant surface follows the current binding, so each stateless turn rematerializes onto that fresh session. `AssistantDTO.workspacePath` retains the configuration value: `null` selects the managed workspace and a string selects a registered project path. `AssistantDTO.managedWorkspacePath` always carries the canonical managed target at `dataDir/assistant-workspaces/<assistantID>`, including for project-configured Assistants. `AssistantDTO.effectiveWorkspacePath` carries the resolved OpenCode session directory. `AssistantDTO.variant` is nullable and reaches OpenCode `session.prompt` with provider, model, agent, and system prompt. `SessionBinding` is `{ sessionID, directory, sessionGeneration }` with the effective directory. `ensure` and `new` return `SessionBinding`; `compact` returns `{ binding, summarized: true }`; messages return `{ binding, messageID, admitted: true }`; shares return top-level `{ operationID, assistantID, sessionID, messageID, state, phase, attempt, leaseExpiresAt, errorCode }`. `contracts.js` exports serialization fixtures for these DTOs. Binding replacement uses generation CAS, so concurrent creation retains one winner while orphan sessions stay available in OpenCode history. Workspace changes create and bind a session in the new directory. Session metadata carries `openchamber.assistant.assistantID` and `name` as the authoritative ownership marker. New OpenCode sessions use the fixed human title `[Assistant] ${name}` and are archived via `session.update({ time: { archived } })` immediately after create and before binding is returned, so ordinary active lists never flash them; metadata isolation still applies if archive fails after create. The session index and global session lists hide system sessions by this metadata (never by title prefix). Assistant history and direct source open remain the entry points.
253
253
 
254
254
  **Contact send admission + SSE (this PR)**
255
255
 
@@ -258,13 +258,13 @@ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `h
258
258
  - Contact SSE reuses `/api/openchamber/events`: `openchamber:contact-bubble-delta` `{ assistantID, turnID, bubbleIndex, delta, done, occurredAt }`. Explicit public prefix records publish during generation; ordinary raw tokens remain private and legacy short spoken preambles publish after parsing. Each `done:true` bubble persists immediately as `${userMessageID}:bubble:${index+1}` and bumps `assistants-changed`, allowing another client to fetch it before the turn ends. Token deltas (`done:false`) remain ephemeral. Turn-global indices continue from `projectStreamedContactTurnBubbles` plus the current completion's published records, including corrections. On normal turns these messages form the final array's stable prefix and turn-end persistence adds only the remainder. Every tool result stays model-only; missed-tool exhaustion with no model text becomes an error. Tool cards persist on `onCard` with the existing `${userMessageID}:card:${index+1}` identity.
259
259
  - Turn end: strip/parse/tools (existing), persist any **remaining** assistant bubbles + cards (skip ids already published mid-turn; never re-insert the user row), clear active contact turn, bump + assistants-changed (error paths bump even without assistant rows), broadcast `openchamber:contact-turn-end` `{ assistantID, turnID, status: 'complete'|'error', error?, occurredAt }`. On failure the admitted user row stays; clients must not spin forever. Completing a contact turn also sends the same completion notification path as a normal contact: SMS-style title (assistant name) + body (spoken bubbles), via desktop / UI SSE / push, gated by `notifyOnCompletion`.
260
260
  - **Successful `new_conversation` / `clear_chat_history`:** mid-turn spoken/card publishes for that turn are discarded. `persistContactAssistantReply({ reset:true })` deletes this turn's `role=assistant` rows by `assistantID+turnID` (parts first; same delete pattern as contact-store, **not** `deleteContactMessages`) inside the existing write transaction, then inserts the single confirm bubble and bumps revision. Clearing user, queued users, peers, older turns, watches, generation, context boundary, and read watermark stay. Late bubble/card publish after reset success is ignored; `spokenByIndex` and `assignedCards` clear. Failure keeps prior error semantics.
261
- - **`stop_session` abort race:** before `session.abort`, the service registers a private per-`sessionID` inflight map (shared promise + observed terminal). Concurrent stop calls on the same worker share that promise. While inflight, `reportAssignedSession` only stashes terminals (`cancelled` > `error` > `complete`) and delays watch writes / continuations. Abort success (including silent no-SSE): release map, `cancelAssignedResumes({ sessionID })`, then `reportAssignedSession(cancelled, resume=false)`. Abort throw / 404 / error / `data:false` / signal failure: release map, replay one observed terminal through the normal reporter when present (else watch unchanged), return the real tool failure. `close()` clears the map; late events stay cancelled-idempotent.
261
+ - **`stop_session` abort race:** before `session.interrupt`, the service registers a private per-`sessionID` inflight map (shared promise + observed terminal). Concurrent stop calls on the same worker share that promise. While inflight, `reportAssignedSession` only stashes terminals (`cancelled` > `error` > `complete`) and delays watch writes / continuations. Abort success (including silent no-SSE): release map, `cancelAssignedResumes({ sessionID })`, then `reportAssignedSession(cancelled, resume=false)`. Abort throw / 404 / error / `data:false` / signal failure: release map, replay one observed terminal through the normal reporter when present (else watch unchanged), return the real tool failure. `close()` clears the map; late events stay cancelled-idempotent.
262
262
  - In-process tests use `whenContactTurnSettled(messageID)` (not part of the HTTP JSON body).
263
263
  - Completions: public `POST /llm/chat/completions` still rejects `stream:true`. In-process contact harness passes `onTextDelta` + `globalEventHub` through `boundCompletion` → `createChatCompletion` → throwaway generate for real tokens only.
264
264
 
265
- Composer messages carry a client message ID. The service accepts the existing OpenCode delivery parts (`{ type: 'text' }` and `{ type: 'file', mime, url, filename? }` data URLs — not a second attachment store), extracts text (`[attachment]` when the send is file-only), admits the user at 202, then runs the contact harness asynchronously (`runContactTurn` / pi-agent-core, thinking off, pi `read` / `write` / `edit` / `bash` in the assistant workspace, OpenChamber tools `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `get_assistant_settings` / `update_default_prompt` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session`, merged `.agents`/`.claude` skills, plus the registered-projects catalog each turn) through the OpenChamber completions gateway (public HTTP non-streaming; in-process `onTextDelta` for contact SSE). Assistant bubbles and tool cards persist when they publish (bubble `done:true` / card insert) and turn end upserts any remainder. File parts survive `GET /:id/contact/messages`. The harness forwards those file parts to the completions gateway. Image data URLs reach `promptAsync` only when the connected catalog marks the model as vision-capable; otherwise generate keeps the `[image: …]` description and text-file bytes so a non-vision model cannot stall the contact turn. Gateway 502 responses include `{ error, message }` so the composer can show the OpenCode error string. OpenCode `promptAsync` is not the contact conversation engine; assign uses it only on the **worker** session. Share and queued delivery still use it on Assistant bindings. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
265
+ Composer messages carry a client message ID. The service accepts the existing OpenCode delivery parts (`{ type: 'text' }` and `{ type: 'file', mime, url, filename? }` data URLs — not a second attachment store), extracts text (`[attachment]` when the send is file-only), admits the user at 202, then runs the contact harness asynchronously (`runContactTurn` / pi-agent-core, thinking off, pi `read` / `write` / `edit` / `bash` in the assistant workspace, OpenChamber tools `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `get_assistant_settings` / `update_default_prompt` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session`, merged `.agents`/`.claude` skills, plus the registered-projects catalog each turn) through the OpenChamber completions gateway (public HTTP non-streaming; in-process `onTextDelta` for contact SSE). Assistant bubbles and tool cards persist when they publish (bubble `done:true` / card insert) and turn end upserts any remainder. File parts survive `GET /:id/contact/messages`. The harness forwards those file parts to the completions gateway. Image data URLs reach `session.prompt` only when the connected catalog marks the model as vision-capable; otherwise generate keeps the `[image: …]` description and text-file bytes so a non-vision model cannot stall the contact turn. Gateway 502 responses include `{ error, message }` so the composer can show the OpenCode error string. OpenCode `session.prompt` is not the contact conversation engine; assign uses it only on the **worker** session. Share and queued delivery still use it on Assistant bindings. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
266
266
 
267
- `GET /api/openchamber/assistants/:assistantID/messages?before=&limit=` returns archived and current bindings as `{ entries: [{ sessionID, directory, info, parts }], nextCursor, complete }`. It reads assistant-owned mirror rows in chronological session and message order, pages toward older rows with an opaque stable cursor, and preserves the raw OpenCode `info` and `part` JSON. `directory` is the effective workspace for that source session and is nullable for legacy archived rows with an unknown workspace. `complete === (nextCursor === null)` marks arrival at the oldest available history. The service reads the latest persisted `limit + 1` rows first, then demand-backfills one page at a time from the newest incomplete archived session covered by the cursor; a request performs at most three upstream pages and reuses persisted pages without upstream access. Backfill page commits are independent. Each `session.messages` page attempt retries only transient 5xx/network-class SDK failures up to three times with short backoff; persistent failures still throw `upstream_error`. An authoritative OpenCode 404 for an archived or old binding marks that session's backfill complete via the `session-missing` reducer path, deletes only its uncovered event mirrors, preserves covered/admitted rows, and continues scanning other incomplete sessions so one deleted session cannot permanently block Assistant history. Successful backfill pages upsert authoritative rows without clearing provisional mirrors missing from that snapshot. Concurrent ensure that replaces a missing current binding archives the old ID and creates a new one; 404 completion never deletes covered history under either identity. A required-page failure surfaces as an upstream error only when the current request's `pageRows()` is empty; when the current range already has covered or provisional rows, the service returns that partial result and keeps the incomplete cursor retryable. Provisional event mirrors stay readable as `covered=0` fallbacks until a shrink-whitelist signal or a later authoritative upsert elevates them. Current-session live sync overrides the matching SQLite message identity in the UI.
267
+ `GET /api/openchamber/assistants/:assistantID/messages?before=&limit=` returns archived and current bindings as `{ entries: [{ sessionID, directory, info, parts }], nextCursor, complete }`. It reads assistant-owned mirror rows in chronological session and message order, pages toward older rows with an opaque stable cursor, and preserves the raw OpenCode `info` and `part` JSON. `directory` is the effective workspace for that source session and is nullable for legacy archived rows with an unknown workspace. `complete === (nextCursor === null)` marks arrival at the oldest available history. The service reads the latest persisted `limit + 1` rows first, then demand-backfills one page at a time from the newest incomplete archived session covered by the cursor; a request performs at most three upstream pages and reuses persisted pages without upstream access. Backfill page commits are independent. Each `message.list` page attempt retries only transient 5xx/network-class SDK failures up to three times with short backoff; persistent failures still throw `upstream_error`. An authoritative OpenCode 404 for an archived or old binding marks that session's backfill complete via the `session-missing` reducer path, deletes only its uncovered event mirrors, preserves covered/admitted rows, and continues scanning other incomplete sessions so one deleted session cannot permanently block Assistant history. Successful backfill pages upsert authoritative rows without clearing provisional mirrors missing from that snapshot. Concurrent ensure that replaces a missing current binding archives the old ID and creates a new one; 404 completion never deletes covered history under either identity. A required-page failure surfaces as an upstream error only when the current request's `pageRows()` is empty; when the current range already has covered or provisional rows, the service returns that partial result and keeps the incomplete cursor retryable. Provisional event mirrors stay readable as `covered=0` fallbacks until a shrink-whitelist signal or a later authoritative upsert elevates them. Current-session live sync overrides the matching SQLite message identity in the UI.
268
268
 
269
269
  Queue admission accepts `{ kind: 'assistant', assistantID }`, captures a structured binding plus provider, model, agent, nullable variant, default prompt/system, compiled text parts, and durable attachment descriptors. Direct messages, shares, and queued Assistant deliveries accept up to 129 strict OpenCode text/file parts, including up to 64 file parts. Queue dispatch uses `sendWithCapturedConfig`, so queued work never reads mutable Assistant configuration. Continuous delivery retains binding fencing and a changed binding produces `stale_target`. Stateless delivery instead belongs to the stable `assistant:<assistantID>` queue lane: it ignores disposable binding replacement, serializes prompt admissions per Assistant, and creates a fresh execution Session for every row.
270
270
 
@@ -276,7 +276,7 @@ Assistant workspaces resolve only under the Assistant workspace root or configur
276
276
 
277
277
  ### Session operations from a contact
278
278
 
279
- Contact tools expose `assign_session` (new or continued session), `steer_session` (insert a user instruction using SDK `promptAsync` with `delivery: steer`, without a model override), `stop_session` (real SDK abort), `archive_session` (SDK update `time.archived`), and `delete_session` (SDK delete). All existing-session mutations resolve directory from authoritative session metadata and apply the same registered-project scope checks. They accept exact session IDs from references, history, or `list_sessions`; ambiguous targets require clarification. Delete is permanent and is only offered for an explicit deletion request. SDK failures remain tool errors, never success confirmations; a failed mutation does not clear its watch. Successful archive/delete cancel assistant watches and pending automatic resumes, just as explicit stop does. Archive itself does not abort OpenCode execution: stopping and archiving are separate operations. Steering preserves the upstream session's model and does not create a second session. Terminal operation tools execute once per successful contact turn. All surfaces use the same server contact tool implementation.
279
+ Contact tools expose `assign_session` (new or continued session), `steer_session` (insert a user instruction using SDK `session.prompt` with `delivery: steer`, without a model override), `stop_session` (real SDK abort), `archive_session` (SDK update `time.archived`), and `delete_session` (SDK delete). All existing-session mutations resolve directory from authoritative session metadata and apply the same registered-project scope checks. They accept exact session IDs from references, history, or `list_sessions`; ambiguous targets require clarification. Delete is permanent and is only offered for an explicit deletion request. SDK failures remain tool errors, never success confirmations; a failed mutation does not clear its watch. Successful archive/delete cancel assistant watches and pending automatic resumes, just as explicit stop does. Archive itself does not abort OpenCode execution: stopping and archiving are separate operations. Steering preserves the upstream session's model and does not create a second session. Terminal operation tools execute once per successful contact turn. All surfaces use the same server contact tool implementation.
280
280
 
281
281
  ### Contact context and background notification boundary
282
282
 
@@ -291,6 +291,6 @@ Assign/watch tools forward the contact AbortSignal into their service calls and
291
291
 
292
292
  `read_session` is a read-only contact tool available on user turns and background notifications. An `@session:<id>` chip supplies identity only. The model reads contents through this tool before answering about the reference, and treats returned messages as quoted data, never as new user instructions or authorization to resume/watch/mutate. The quoted payload stays on the tool transcript for the model; contact bubbles keep only the spoken preamble and the post-read reply.
293
293
 
294
- The service resolves the exact session and registered project through the existing authoritative session resolver, then calls SDK `session.messages` with `limit` (default 20, maximum 50), optional opaque `before`, and the turn AbortSignal. The upstream `x-next-cursor` is returned unchanged as `nextCursor`. Missing sessions, transport errors, malformed message lists and cross-session message identity mismatches fail closed. Empty successful lists remain distinguishable from failures.
294
+ The service resolves the exact session and registered project through the existing authoritative session resolver, then calls SDK `message.list` with `limit` (default 20, maximum 50), optional opaque `before`, and the turn AbortSignal. The upstream `x-next-cursor` is returned unchanged as `nextCursor`. Missing sessions, transport errors, malformed message lists and cross-session message identity mismatches fail closed. Empty successful lists remain distinguishable from failures.
295
295
 
296
296
  Results retain message IDs, roles, text and bounded tool-result/file metadata. Text is limited to 4,000 characters per field and 24,000 total; each message contributes at most 20 parts. File URLs/bodies, reasoning and tool inputs are omitted. `partial` explicitly marks truncation, omissions or older pages. Reading creates no watch, prompt or other mutation.
@@ -288,7 +288,8 @@ export function extractSessionDirectory(session) {
288
288
  ? session.data
289
289
  : session;
290
290
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
291
- return trim(payload.directory, 4096)
291
+ return trim(payload.location?.directory, 4096)
292
+ || trim(payload.directory, 4096)
292
293
  || trim(payload.path, 4096)
293
294
  || trim(payload.project?.worktree, 4096)
294
295
  || trim(payload.project?.directory, 4096)
@@ -103,6 +103,9 @@ export function ensureContactSchema(db) {
103
103
  if (!boundaryColumns.has('assistant_after_ordinal')) db.exec('ALTER TABLE assistant_contact_context_boundary ADD COLUMN assistant_after_ordinal INTEGER NOT NULL DEFAULT 0');
104
104
  const watchColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_contact_watch')").all().map((column) => column.name));
105
105
  if (!watchColumns.has('resume_allowed')) db.exec('ALTER TABLE assistant_contact_watch ADD COLUMN resume_allowed INTEGER NOT NULL DEFAULT 1');
106
+ // Delivery watermark: assign prompt message ID so reused-session settle
107
+ // only counts assistant tails that belong to this delivery.
108
+ if (!watchColumns.has('message_id')) db.exec('ALTER TABLE assistant_contact_watch ADD COLUMN message_id TEXT');
106
109
  // Attachment table ownership lives in contact-attachments.ensureContactAttachmentSchema.
107
110
  }
108
111
 
@@ -1220,35 +1223,50 @@ export function clearContactMemory(db, assistantID, { updatedAt = Date.now(), up
1220
1223
  return { assistantID, afterOrdinal, memoryCleared: true };
1221
1224
  }
1222
1225
 
1223
- export function upsertContactWatch(db, { assistantID, sessionID, directory, status, updatedAt, resumeAllowed = true }) {
1226
+ export function upsertContactWatch(db, {
1227
+ assistantID,
1228
+ sessionID,
1229
+ directory,
1230
+ status,
1231
+ updatedAt,
1232
+ resumeAllowed = true,
1233
+ messageID = undefined,
1234
+ }) {
1235
+ const nextMessageID = typeof messageID === 'string' && messageID.trim()
1236
+ ? messageID.trim()
1237
+ : (messageID === null ? null : undefined);
1238
+ if (nextMessageID === undefined) {
1239
+ db.prepare(
1240
+ 'INSERT INTO assistant_contact_watch(assistant_id,session_id,directory,status,updated_at,resume_allowed,message_id) VALUES (?,?,?,?,?,?,NULL) ON CONFLICT(assistant_id,session_id) DO UPDATE SET directory=excluded.directory,status=excluded.status,updated_at=excluded.updated_at,resume_allowed=excluded.resume_allowed',
1241
+ ).run(assistantID, sessionID, directory || null, status, updatedAt, resumeAllowed ? 1 : 0);
1242
+ return;
1243
+ }
1224
1244
  db.prepare(
1225
- 'INSERT INTO assistant_contact_watch(assistant_id,session_id,directory,status,updated_at,resume_allowed) VALUES (?,?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET directory=excluded.directory,status=excluded.status,updated_at=excluded.updated_at,resume_allowed=excluded.resume_allowed',
1226
- ).run(assistantID, sessionID, directory || null, status, updatedAt, resumeAllowed ? 1 : 0);
1245
+ 'INSERT INTO assistant_contact_watch(assistant_id,session_id,directory,status,updated_at,resume_allowed,message_id) VALUES (?,?,?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET directory=excluded.directory,status=excluded.status,updated_at=excluded.updated_at,resume_allowed=excluded.resume_allowed,message_id=excluded.message_id',
1246
+ ).run(assistantID, sessionID, directory || null, status, updatedAt, resumeAllowed ? 1 : 0, nextMessageID);
1227
1247
  }
1228
1248
 
1249
+ const mapWatchRow = (row) => ({
1250
+ assistantID: row.assistant_id,
1251
+ sessionID: row.session_id,
1252
+ directory: row.directory,
1253
+ status: row.status,
1254
+ resumeAllowed: row.resume_allowed !== 0,
1255
+ messageID: typeof row.message_id === 'string' && row.message_id.trim() ? row.message_id : null,
1256
+ });
1257
+
1229
1258
  export function listWatchesBySession(db, sessionID) {
1230
- return db.prepare('SELECT assistant_id, session_id, directory, status, resume_allowed FROM assistant_contact_watch WHERE session_id=?').all(sessionID)
1231
- .map((row) => ({
1232
- assistantID: row.assistant_id,
1233
- sessionID: row.session_id,
1234
- directory: row.directory,
1235
- status: row.status,
1236
- resumeAllowed: row.resume_allowed !== 0,
1237
- }));
1259
+ return db.prepare('SELECT assistant_id, session_id, directory, status, resume_allowed, message_id FROM assistant_contact_watch WHERE session_id=?').all(sessionID)
1260
+ .map(mapWatchRow);
1238
1261
  }
1239
1262
 
1240
1263
  export function listInFlightWatches(db, assistantID) {
1241
1264
  const rows = typeof assistantID === 'string' && assistantID
1242
- ? db.prepare('SELECT assistant_id, session_id, directory, status FROM assistant_contact_watch WHERE assistant_id=?').all(assistantID)
1243
- : db.prepare('SELECT assistant_id, session_id, directory, status FROM assistant_contact_watch').all();
1265
+ ? db.prepare('SELECT assistant_id, session_id, directory, status, resume_allowed, message_id FROM assistant_contact_watch WHERE assistant_id=?').all(assistantID)
1266
+ : db.prepare('SELECT assistant_id, session_id, directory, status, resume_allowed, message_id FROM assistant_contact_watch').all();
1244
1267
  return rows
1245
1268
  .filter((row) => IN_FLIGHT_WATCH.has(row.status))
1246
- .map((row) => ({
1247
- assistantID: row.assistant_id,
1248
- sessionID: row.session_id,
1249
- directory: row.directory,
1250
- status: row.status,
1251
- }));
1269
+ .map(mapWatchRow);
1252
1270
  }
1253
1271
 
1254
1272
  export function updateSessionCardStatus(db, { assistantID, sessionID, status }) {