ompchamber 0.3.0 → 0.4.0

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 (240) hide show
  1. package/README.md +9 -6
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5291f58223.css +2 -0
  4. package/dist/client/static/js/async/{1k.544adbf77d.js → 1k.5974df3b87.js} +2 -2
  5. package/dist/client/static/js/async/3j.357c80ff38.js +2 -0
  6. package/dist/client/static/js/async/{3y.22075aa079.js → 3y.ededeebde8.js} +5 -5
  7. package/dist/client/static/js/async/51.95bcae98af.js +2 -0
  8. package/dist/client/static/js/async/6.f034c9b528.js +1 -0
  9. package/dist/client/static/js/async/a.e05da304c8.js +10 -0
  10. package/dist/client/static/js/async/ar.1e14da5588.js +1 -0
  11. package/dist/client/static/js/async/art.62139ed479.js +2 -0
  12. package/dist/client/static/js/async/ct.b74928f7a3.js +1 -0
  13. package/dist/client/static/js/async/fw.6a51998a8e.js +6 -0
  14. package/dist/client/static/js/async/g.7a4bbd4798.js +2 -0
  15. package/dist/client/static/js/async/g.7a4bbd4798.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{k.e878dd350f.js → k.7466330d1a.js} +2 -2
  17. package/dist/client/static/js/async/katex-vendor.de6a87c308.js +1 -0
  18. package/dist/client/static/js/async/kc.bbdb35b771.js +1 -0
  19. package/dist/client/static/js/async/l6.c56640c205.js +1 -0
  20. package/dist/client/static/js/async/{m.3fc017a055.js → m.4b29d34f93.js} +3 -3
  21. package/dist/client/static/js/async/{og.ae7acf03ac.js → og.634fba99ac.js} +2 -2
  22. package/dist/client/static/js/async/q.7fb4d2f374.js +21 -0
  23. package/dist/client/static/js/async/rd.cb6bf495bb.js +2 -0
  24. package/dist/client/static/js/async/rf.d626afc367.js +2 -0
  25. package/dist/client/static/js/async/s.413c2a4683.js +2 -0
  26. package/dist/client/static/js/async/s.413c2a4683.js.LICENSE.txt +6 -0
  27. package/dist/client/static/js/async/sy.d16be843a7.js +94 -0
  28. package/dist/client/static/js/async/t.cb3fcf8615.js +1 -0
  29. package/dist/client/static/js/async/ud.3065bfee31.js +2 -0
  30. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  31. package/dist/client/static/js/async/uf.46ad6209fa.js.LICENSE.txt +6 -0
  32. package/dist/client/static/js/async/v.7d28d3c241.js +34 -0
  33. package/dist/client/static/js/async/v0.394ed37c6b.js +2 -0
  34. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +6 -0
  35. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  36. package/dist/client/static/js/async/vy.bbe114c4e0.js +2 -0
  37. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +6 -0
  38. package/dist/client/static/js/async/xw.ae67ba06fd.js +31 -0
  39. package/dist/client/static/js/async/xw.ae67ba06fd.js.LICENSE.txt +4 -0
  40. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  41. package/dist/client/static/js/async/yv.2fa22f009a.js +1 -0
  42. package/dist/client/static/js/index.bf1b1fe988.js +33 -0
  43. package/dist/client/static/js/index.bf1b1fe988.js.LICENSE.txt +4 -0
  44. package/dist/client/static/js/{markdown-vendor.9da142c67b.js → markdown-vendor.71e43278b3.js} +1 -1
  45. package/dist/client/static/js/uy.5f791144ce.js +139 -0
  46. package/package.json +2 -1
  47. package/src/client/App.tsx +22 -5
  48. package/src/client/components/common/MarkdownRenderer.tsx +36 -0
  49. package/src/client/components/layout/desktop-layout/index.tsx +1 -1
  50. package/src/client/components/layout/session-sidebar/Modals.tsx +1 -1
  51. package/src/client/components/mobile/LayoutWrapper.tsx +1 -1
  52. package/src/client/components/settings/LazyModal.tsx +46 -0
  53. package/src/client/components/settings/Modal.tsx +1 -1
  54. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +43 -29
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +15 -69
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +47 -66
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +60 -0
  58. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +37 -21
  59. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +22 -4
  60. package/src/client/components/workspace/chat-timeline/index.tsx +3 -6
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +15 -19
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader.tsx +46 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput.tsx +18 -68
  64. package/src/client/components/workspace/file-explorer/TreeItem.tsx +8 -2
  65. package/src/client/hooks/chat/omp/index.ts +9 -10
  66. package/src/client/hooks/chat/omp/revalidation-throttle.ts +6 -5
  67. package/src/client/hooks/chat/timeline/actions.ts +1 -2
  68. package/src/client/hooks/chat/timeline/index.ts +5 -0
  69. package/src/client/hooks/chat/timeline/send.ts +1 -2
  70. package/src/client/hooks/chat/timeline/session-load.ts +3 -4
  71. package/src/client/hooks/ui/syntax-ready.ts +16 -3
  72. package/src/client/main.tsx +2 -5
  73. package/src/client/stubs/elkjs.ts +37 -0
  74. package/src/client/tailwind.css +10 -0
  75. package/src/server/lib/fs/git-ignore.ts +49 -0
  76. package/src/server/lib/omp/rpc/constants.ts +10 -0
  77. package/src/server/lib/omp/rpc/lifecycle.test.ts +138 -0
  78. package/src/server/lib/omp/rpc/manager.ts +32 -4
  79. package/src/server/lib/omp/rpc/session-commands.ts +32 -9
  80. package/src/server/lib/omp/rpc/session-registry.ts +3 -2
  81. package/src/server/lib/omp/rpc/subagent-liveness.test.ts +62 -0
  82. package/src/server/lib/omp/rpc/subagent-liveness.ts +100 -0
  83. package/src/server/lib/omp/session/messages.test.ts +112 -0
  84. package/src/server/lib/omp/session/messages.ts +6 -5
  85. package/src/server/routes/agent/command.ts +34 -16
  86. package/src/server/routes/fs/read.ts +15 -7
  87. package/src/shared/lib/chat/notice-row.test.ts +89 -0
  88. package/src/shared/lib/chat/notice-row.ts +78 -0
  89. package/src/shared/lib/chat/omp/abort.ts +41 -0
  90. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +154 -0
  91. package/src/shared/lib/chat/timeline/omp-callbacks.ts +36 -7
  92. package/src/shared/lib/chat/timeline/run-footer.test.ts +84 -0
  93. package/src/shared/lib/chat/timeline/run-footer.ts +96 -0
  94. package/src/shared/lib/chat/tool-output.test.ts +91 -0
  95. package/src/shared/lib/chat/tool-output.ts +54 -0
  96. package/src/shared/lib/chat/xml-envelope.ts +158 -0
  97. package/src/shared/lib/code/highlighter-lazy.ts +106 -0
  98. package/src/shared/lib/code/highlighter.ts +31 -52
  99. package/src/shared/lib/code/syntax-highlight.test.ts +36 -1
  100. package/src/shared/lib/code/syntax-highlight.ts +17 -3
  101. package/src/shared/lib/format/time.ts +19 -0
  102. package/src/shared/lib/markdown/fira-code-fonts.css +51 -0
  103. package/src/shared/lib/markdown/katex-fonts.css +20 -0
  104. package/src/shared/lib/markdown/katex.ts +101 -0
  105. package/src/shared/lib/markdown/marked.ts +23 -14
  106. package/src/shared/lib/markdown/math.test.ts +109 -0
  107. package/src/shared/lib/markdown/mermaid.ts +6 -0
  108. package/src/shared/lib/markdown/sanitize.ts +48 -2
  109. package/src/shared/lib/omp/session/mapper.ts +11 -3
  110. package/src/shared/lib/omp/session/messages-map.ts +12 -12
  111. package/src/shared/types/chat.ts +3 -1
  112. package/src/shared/types/fs.ts +3 -0
  113. package/dist/client/static/css/g.f36aa429fa.css +0 -1
  114. package/dist/client/static/css/index.a0f07455bd.css +0 -2
  115. package/dist/client/static/css/katex-vendor.ca3a0600e8.css +0 -1
  116. package/dist/client/static/font/KaTeX_AMS-Regular.1608a09b4a.woff +0 -0
  117. package/dist/client/static/font/KaTeX_AMS-Regular.4aafdb685c.ttf +0 -0
  118. package/dist/client/static/font/KaTeX_Caligraphic-Bold.b67709187b.woff +0 -0
  119. package/dist/client/static/font/KaTeX_Caligraphic-Bold.cce5b8ecea.ttf +0 -0
  120. package/dist/client/static/font/KaTeX_Caligraphic-Regular.07ef19e7b0.ttf +0 -0
  121. package/dist/client/static/font/KaTeX_Caligraphic-Regular.dad44a7fc6.woff +0 -0
  122. package/dist/client/static/font/KaTeX_Fraktur-Bold.9f256b8593.woff +0 -0
  123. package/dist/client/static/font/KaTeX_Fraktur-Bold.b18f59e1d1.ttf +0 -0
  124. package/dist/client/static/font/KaTeX_Fraktur-Regular.7c1871215e.woff +0 -0
  125. package/dist/client/static/font/KaTeX_Fraktur-Regular.ed38e79f57.ttf +0 -0
  126. package/dist/client/static/font/KaTeX_Main-Bold.b74a1a8b2c.ttf +0 -0
  127. package/dist/client/static/font/KaTeX_Main-Bold.d181c4650d.woff +0 -0
  128. package/dist/client/static/font/KaTeX_Main-BoldItalic.70d8b0a530.ttf +0 -0
  129. package/dist/client/static/font/KaTeX_Main-BoldItalic.e3f82f9d27.woff +0 -0
  130. package/dist/client/static/font/KaTeX_Main-Italic.47373d1e51.ttf +0 -0
  131. package/dist/client/static/font/KaTeX_Main-Italic.9024d815ba.woff +0 -0
  132. package/dist/client/static/font/KaTeX_Main-Regular.7f51fe0340.woff +0 -0
  133. package/dist/client/static/font/KaTeX_Main-Regular.b7f8fe9b5f.ttf +0 -0
  134. package/dist/client/static/font/KaTeX_Math-BoldItalic.a879cf8383.ttf +0 -0
  135. package/dist/client/static/font/KaTeX_Math-BoldItalic.f1035d8d5d.woff +0 -0
  136. package/dist/client/static/font/KaTeX_Math-Italic.5295ba483a.woff +0 -0
  137. package/dist/client/static/font/KaTeX_Math-Italic.939bc64440.ttf +0 -0
  138. package/dist/client/static/font/KaTeX_SansSerif-Bold.94e1e8dc5f.ttf +0 -0
  139. package/dist/client/static/font/KaTeX_SansSerif-Bold.bf59d231c3.woff +0 -0
  140. package/dist/client/static/font/KaTeX_SansSerif-Italic.7c9bc82b17.woff +0 -0
  141. package/dist/client/static/font/KaTeX_SansSerif-Italic.b4c20c84d8.ttf +0 -0
  142. package/dist/client/static/font/KaTeX_SansSerif-Regular.740484788f.woff +0 -0
  143. package/dist/client/static/font/KaTeX_SansSerif-Regular.d4d7ba4804.ttf +0 -0
  144. package/dist/client/static/font/KaTeX_Script-Regular.0750571032.woff +0 -0
  145. package/dist/client/static/font/KaTeX_Script-Regular.fe9cbbe1a0.ttf +0 -0
  146. package/dist/client/static/font/KaTeX_Size1-Regular.e1e279cbdd.woff +0 -0
  147. package/dist/client/static/font/KaTeX_Size1-Regular.fabc004aab.ttf +0 -0
  148. package/dist/client/static/font/KaTeX_Size2-Regular.577270225e.woff +0 -0
  149. package/dist/client/static/font/KaTeX_Size2-Regular.d6b476ecd3.ttf +0 -0
  150. package/dist/client/static/font/KaTeX_Size3-Regular.9acaf01c64.woff +0 -0
  151. package/dist/client/static/font/KaTeX_Size3-Regular.a144ef5840.ttf +0 -0
  152. package/dist/client/static/font/KaTeX_Size4-Regular.7a996c9da2.woff +0 -0
  153. package/dist/client/static/font/KaTeX_Size4-Regular.fbccdabe0a.ttf +0 -0
  154. package/dist/client/static/font/KaTeX_Typewriter-Regular.6258592bdc.woff +0 -0
  155. package/dist/client/static/font/KaTeX_Typewriter-Regular.d97aaf4a1e.ttf +0 -0
  156. package/dist/client/static/font/fira-code-cyrillic-400-normal.5971438a4a.woff2 +0 -0
  157. package/dist/client/static/font/fira-code-cyrillic-400-normal.9bb7bfea9c.woff +0 -0
  158. package/dist/client/static/font/fira-code-cyrillic-500-normal.5486265b18.woff2 +0 -0
  159. package/dist/client/static/font/fira-code-cyrillic-500-normal.89f92bfb31.woff +0 -0
  160. package/dist/client/static/font/fira-code-cyrillic-600-normal.2805b497e5.woff +0 -0
  161. package/dist/client/static/font/fira-code-cyrillic-600-normal.86e0df22a3.woff2 +0 -0
  162. package/dist/client/static/font/fira-code-cyrillic-700-normal.505a88f22a.woff +0 -0
  163. package/dist/client/static/font/fira-code-cyrillic-700-normal.76f5e8d2e2.woff2 +0 -0
  164. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.3c4b15e57d.woff2 +0 -0
  165. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.61323debaa.woff +0 -0
  166. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.0f34210f2a.woff2 +0 -0
  167. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.2497cfa1aa.woff +0 -0
  168. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.40fce94af9.woff2 +0 -0
  169. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.6c8774b829.woff +0 -0
  170. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.4b742eb865.woff +0 -0
  171. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.e75eb4fccc.woff2 +0 -0
  172. package/dist/client/static/font/fira-code-greek-400-normal.bb900468b6.woff2 +0 -0
  173. package/dist/client/static/font/fira-code-greek-400-normal.be76e699ba.woff +0 -0
  174. package/dist/client/static/font/fira-code-greek-500-normal.176c9fb001.woff2 +0 -0
  175. package/dist/client/static/font/fira-code-greek-500-normal.d2c596136b.woff +0 -0
  176. package/dist/client/static/font/fira-code-greek-600-normal.490c3deed7.woff +0 -0
  177. package/dist/client/static/font/fira-code-greek-600-normal.7df14376d9.woff2 +0 -0
  178. package/dist/client/static/font/fira-code-greek-700-normal.c82b6072fc.woff2 +0 -0
  179. package/dist/client/static/font/fira-code-greek-700-normal.d5f2b2917b.woff +0 -0
  180. package/dist/client/static/font/fira-code-greek-ext-400-normal.4849a79781.woff +0 -0
  181. package/dist/client/static/font/fira-code-greek-ext-400-normal.7f5bfc4538.woff2 +0 -0
  182. package/dist/client/static/font/fira-code-greek-ext-500-normal.aaccca4075.woff2 +0 -0
  183. package/dist/client/static/font/fira-code-greek-ext-500-normal.e383181256.woff +0 -0
  184. package/dist/client/static/font/fira-code-greek-ext-600-normal.78cb4bff8e.woff +0 -0
  185. package/dist/client/static/font/fira-code-greek-ext-600-normal.d6c93d54f9.woff2 +0 -0
  186. package/dist/client/static/font/fira-code-greek-ext-700-normal.57ec7c92d9.woff +0 -0
  187. package/dist/client/static/font/fira-code-greek-ext-700-normal.8fafdae5e8.woff2 +0 -0
  188. package/dist/client/static/font/fira-code-latin-400-normal.973295532b.woff +0 -0
  189. package/dist/client/static/font/fira-code-latin-500-normal.7ae2be8fb8.woff +0 -0
  190. package/dist/client/static/font/fira-code-latin-600-normal.1dbbe5bc27.woff +0 -0
  191. package/dist/client/static/font/fira-code-latin-700-normal.497a70148b.woff +0 -0
  192. package/dist/client/static/font/fira-code-latin-ext-400-normal.9dfae29aaf.woff +0 -0
  193. package/dist/client/static/font/fira-code-latin-ext-400-normal.e534ae46e2.woff2 +0 -0
  194. package/dist/client/static/font/fira-code-latin-ext-500-normal.24c7b781b0.woff +0 -0
  195. package/dist/client/static/font/fira-code-latin-ext-500-normal.aad44ab072.woff2 +0 -0
  196. package/dist/client/static/font/fira-code-latin-ext-600-normal.7bed0f2f7e.woff +0 -0
  197. package/dist/client/static/font/fira-code-latin-ext-600-normal.c509a7c9f1.woff2 +0 -0
  198. package/dist/client/static/font/fira-code-latin-ext-700-normal.6c66c8b0a9.woff2 +0 -0
  199. package/dist/client/static/font/fira-code-latin-ext-700-normal.88f64e2822.woff +0 -0
  200. package/dist/client/static/font/fira-code-symbols2-400-normal.97d3f4b17c.woff +0 -0
  201. package/dist/client/static/font/fira-code-symbols2-500-normal.b06f1a06e4.woff +0 -0
  202. package/dist/client/static/font/fira-code-symbols2-600-normal.304b653b93.woff +0 -0
  203. package/dist/client/static/font/fira-code-symbols2-700-normal.d8ce0ad586.woff +0 -0
  204. package/dist/client/static/js/async/3j.a2fcea99be.js +0 -2
  205. package/dist/client/static/js/async/51.1938cec320.js +0 -2
  206. package/dist/client/static/js/async/6.7929428505.js +0 -1
  207. package/dist/client/static/js/async/69.a18a10882f.js +0 -1
  208. package/dist/client/static/js/async/a.bce4542392.js +0 -1
  209. package/dist/client/static/js/async/ar.9dc8b1992a.js +0 -2
  210. package/dist/client/static/js/async/bi.cbfe8eaa8e.js +0 -1
  211. package/dist/client/static/js/async/ct.38d9da8a5a.js +0 -1
  212. package/dist/client/static/js/async/fw.fb1a08b92d.js +0 -6
  213. package/dist/client/static/js/async/l6.45f3b62e15.js +0 -1
  214. package/dist/client/static/js/async/q.0b6fb9bf8d.js +0 -21
  215. package/dist/client/static/js/async/rd.b77011ab7f.js +0 -2
  216. package/dist/client/static/js/async/rf.cfbde7f744.js +0 -2
  217. package/dist/client/static/js/async/s.c7d521a8ae.js +0 -2
  218. package/dist/client/static/js/async/sy.7345abcd67.js +0 -94
  219. package/dist/client/static/js/async/t.0dcb01dfde.js +0 -1
  220. package/dist/client/static/js/async/v.5c7a4b087b.js +0 -1
  221. package/dist/client/static/js/async/vu.311f1d202c.js +0 -34
  222. package/dist/client/static/js/async/yg.9bd4e09ecb.js +0 -149
  223. package/dist/client/static/js/async/yv.593a3f7d54.js +0 -1
  224. package/dist/client/static/js/g.124fba0826.js +0 -139
  225. package/dist/client/static/js/index.907fd4670e.js +0 -68
  226. package/dist/client/static/js/katex-vendor.95e48cf65a.js +0 -1
  227. package/src/shared/lib/chat/order.ts +0 -40
  228. /package/dist/client/static/js/async/{1k.544adbf77d.js.LICENSE.txt → 1k.5974df3b87.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{3j.a2fcea99be.js.LICENSE.txt → 3j.357c80ff38.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{3y.22075aa079.js.LICENSE.txt → 3y.ededeebde8.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{51.1938cec320.js.LICENSE.txt → 51.95bcae98af.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{ar.9dc8b1992a.js.LICENSE.txt → a.e05da304c8.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/async/{fw.fb1a08b92d.js.LICENSE.txt → art.62139ed479.js.LICENSE.txt} +0 -0
  234. /package/dist/client/static/js/async/{m.3fc017a055.js.LICENSE.txt → fw.6a51998a8e.js.LICENSE.txt} +0 -0
  235. /package/dist/client/static/js/async/{q.0b6fb9bf8d.js.LICENSE.txt → m.4b29d34f93.js.LICENSE.txt} +0 -0
  236. /package/dist/client/static/js/async/{rf.cfbde7f744.js.LICENSE.txt → q.7fb4d2f374.js.LICENSE.txt} +0 -0
  237. /package/dist/client/static/js/async/{rd.b77011ab7f.js.LICENSE.txt → rd.cb6bf495bb.js.LICENSE.txt} +0 -0
  238. /package/dist/client/static/js/async/{s.c7d521a8ae.js.LICENSE.txt → rf.d626afc367.js.LICENSE.txt} +0 -0
  239. /package/dist/client/static/js/{index.907fd4670e.js.LICENSE.txt → async/ud.3065bfee31.js.LICENSE.txt} +0 -0
  240. /package/dist/client/static/js/{g.124fba0826.js.LICENSE.txt → uy.5f791144ce.js.LICENSE.txt} +0 -0
@@ -21,7 +21,6 @@ import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
21
21
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
22
22
 
23
23
  import type { ChatMessageData } from '@/shared/types';
24
- import { normalizeNoticePositions } from '@/shared/lib/chat/order';
25
24
  import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
26
25
 
27
26
  export interface SessionDataShape {
@@ -210,9 +209,9 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
210
209
  if (isGeneratingRef.current) {
211
210
  const fetchedIds = fetched.map((m: ChatMessageData) => m.id);
212
211
  const liveTail = localMessagesRef.current.filter(m => !fetchedIds.includes(m.id));
213
- setLocalMessages(normalizeNoticePositions([...fetched, ...liveTail]));
212
+ setLocalMessages([...fetched, ...liveTail]);
214
213
  } else {
215
- setLocalMessages(normalizeNoticePositions(fetched));
214
+ setLocalMessages(fetched);
216
215
  }
217
216
  } else if (!sessionId.startsWith('new-') && !optimisticOwnsTail) {
218
217
  setLocalMessages([]);
@@ -284,7 +283,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
284
283
  setLocalMessages(prev => {
285
284
  // Drop overlap guard: the server window is index-based, so the
286
285
  // fetched slice is strictly older than everything already mounted.
287
- return [...normalizeNoticePositions(older), ...prev];
286
+ return [...older, ...prev];
288
287
  });
289
288
  // Bump the prepend tick so the layout effect below re-runs for THIS
290
289
  // commit — it previously depended only on stable identities and ran
@@ -5,13 +5,26 @@
5
5
 
6
6
  import { useEffect, useState } from 'preact/hooks';
7
7
 
8
- import { isSyntaxReady, onSyntaxReady } from '@/shared/lib/code/highlighter';
8
+ import { isSyntaxReady, onLanguageReady, onSyntaxReady } from '@/shared/lib/code/highlighter';
9
9
 
10
- /** Re-renders once the Shiki highlighter has finished warming up. */
10
+ /**
11
+ * Re-renders when the Shiki highlighter finishes booting **and** whenever a
12
+ * grammar lands. Grammars are fetched on demand — a language nobody renders is
13
+ * never downloaded — so a component may see a `true` value here while the
14
+ * grammar for its own code is still in flight; the second subscription is what
15
+ * swaps the plain-text fallback for tokenized markup once the chunk arrives.
16
+ */
11
17
  export function useSyntaxReady(): boolean {
12
18
  const [ready, setReady] = useState<boolean>(() => isSyntaxReady());
13
19
 
14
- useEffect(() => onSyntaxReady(() => setReady(true)), []);
20
+ useEffect(() => {
21
+ const offBoot = onSyntaxReady(() => setReady(true));
22
+ const offLang = onLanguageReady(() => setReady(true));
23
+ return () => {
24
+ offBoot();
25
+ offLang();
26
+ };
27
+ }, []);
15
28
 
16
29
  return ready;
17
30
  }
@@ -4,11 +4,8 @@ import { bootSyntax } from '@/shared/lib/code/highlighter';
4
4
  import { primeChamberSettings } from '@/shared/lib/settings/client';
5
5
 
6
6
  import '@/client/tailwind.css';
7
- import 'katex/dist/katex.min.css';
8
- import '@fontsource/fira-code/400.css';
9
- import '@fontsource/fira-code/500.css';
10
- import '@fontsource/fira-code/600.css';
11
- import '@fontsource/fira-code/700.css';
7
+ import '@/shared/lib/markdown/katex-fonts.css';
8
+ import '@/shared/lib/markdown/fira-code-fonts.css';
12
9
 
13
10
  const root = document.getElementById('app');
14
11
  const bootstrap = window.__OMP_BOOTSTRAP__;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Browser replacement for `elkjs`, aliased in `rsbuild.config.ts`.
8
+ *
9
+ * Mermaid 12 changed the global `layout` default from `dagre` to `elk`, so every
10
+ * flowchart pulled in `elkjs/lib/elk.bundled.js` whether the diagram asked for
11
+ * ELK or not — the single largest artifact in the client build (~1.4 MB raw,
12
+ * 434 kB gzip). `src/shared/lib/markdown/mermaid.ts` pins `layout: 'dagre'`,
13
+ * which is what makes this stub safe: with dagre selected explicitly, mermaid
14
+ * never calls into `runElkLayoutCore`, the only code path that constructs an ELK
15
+ * instance (verified across 20 diagram types, byte-identical results to a build
16
+ * with the real elkjs).
17
+ *
18
+ * `layout: 'elk'` is still a valid *request* — a diagram using the
19
+ * `flowchart-elk` directive, or a syntax flagged `@config{layout: "elk"}`. Those
20
+ * now fail loudly here instead of silently rendering with a different layout
21
+ * than asked for. Throwing is deliberate: mermaid's `render()` propagates the
22
+ * error to the caller, and `mermaid.ts` already renders a fallback block on
23
+ * failure, so a mis-specified diagram degrades to a visible message rather than
24
+ * a wrong diagram.
25
+ */
26
+
27
+ function unavailable(): never {
28
+ throw new Error('elkjs is not bundled in this build; use the default dagre layout');
29
+ }
30
+
31
+ export class ELK {}
32
+
33
+ export default new Proxy(ELK as unknown as object, {
34
+ construct: unavailable,
35
+ apply: unavailable,
36
+ get: unavailable,
37
+ }) as unknown as typeof ELK;
@@ -285,6 +285,16 @@
285
285
  [data-theme="one-dark-pro-soft"] .shiki span { color: var(--shiki-dark); }
286
286
  .prose-content .katex-display { overflow-x: auto; overflow-y: hidden; }
287
287
 
288
+ /* Math placeholder: `marked.ts` emits a `.math-pending` span and KaTeX is
289
+ * fetched on demand (~522 kB async chunk), so the span is empty for one frame.
290
+ * Reserve display-math height so the message does not jump when the formula
291
+ * lands, and keep invalid/queued source faintly visible rather than blank. */
292
+ .prose-content .math-pending::before {
293
+ content: '…';
294
+ opacity: 0.35;
295
+ }
296
+ .prose-content p:has(> .math-pending[data-math-display='1']) { min-height: 2.5rem; }
297
+
288
298
  /* Streaming placeholder: remend turns a half-received [text](url into this
289
299
  * span; it becomes a real link once the URL arrives in a later frame. */
290
300
  .prose-content .md-incomplete-link {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Git-ignore resolution for the Files panel.
8
+ *
9
+ * `git check-ignore` is the only authority that evaluates every rule source in
10
+ * one pass — per-directory `.gitignore`, `.git/info/exclude`, and the global
11
+ * `core.excludesFile` (`~/.gitignore`) — so the panel dims exactly what git
12
+ * would refuse to track. Paths are fed NUL-separated over stdin (`--stdin -z`)
13
+ * so a directory listing of any size cannot overflow argv, and the verbatim
14
+ * NUL-separated reply maps straight back onto those same paths.
15
+ *
16
+ * Paths must be relative to `dir` and must exist on disk: git resolves
17
+ * directory-only patterns (`build/`) by stat-ing the path.
18
+ */
19
+ export async function collectIgnoredPaths(dir: string, relPaths: string[]): Promise<Set<string>> {
20
+ if (relPaths.length === 0) return new Set();
21
+
22
+ try {
23
+ const proc = Bun.spawn({
24
+ cmd: ['git', 'check-ignore', '--stdin', '-z'],
25
+ cwd: dir,
26
+ stdin: 'pipe',
27
+ stdout: 'pipe',
28
+ stderr: 'ignore',
29
+ timeout: 10_000,
30
+ });
31
+
32
+ // Drain stdout while the listing is written: a directory big enough to
33
+ // fill both pipe buffers would otherwise deadlock child against parent.
34
+ const stdout = new Response(proc.stdout).text();
35
+ proc.stdin.write(relPaths.map(rel => `${rel}\0`).join(''));
36
+ // FileSink.end() types as `number | Promise<number>` — EPIPE (child gone)
37
+ // must not surface as an unhandled rejection.
38
+ void Promise.resolve(proc.stdin.end()).catch(() => {});
39
+
40
+ const [text, exitCode] = await Promise.all([stdout, proc.exited]);
41
+ // 0 = at least one path matched, 1 = none, 128 = no repository. Only 0 can
42
+ // carry a match list.
43
+ if (exitCode !== 0) return new Set();
44
+
45
+ return new Set(text.split('\0').filter(Boolean));
46
+ } catch {
47
+ return new Set(); // git missing / spawn failed — nothing is dimmed
48
+ }
49
+ }
@@ -70,12 +70,22 @@ export interface WebSessionState {
70
70
  }
71
71
 
72
72
  export const IDLE_DESTROY_MS = 10 * 60 * 1000;
73
+ // Longest a subagent may go without a frame before the wrapper stops counting
74
+ // it as live work. A terminal frame lost to a protocol hiccup would otherwise
75
+ // pin the session's omp process (and its whole process group) open forever; the
76
+ // price of the window is at most one idle omp process.
77
+ export const SUBAGENT_STALE_MS = 30 * 60 * 1000;
73
78
  export const READY_TIMEOUT_MS = 120_000;
74
79
  export const GET_STATE_TIMEOUT_MS = 5_000;
75
80
  export const PROMPT_ACK_TIMEOUT_MS = 30_000;
76
81
  export const NON_TERMINAL_CONTINUATION_GRACE_MS = 2_000;
77
82
  export const AWAITING_AGENT_START_TIMEOUT_MS = 10_000;
78
83
  export const RESTARTING_MESSAGE = 'This session is restarting — retry in a moment.';
84
+ // A command timeout is not proof an omp child is wedged: omp runs RPC handlers
85
+ // one at a time, so a slow `get_state`/`prompt` ack can simply be queued behind
86
+ // the running turn. That session keeps its process — only a session that is
87
+ // idle AND unresponsive is reset.
88
+ export const SESSION_BUSY_MESSAGE = 'The OMP session is busy with the running turn; retry once it settles.';
79
89
 
80
90
  export class WebRpcError extends Error {
81
91
  readonly code: string;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The wrapper's lifecycle rules: which conditions may reclaim a session's omp
8
+ * process. A reset kills the whole process group, so a wrong answer here
9
+ * destroys a live turn and every subagent under it.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'bun:test';
13
+
14
+ import { AgentSessionWrapper, WebRpcError } from '@/server/lib/omp/rpc/manager';
15
+ import { RpcCommandTimeoutError, type RpcProcess } from '@/server/lib/omp/rpc/process';
16
+
17
+ /** Commands whose ack is fabricated as timed out; everything else resolves, so
18
+ * only the timeout policy is under test. */
19
+ const TIMED_OUT_COMMANDS: Record<string, true> = { get_state: true, prompt: true };
20
+
21
+ interface StubProcess {
22
+ proc: RpcProcess;
23
+ emit(frame: Record<string, unknown>): void;
24
+ disposeCount(): number;
25
+ }
26
+
27
+ function makeProc(): StubProcess {
28
+ let listener: ((frame: unknown) => void) | null = null;
29
+ let disposals = 0;
30
+ const proc = {
31
+ isAlive: true,
32
+ pid: 4242,
33
+ onFrame(next: (frame: unknown) => void) {
34
+ listener = next;
35
+ return () => {
36
+ listener = null;
37
+ };
38
+ },
39
+ sendCommand(command: { type: string }) {
40
+ if (TIMED_OUT_COMMANDS[command.type] === true) {
41
+ return Promise.reject(new RpcCommandTimeoutError(command.type, 1));
42
+ }
43
+ return Promise.resolve(undefined);
44
+ },
45
+ sendFrame() {},
46
+ async dispose() {
47
+ disposals += 1;
48
+ },
49
+ };
50
+ return {
51
+ proc: proc as unknown as RpcProcess,
52
+ emit: (frame) => listener?.(frame),
53
+ disposeCount: () => disposals,
54
+ };
55
+ }
56
+
57
+ async function rejectionOf(operation: Promise<unknown>): Promise<WebRpcError> {
58
+ const error = await operation.then(() => null, (caught: unknown) => caught);
59
+ expect(error).toBeInstanceOf(WebRpcError);
60
+ return error as WebRpcError;
61
+ }
62
+
63
+ describe('AgentSessionWrapper lifecycle', () => {
64
+ // Every command arms the wrapper's idle timer; keep the clock fake so a test
65
+ // never schedules a real ten-minute window.
66
+ beforeEach(() => {
67
+ vi.useFakeTimers();
68
+ });
69
+ afterEach(() => {
70
+ vi.useRealTimers();
71
+ });
72
+
73
+ test('resets a session that ignores commands while idle', async () => {
74
+ const stub = makeProc();
75
+ const wrapper = new AgentSessionWrapper(stub.proc, '/tmp');
76
+
77
+ const error = await rejectionOf(wrapper.send({ type: 'get_state' }));
78
+
79
+ expect(error.code).toBe('session_unresponsive');
80
+ expect(stub.disposeCount()).toBe(1);
81
+ });
82
+
83
+ test('keeps the process when a get_state times out mid-turn', async () => {
84
+ const stub = makeProc();
85
+ const wrapper = new AgentSessionWrapper(stub.proc, '/tmp');
86
+ wrapper.start();
87
+ stub.emit({ type: 'agent_start' });
88
+
89
+ const error = await rejectionOf(wrapper.send({ type: 'get_state' }));
90
+
91
+ expect(error.code).toBe('session_busy');
92
+ expect(stub.disposeCount()).toBe(0);
93
+ expect(wrapper.isRunning()).toBe(true);
94
+ });
95
+
96
+ test('keeps the process when a prompt ack times out mid-turn', async () => {
97
+ const stub = makeProc();
98
+ const wrapper = new AgentSessionWrapper(stub.proc, '/tmp');
99
+ wrapper.start();
100
+ stub.emit({ type: 'agent_start' });
101
+
102
+ const error = await rejectionOf(wrapper.send({ type: 'prompt', message: 'hi' }));
103
+
104
+ expect(error.code).toBe('session_busy');
105
+ expect(stub.disposeCount()).toBe(0);
106
+ });
107
+
108
+ test('idle reclaim waits for subagents that outlive the turn', () => {
109
+ const stub = makeProc();
110
+ const wrapper = new AgentSessionWrapper(stub.proc, '/tmp', null, { idleDestroyMs: 20 });
111
+ wrapper.start();
112
+
113
+ stub.emit({ type: 'agent_start' });
114
+ stub.emit({ type: 'agent_end', isTerminal: true });
115
+ stub.emit({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 0 } });
116
+ expect(wrapper.isRunning()).toBe(false);
117
+ expect(wrapper.isBusy()).toBe(true);
118
+
119
+ vi.advanceTimersByTime(200);
120
+ expect(stub.disposeCount()).toBe(0);
121
+
122
+ stub.emit({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'completed', index: 0 } });
123
+ expect(wrapper.isBusy()).toBe(false);
124
+
125
+ vi.advanceTimersByTime(200);
126
+ expect(stub.disposeCount()).toBe(1);
127
+ });
128
+
129
+ test('force_reset reclaims a process whose turn will not stop', async () => {
130
+ const stub = makeProc();
131
+ const wrapper = new AgentSessionWrapper(stub.proc, '/tmp');
132
+ stub.emit({ type: 'agent_start' });
133
+
134
+ await wrapper.send({ type: 'force_reset' });
135
+
136
+ expect(stub.disposeCount()).toBe(1);
137
+ });
138
+ });
@@ -20,8 +20,9 @@ import { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
20
20
  import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
21
21
  import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
22
22
  import { dispatchSessionCommand } from '@/server/lib/omp/rpc/session-commands';
23
+ import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
23
24
  import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
24
- import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, NON_TERMINAL_CONTINUATION_GRACE_MS, READY_TIMEOUT_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
25
+ import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, NON_TERMINAL_CONTINUATION_GRACE_MS, READY_TIMEOUT_MS, SUBAGENT_STALE_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
25
26
 
26
27
  export type {
27
28
  AgentEvent,
@@ -31,6 +32,13 @@ export type {
31
32
  } from '@/server/lib/omp/rpc/constants';
32
33
  export { WebRpcError, resolveSpawnCwd } from '@/server/lib/omp/rpc/constants';
33
34
 
35
+ /** Overrides for the wrapper's own timers. Production always uses the
36
+ * constant; tests shrink the window so the lifecycle rules stay fast. */
37
+ export interface AgentSessionWrapperOptions {
38
+ /** Idle window before an unused process is reclaimed. */
39
+ idleDestroyMs?: number;
40
+ }
41
+
34
42
  export class AgentSessionWrapper {
35
43
  private listeners: EventListener[] = [];
36
44
  promptRunning = false;
@@ -55,14 +63,20 @@ export class AgentSessionWrapper {
55
63
  // `extension_ui_request`, so a client that reloads mid-dialog can only learn
56
64
  // the id it must answer from here.
57
65
  private readonly pendingUiDialogs = new PendingUiDialogs();
66
+ // Live subagents, folded from the frames omp streams after the subscription
67
+ // set in initialize(). No other flag on this wrapper can see them, and an
68
+ // idle reclaim or a spawn-mode reconcile must never kill a running subagent.
69
+ private readonly subagents = new SubagentLiveness();
70
+ readonly idleDestroyMs: number;
58
71
  proc: RpcProcess;
59
72
  readonly cwd: string;
60
73
  private readonly recordedCwd: string | null;
61
74
 
62
- constructor(proc: RpcProcess, cwd: string, recordedCwd?: string | null) {
75
+ constructor(proc: RpcProcess, cwd: string, recordedCwd?: string | null, options: AgentSessionWrapperOptions = {}) {
63
76
  this.proc = proc;
64
77
  this.cwd = cwd;
65
78
  this.recordedCwd = recordedCwd ?? null;
79
+ this.idleDestroyMs = options.idleDestroyMs ?? IDLE_DESTROY_MS;
66
80
  }
67
81
 
68
82
  get sessionId(): string {
@@ -87,6 +101,13 @@ export class AgentSessionWrapper {
87
101
  return this.isAlive() && (this.promptRunning || this.streaming || this.compacting || this.bashRunning);
88
102
  }
89
103
 
104
+ /** Anything in flight that a process reset would destroy: the current turn,
105
+ * a compaction, a shell command, or live subagents — which outlive the turn
106
+ * that spawned them, so no other flag here can see them. */
107
+ isBusy(): boolean {
108
+ return this.isRunning() || this.subagents.liveCount(Date.now(), SUBAGENT_STALE_MS) > 0;
109
+ }
110
+
90
111
  start(): void {
91
112
  this.unsubscribeFrames = this.proc.onFrame((frame) => this.handleFrame(frame));
92
113
  this.resetIdleTimer();
@@ -188,6 +209,13 @@ export class AgentSessionWrapper {
188
209
  case 'extension_ui_request':
189
210
  this.pendingUiDialogs.track(event);
190
211
  break;
212
+ // Subagent frames carry no turn state, but they are the only proof that
213
+ // work is still running once the parent turn has ended.
214
+ case 'subagent_lifecycle':
215
+ case 'subagent_progress':
216
+ case 'subagent_event':
217
+ this.subagents.observe(event, Date.now());
218
+ break;
191
219
  case 'response': {
192
220
  if (event.success === false && event.command === 'prompt') {
193
221
  this.promptRunning = false;
@@ -226,12 +254,12 @@ export class AgentSessionWrapper {
226
254
  this.lastIdleReset = now;
227
255
  if (this.idleTimer) clearTimeout(this.idleTimer);
228
256
  this.idleTimer = setTimeout(() => {
229
- if (this.isRunning()) {
257
+ if (this.isBusy()) {
230
258
  this.resetIdleTimer(true);
231
259
  return;
232
260
  }
233
261
  this.destroy();
234
- }, IDLE_DESTROY_MS);
262
+ }, this.idleDestroyMs);
235
263
  }
236
264
 
237
265
  onEvent(listener: EventListener): () => void {
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { RpcCommandTimeoutError, type RpcProcess } from '@/server/lib/omp/rpc/process';
13
- import { AWAITING_AGENT_START_TIMEOUT_MS, GET_STATE_TIMEOUT_MS, IMAGE_BEARING_COMMANDS, PASSTHROUGH_COMMANDS, PROMPT_ACK_TIMEOUT_MS, RESTARTING_MESSAGE, WebRpcError, toImageContents, type AgentEvent, type RpcSessionState, validateAgentImages } from '@/server/lib/omp/rpc/constants';
13
+ import { AWAITING_AGENT_START_TIMEOUT_MS, GET_STATE_TIMEOUT_MS, IMAGE_BEARING_COMMANDS, PASSTHROUGH_COMMANDS, PROMPT_ACK_TIMEOUT_MS, RESTARTING_MESSAGE, SESSION_BUSY_MESSAGE, WebRpcError, toImageContents, type AgentEvent, type RpcSessionState, validateAgentImages } from '@/server/lib/omp/rpc/constants';
14
14
  import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
15
15
  import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
16
16
  import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
@@ -21,6 +21,10 @@ export interface SessionCommandHost extends WebStateHost {
21
21
  restarting: boolean;
22
22
  proc: RpcProcess;
23
23
  isAlive(): boolean;
24
+ /** Anything a reset would destroy: the running turn, a compaction, a shell
25
+ * command, or live subagents. A timed-out command against a busy session is
26
+ * queued behind that work, not evidence the child is wedged. */
27
+ isBusy(): boolean;
24
28
  /** Real omp session id (empty before the first get_state). */
25
29
  sessionId: string;
26
30
  emit(event: AgentEvent): void;
@@ -31,6 +35,20 @@ export interface SessionCommandHost extends WebStateHost {
31
35
  destroyAndWait(): Promise<void>;
32
36
  }
33
37
 
38
+ /** Decide a command timeout's consequence. omp runs RPC handlers one at a
39
+ * time, so a late `get_state`/`prompt` ack is usually queued behind the
40
+ * running turn (or behind a subagent's spawn): resetting that child would
41
+ * throw away a live turn and every subagent it owns. Only a session that is
42
+ * demonstrably idle AND unresponsive is reclaimed.
43
+ *
44
+ * Never throws `session_unresponsive` for a busy session, and never suggests a
45
+ * retry of a command whose acceptance is unknown. */
46
+ async function settleCommandTimeout(host: SessionCommandHost): Promise<never> {
47
+ if (host.isBusy()) throw new WebRpcError(SESSION_BUSY_MESSAGE, 'session_busy');
48
+ await host.destroyAndWait();
49
+ throw new WebRpcError('The OMP session stopped responding and was reset.', 'session_unresponsive');
50
+ }
51
+
34
52
  export async function dispatchSessionCommand(host: SessionCommandHost, command: Record<string, unknown>): Promise<unknown> {
35
53
  if (host.restarting) throw new WebRpcError(RESTARTING_MESSAGE, 'session_restarting');
36
54
  if (!host.isAlive()) throw new Error('Session is no longer running');
@@ -78,10 +96,10 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
78
96
  host.awaitingAgentStart = false;
79
97
  host.awaitingAgentStartDeadline = 0;
80
98
  notifyRunningChange();
81
- if (error instanceof RpcCommandTimeoutError) {
82
- await host.destroyAndWait();
83
- throw new WebRpcError('The OMP session stopped responding and was reset.', 'session_unresponsive');
84
- }
99
+ // The ack may simply be queued behind a running turn: omp accepts the
100
+ // prompt before the turn it starts. No reset, and the client must not
101
+ // resend — a duplicate prompt would run twice.
102
+ if (error instanceof RpcCommandTimeoutError) await settleCommandTimeout(host);
85
103
  throw error;
86
104
  } finally {
87
105
  if (!streamingBehavior) {
@@ -102,15 +120,20 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
102
120
  });
103
121
  return null;
104
122
 
123
+ // Escape hatch behind the Stop button: `abort` resolves only once the turn
124
+ // actually stops, so a session wedged on a subagent never answers it. The
125
+ // caller (shared/lib/chat/omp/abort.ts) escalates to this after a grace
126
+ // period and accepts losing the in-flight work.
127
+ case 'force_reset':
128
+ await host.destroyAndWait();
129
+ return null;
130
+
105
131
  case 'get_state': {
106
132
  try {
107
133
  const state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
108
134
  return buildWebState(host, state);
109
135
  } catch (error) {
110
- if (error instanceof RpcCommandTimeoutError) {
111
- await host.destroyAndWait();
112
- throw new WebRpcError('The OMP session stopped responding and was reset.', 'session_unresponsive');
113
- }
136
+ if (error instanceof RpcCommandTimeoutError) await settleCommandTimeout(host);
114
137
  throw error;
115
138
  }
116
139
  }
@@ -131,8 +131,9 @@ export function getSpawnApprovalMode(session: AgentSessionWrapper): ApprovalMode
131
131
  * Returns true when the session was destroyed (caller MUST respawn). */
132
132
  export async function reconcileSpawnApprovalMode(session: AgentSessionWrapper, desired: ApprovalMode): Promise<boolean> {
133
133
  if (getSpawnApprovalMode(session) === desired) return false;
134
- // Never kill an in-flight run — the caller would lose the active turn.
135
- if (session.isRunning()) return false;
134
+ // Never kill in-flight work — the caller would lose the active turn, and a
135
+ // live subagent outlives that turn.
136
+ if (session.isBusy()) return false;
136
137
  // A brand-new session has no JSONL on disk yet; destroying it would 404 the
137
138
  // next request that tries to resolve its file.
138
139
  if (!session.sessionFile) return false;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
9
+
10
+ const NOW = 1_000_000;
11
+ const STALE_MS = 60_000;
12
+
13
+ describe('SubagentLiveness', () => {
14
+ test('counts a started subagent until its terminal lifecycle arrives', () => {
15
+ const live = new SubagentLiveness();
16
+ live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 0 } }, NOW);
17
+ expect(live.liveCount(NOW, STALE_MS)).toBe(1);
18
+
19
+ live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'completed', index: 0 } }, NOW + 1);
20
+ expect(live.liveCount(NOW + 1, STALE_MS)).toBe(0);
21
+ });
22
+
23
+ test('an id-less progress frame lands on the lifecycle entry it names', () => {
24
+ const live = new SubagentLiveness();
25
+ live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 3 } }, NOW);
26
+ live.observe({ type: 'subagent_progress', payload: { index: 3, status: 'running' } }, NOW + 1);
27
+ expect(live.liveCount(NOW + 1, STALE_MS)).toBe(1);
28
+
29
+ // A terminal progress frame for the same index must clear that one entry —
30
+ // not leave a second key behind holding the session open.
31
+ live.observe({ type: 'subagent_progress', payload: { index: 3, status: 'completed' } }, NOW + 2);
32
+ expect(live.liveCount(NOW + 2, STALE_MS)).toBe(0);
33
+ });
34
+
35
+ test('reads a progress snapshot nested under `progress`', () => {
36
+ const live = new SubagentLiveness();
37
+ live.observe({ type: 'subagent_progress', payload: { progress: { id: 's9', status: 'running' } } }, NOW);
38
+ expect(live.liveCount(NOW, STALE_MS)).toBe(1);
39
+ });
40
+
41
+ test('malformed frames never fabricate liveness', () => {
42
+ const live = new SubagentLiveness();
43
+ live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'paused' } }, NOW);
44
+ live.observe({ type: 'subagent_lifecycle', payload: { status: 'started' } }, NOW);
45
+ live.observe({ type: 'subagent_progress', payload: 'nonsense' }, NOW);
46
+ live.observe({ type: 'subagent_event', payload: { event: { type: 'message_end' } } }, NOW);
47
+ expect(live.liveCount(NOW, STALE_MS)).toBe(0);
48
+ });
49
+
50
+ test('a subagent silent past the stale window stops counting, so one lost terminal frame cannot pin a session', () => {
51
+ const live = new SubagentLiveness();
52
+ live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 0 } }, NOW);
53
+ expect(live.liveCount(NOW + STALE_MS + 1, STALE_MS)).toBe(0);
54
+ });
55
+
56
+ test('an unattributed event refreshes tracked work without inventing an entry', () => {
57
+ const live = new SubagentLiveness();
58
+ live.observe({ type: 'subagent_lifecycle', payload: { id: 's1', status: 'started', index: 0 } }, NOW);
59
+ live.observe({ type: 'subagent_event', payload: { event: { type: 'tool_execution_start', toolName: 'bash' } } }, NOW + STALE_MS - 1);
60
+ expect(live.liveCount(NOW + STALE_MS + 1, STALE_MS)).toBe(1);
61
+ });
62
+ });