ompchamber 0.3.0 → 0.5.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 (256) hide show
  1. package/README.md +17 -10
  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/ct.b74928f7a3.js +1 -0
  12. package/dist/client/static/js/async/dc.6dc4b0a04b.js +2 -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.0ac93ee878.js +33 -0
  43. package/dist/client/static/js/index.0ac93ee878.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 -2
  47. package/src/cli/lib/args.js +1 -0
  48. package/src/cli/lib/commands/serve.js +7 -0
  49. package/src/client/App.tsx +22 -5
  50. package/src/client/components/common/MarkdownRenderer.tsx +36 -0
  51. package/src/client/components/layout/desktop-layout/index.tsx +1 -1
  52. package/src/client/components/layout/session-sidebar/Modals.tsx +1 -1
  53. package/src/client/components/mobile/LayoutWrapper.tsx +1 -1
  54. package/src/client/components/settings/LazyModal.tsx +46 -0
  55. package/src/client/components/settings/Modal.tsx +1 -1
  56. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +43 -29
  57. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +15 -69
  58. package/src/client/components/workspace/chat-timeline/MessageList.tsx +47 -66
  59. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +60 -0
  60. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +37 -21
  61. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +22 -4
  62. package/src/client/components/workspace/chat-timeline/index.tsx +3 -6
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +15 -19
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader.tsx +46 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput.tsx +18 -68
  66. package/src/client/components/workspace/file-explorer/TreeItem.tsx +16 -4
  67. package/src/client/components/workspace/file-explorer/index.tsx +6 -0
  68. package/src/client/data/models/catalog.ts +8 -0
  69. package/src/client/data/samples/dialogue-session.ts +1 -1
  70. package/src/client/data/samples/sample-tools/other.ts +1 -1
  71. package/src/client/hooks/chat/omp/index.ts +9 -10
  72. package/src/client/hooks/chat/omp/revalidation-throttle.ts +6 -5
  73. package/src/client/hooks/chat/timeline/actions.ts +1 -2
  74. package/src/client/hooks/chat/timeline/index.ts +5 -0
  75. package/src/client/hooks/chat/timeline/send.ts +2 -3
  76. package/src/client/hooks/chat/timeline/session-load.ts +3 -4
  77. package/src/client/hooks/ui/syntax-ready.ts +16 -3
  78. package/src/client/main.tsx +2 -5
  79. package/src/client/stubs/elkjs.ts +37 -0
  80. package/src/client/tailwind.css +10 -0
  81. package/src/server/index.ts +53 -0
  82. package/src/server/lib/fs/git-ignore.ts +49 -0
  83. package/src/server/lib/models/provider-registry.server.ts +44 -20
  84. package/src/server/lib/omp/core/cli.ts +3 -2
  85. package/src/server/lib/omp/core/startup.ts +64 -0
  86. package/src/server/lib/omp/rpc/constants.ts +10 -0
  87. package/src/server/lib/omp/rpc/lifecycle.test.ts +138 -0
  88. package/src/server/lib/omp/rpc/manager.ts +32 -4
  89. package/src/server/lib/omp/rpc/process.ts +1 -1
  90. package/src/server/lib/omp/rpc/session-commands.ts +32 -9
  91. package/src/server/lib/omp/rpc/session-registry.ts +3 -2
  92. package/src/server/lib/omp/rpc/subagent-liveness.test.ts +62 -0
  93. package/src/server/lib/omp/rpc/subagent-liveness.ts +100 -0
  94. package/src/server/lib/omp/session/messages.test.ts +112 -0
  95. package/src/server/lib/omp/session/messages.ts +6 -5
  96. package/src/server/mock.server.ts +6 -12
  97. package/src/server/routes/agent/command.ts +34 -16
  98. package/src/server/routes/chat/stream.ts +13 -30
  99. package/src/server/routes/fs/read.ts +15 -7
  100. package/src/server/routes/models/root.ts +5 -1
  101. package/src/shared/lib/chat/notice-row.test.ts +89 -0
  102. package/src/shared/lib/chat/notice-row.ts +78 -0
  103. package/src/shared/lib/chat/omp/abort.ts +41 -0
  104. package/src/shared/lib/chat/stream-service.ts +0 -76
  105. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +154 -0
  106. package/src/shared/lib/chat/timeline/omp-callbacks.ts +36 -7
  107. package/src/shared/lib/chat/timeline/run-footer.test.ts +84 -0
  108. package/src/shared/lib/chat/timeline/run-footer.ts +96 -0
  109. package/src/shared/lib/chat/tool-output.test.ts +91 -0
  110. package/src/shared/lib/chat/tool-output.ts +54 -0
  111. package/src/shared/lib/chat/xml-envelope.ts +158 -0
  112. package/src/shared/lib/code/highlighter-lazy.ts +106 -0
  113. package/src/shared/lib/code/highlighter.ts +31 -52
  114. package/src/shared/lib/code/syntax-highlight.test.ts +36 -1
  115. package/src/shared/lib/code/syntax-highlight.ts +17 -3
  116. package/src/shared/lib/format/time.ts +19 -0
  117. package/src/shared/lib/fs/paths.ts +19 -0
  118. package/src/shared/lib/markdown/fira-code-fonts.css +51 -0
  119. package/src/shared/lib/markdown/katex-fonts.css +20 -0
  120. package/src/shared/lib/markdown/katex.ts +101 -0
  121. package/src/shared/lib/markdown/marked.ts +23 -14
  122. package/src/shared/lib/markdown/math.test.ts +109 -0
  123. package/src/shared/lib/markdown/mermaid.ts +6 -0
  124. package/src/shared/lib/markdown/sanitize.ts +48 -2
  125. package/src/shared/lib/omp/session/mapper.ts +11 -3
  126. package/src/shared/lib/omp/session/messages-map.ts +12 -12
  127. package/src/shared/types/chat.ts +3 -1
  128. package/src/shared/types/fs.ts +3 -0
  129. package/dist/client/static/css/g.f36aa429fa.css +0 -1
  130. package/dist/client/static/css/index.a0f07455bd.css +0 -2
  131. package/dist/client/static/css/katex-vendor.ca3a0600e8.css +0 -1
  132. package/dist/client/static/font/KaTeX_AMS-Regular.1608a09b4a.woff +0 -0
  133. package/dist/client/static/font/KaTeX_AMS-Regular.4aafdb685c.ttf +0 -0
  134. package/dist/client/static/font/KaTeX_Caligraphic-Bold.b67709187b.woff +0 -0
  135. package/dist/client/static/font/KaTeX_Caligraphic-Bold.cce5b8ecea.ttf +0 -0
  136. package/dist/client/static/font/KaTeX_Caligraphic-Regular.07ef19e7b0.ttf +0 -0
  137. package/dist/client/static/font/KaTeX_Caligraphic-Regular.dad44a7fc6.woff +0 -0
  138. package/dist/client/static/font/KaTeX_Fraktur-Bold.9f256b8593.woff +0 -0
  139. package/dist/client/static/font/KaTeX_Fraktur-Bold.b18f59e1d1.ttf +0 -0
  140. package/dist/client/static/font/KaTeX_Fraktur-Regular.7c1871215e.woff +0 -0
  141. package/dist/client/static/font/KaTeX_Fraktur-Regular.ed38e79f57.ttf +0 -0
  142. package/dist/client/static/font/KaTeX_Main-Bold.b74a1a8b2c.ttf +0 -0
  143. package/dist/client/static/font/KaTeX_Main-Bold.d181c4650d.woff +0 -0
  144. package/dist/client/static/font/KaTeX_Main-BoldItalic.70d8b0a530.ttf +0 -0
  145. package/dist/client/static/font/KaTeX_Main-BoldItalic.e3f82f9d27.woff +0 -0
  146. package/dist/client/static/font/KaTeX_Main-Italic.47373d1e51.ttf +0 -0
  147. package/dist/client/static/font/KaTeX_Main-Italic.9024d815ba.woff +0 -0
  148. package/dist/client/static/font/KaTeX_Main-Regular.7f51fe0340.woff +0 -0
  149. package/dist/client/static/font/KaTeX_Main-Regular.b7f8fe9b5f.ttf +0 -0
  150. package/dist/client/static/font/KaTeX_Math-BoldItalic.a879cf8383.ttf +0 -0
  151. package/dist/client/static/font/KaTeX_Math-BoldItalic.f1035d8d5d.woff +0 -0
  152. package/dist/client/static/font/KaTeX_Math-Italic.5295ba483a.woff +0 -0
  153. package/dist/client/static/font/KaTeX_Math-Italic.939bc64440.ttf +0 -0
  154. package/dist/client/static/font/KaTeX_SansSerif-Bold.94e1e8dc5f.ttf +0 -0
  155. package/dist/client/static/font/KaTeX_SansSerif-Bold.bf59d231c3.woff +0 -0
  156. package/dist/client/static/font/KaTeX_SansSerif-Italic.7c9bc82b17.woff +0 -0
  157. package/dist/client/static/font/KaTeX_SansSerif-Italic.b4c20c84d8.ttf +0 -0
  158. package/dist/client/static/font/KaTeX_SansSerif-Regular.740484788f.woff +0 -0
  159. package/dist/client/static/font/KaTeX_SansSerif-Regular.d4d7ba4804.ttf +0 -0
  160. package/dist/client/static/font/KaTeX_Script-Regular.0750571032.woff +0 -0
  161. package/dist/client/static/font/KaTeX_Script-Regular.fe9cbbe1a0.ttf +0 -0
  162. package/dist/client/static/font/KaTeX_Size1-Regular.e1e279cbdd.woff +0 -0
  163. package/dist/client/static/font/KaTeX_Size1-Regular.fabc004aab.ttf +0 -0
  164. package/dist/client/static/font/KaTeX_Size2-Regular.577270225e.woff +0 -0
  165. package/dist/client/static/font/KaTeX_Size2-Regular.d6b476ecd3.ttf +0 -0
  166. package/dist/client/static/font/KaTeX_Size3-Regular.9acaf01c64.woff +0 -0
  167. package/dist/client/static/font/KaTeX_Size3-Regular.a144ef5840.ttf +0 -0
  168. package/dist/client/static/font/KaTeX_Size4-Regular.7a996c9da2.woff +0 -0
  169. package/dist/client/static/font/KaTeX_Size4-Regular.fbccdabe0a.ttf +0 -0
  170. package/dist/client/static/font/KaTeX_Typewriter-Regular.6258592bdc.woff +0 -0
  171. package/dist/client/static/font/KaTeX_Typewriter-Regular.d97aaf4a1e.ttf +0 -0
  172. package/dist/client/static/font/fira-code-cyrillic-400-normal.5971438a4a.woff2 +0 -0
  173. package/dist/client/static/font/fira-code-cyrillic-400-normal.9bb7bfea9c.woff +0 -0
  174. package/dist/client/static/font/fira-code-cyrillic-500-normal.5486265b18.woff2 +0 -0
  175. package/dist/client/static/font/fira-code-cyrillic-500-normal.89f92bfb31.woff +0 -0
  176. package/dist/client/static/font/fira-code-cyrillic-600-normal.2805b497e5.woff +0 -0
  177. package/dist/client/static/font/fira-code-cyrillic-600-normal.86e0df22a3.woff2 +0 -0
  178. package/dist/client/static/font/fira-code-cyrillic-700-normal.505a88f22a.woff +0 -0
  179. package/dist/client/static/font/fira-code-cyrillic-700-normal.76f5e8d2e2.woff2 +0 -0
  180. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.3c4b15e57d.woff2 +0 -0
  181. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.61323debaa.woff +0 -0
  182. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.0f34210f2a.woff2 +0 -0
  183. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.2497cfa1aa.woff +0 -0
  184. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.40fce94af9.woff2 +0 -0
  185. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.6c8774b829.woff +0 -0
  186. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.4b742eb865.woff +0 -0
  187. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.e75eb4fccc.woff2 +0 -0
  188. package/dist/client/static/font/fira-code-greek-400-normal.bb900468b6.woff2 +0 -0
  189. package/dist/client/static/font/fira-code-greek-400-normal.be76e699ba.woff +0 -0
  190. package/dist/client/static/font/fira-code-greek-500-normal.176c9fb001.woff2 +0 -0
  191. package/dist/client/static/font/fira-code-greek-500-normal.d2c596136b.woff +0 -0
  192. package/dist/client/static/font/fira-code-greek-600-normal.490c3deed7.woff +0 -0
  193. package/dist/client/static/font/fira-code-greek-600-normal.7df14376d9.woff2 +0 -0
  194. package/dist/client/static/font/fira-code-greek-700-normal.c82b6072fc.woff2 +0 -0
  195. package/dist/client/static/font/fira-code-greek-700-normal.d5f2b2917b.woff +0 -0
  196. package/dist/client/static/font/fira-code-greek-ext-400-normal.4849a79781.woff +0 -0
  197. package/dist/client/static/font/fira-code-greek-ext-400-normal.7f5bfc4538.woff2 +0 -0
  198. package/dist/client/static/font/fira-code-greek-ext-500-normal.aaccca4075.woff2 +0 -0
  199. package/dist/client/static/font/fira-code-greek-ext-500-normal.e383181256.woff +0 -0
  200. package/dist/client/static/font/fira-code-greek-ext-600-normal.78cb4bff8e.woff +0 -0
  201. package/dist/client/static/font/fira-code-greek-ext-600-normal.d6c93d54f9.woff2 +0 -0
  202. package/dist/client/static/font/fira-code-greek-ext-700-normal.57ec7c92d9.woff +0 -0
  203. package/dist/client/static/font/fira-code-greek-ext-700-normal.8fafdae5e8.woff2 +0 -0
  204. package/dist/client/static/font/fira-code-latin-400-normal.973295532b.woff +0 -0
  205. package/dist/client/static/font/fira-code-latin-500-normal.7ae2be8fb8.woff +0 -0
  206. package/dist/client/static/font/fira-code-latin-600-normal.1dbbe5bc27.woff +0 -0
  207. package/dist/client/static/font/fira-code-latin-700-normal.497a70148b.woff +0 -0
  208. package/dist/client/static/font/fira-code-latin-ext-400-normal.9dfae29aaf.woff +0 -0
  209. package/dist/client/static/font/fira-code-latin-ext-400-normal.e534ae46e2.woff2 +0 -0
  210. package/dist/client/static/font/fira-code-latin-ext-500-normal.24c7b781b0.woff +0 -0
  211. package/dist/client/static/font/fira-code-latin-ext-500-normal.aad44ab072.woff2 +0 -0
  212. package/dist/client/static/font/fira-code-latin-ext-600-normal.7bed0f2f7e.woff +0 -0
  213. package/dist/client/static/font/fira-code-latin-ext-600-normal.c509a7c9f1.woff2 +0 -0
  214. package/dist/client/static/font/fira-code-latin-ext-700-normal.6c66c8b0a9.woff2 +0 -0
  215. package/dist/client/static/font/fira-code-latin-ext-700-normal.88f64e2822.woff +0 -0
  216. package/dist/client/static/font/fira-code-symbols2-400-normal.97d3f4b17c.woff +0 -0
  217. package/dist/client/static/font/fira-code-symbols2-500-normal.b06f1a06e4.woff +0 -0
  218. package/dist/client/static/font/fira-code-symbols2-600-normal.304b653b93.woff +0 -0
  219. package/dist/client/static/font/fira-code-symbols2-700-normal.d8ce0ad586.woff +0 -0
  220. package/dist/client/static/js/async/3j.a2fcea99be.js +0 -2
  221. package/dist/client/static/js/async/51.1938cec320.js +0 -2
  222. package/dist/client/static/js/async/6.7929428505.js +0 -1
  223. package/dist/client/static/js/async/69.a18a10882f.js +0 -1
  224. package/dist/client/static/js/async/a.bce4542392.js +0 -1
  225. package/dist/client/static/js/async/ar.9dc8b1992a.js +0 -2
  226. package/dist/client/static/js/async/bi.cbfe8eaa8e.js +0 -1
  227. package/dist/client/static/js/async/ct.38d9da8a5a.js +0 -1
  228. package/dist/client/static/js/async/fw.fb1a08b92d.js +0 -6
  229. package/dist/client/static/js/async/l6.45f3b62e15.js +0 -1
  230. package/dist/client/static/js/async/q.0b6fb9bf8d.js +0 -21
  231. package/dist/client/static/js/async/rd.b77011ab7f.js +0 -2
  232. package/dist/client/static/js/async/rf.cfbde7f744.js +0 -2
  233. package/dist/client/static/js/async/s.c7d521a8ae.js +0 -2
  234. package/dist/client/static/js/async/sy.7345abcd67.js +0 -94
  235. package/dist/client/static/js/async/t.0dcb01dfde.js +0 -1
  236. package/dist/client/static/js/async/v.5c7a4b087b.js +0 -1
  237. package/dist/client/static/js/async/vu.311f1d202c.js +0 -34
  238. package/dist/client/static/js/async/yg.9bd4e09ecb.js +0 -149
  239. package/dist/client/static/js/async/yv.593a3f7d54.js +0 -1
  240. package/dist/client/static/js/g.124fba0826.js +0 -139
  241. package/dist/client/static/js/index.907fd4670e.js +0 -68
  242. package/dist/client/static/js/katex-vendor.95e48cf65a.js +0 -1
  243. package/src/shared/lib/chat/order.ts +0 -40
  244. /package/dist/client/static/js/async/{1k.544adbf77d.js.LICENSE.txt → 1k.5974df3b87.js.LICENSE.txt} +0 -0
  245. /package/dist/client/static/js/async/{3j.a2fcea99be.js.LICENSE.txt → 3j.357c80ff38.js.LICENSE.txt} +0 -0
  246. /package/dist/client/static/js/async/{3y.22075aa079.js.LICENSE.txt → 3y.ededeebde8.js.LICENSE.txt} +0 -0
  247. /package/dist/client/static/js/async/{51.1938cec320.js.LICENSE.txt → 51.95bcae98af.js.LICENSE.txt} +0 -0
  248. /package/dist/client/static/js/async/{ar.9dc8b1992a.js.LICENSE.txt → a.e05da304c8.js.LICENSE.txt} +0 -0
  249. /package/dist/client/static/js/async/{fw.fb1a08b92d.js.LICENSE.txt → dc.6dc4b0a04b.js.LICENSE.txt} +0 -0
  250. /package/dist/client/static/js/async/{m.3fc017a055.js.LICENSE.txt → fw.6a51998a8e.js.LICENSE.txt} +0 -0
  251. /package/dist/client/static/js/async/{q.0b6fb9bf8d.js.LICENSE.txt → m.4b29d34f93.js.LICENSE.txt} +0 -0
  252. /package/dist/client/static/js/async/{rf.cfbde7f744.js.LICENSE.txt → q.7fb4d2f374.js.LICENSE.txt} +0 -0
  253. /package/dist/client/static/js/async/{rd.b77011ab7f.js.LICENSE.txt → rd.cb6bf495bb.js.LICENSE.txt} +0 -0
  254. /package/dist/client/static/js/async/{s.c7d521a8ae.js.LICENSE.txt → rf.d626afc367.js.LICENSE.txt} +0 -0
  255. /package/dist/client/static/js/{index.907fd4670e.js.LICENSE.txt → async/ud.3065bfee31.js.LICENSE.txt} +0 -0
  256. /package/dist/client/static/js/{g.124fba0826.js.LICENSE.txt → uy.5f791144ce.js.LICENSE.txt} +0 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Startup gate: the chamber must not run without a resolvable `omp` binary.
8
+ *
9
+ * Every live capability — RPC agent sessions, config get/set, session state,
10
+ * model lists, updates — shells out to that binary (see ./cli.ts), so a missing
11
+ * install is not a degraded mode to browse into: the UI would open onto an
12
+ * empty shell whose every action fails. Both entry points call this before
13
+ * binding a port (src/server/index.ts) or spawning a detached server
14
+ * (src/cli/lib/commands/serve.js), turning the failure into one actionable line
15
+ * instead of a health check that never passes.
16
+ *
17
+ * `MOCK=true` is exempt by definition — demo mode exists to run on presets
18
+ * without a real omp install.
19
+ */
20
+
21
+ import { resolveOmpBin } from '@/server/lib/omp/core/cli';
22
+ import { getAgentDir, getConfigRoot } from '@/server/lib/omp/core/paths';
23
+ import { isMockMode } from '@/server/mock.server';
24
+
25
+ /** Null when the app may start; otherwise the message to print before exiting. */
26
+ export function ompStartupError(): string | null {
27
+ if (isMockMode()) return null;
28
+ if (resolveOmpBin()) return null;
29
+ return [
30
+ 'omp binary not found — OMPChamber cannot start.',
31
+ 'Agent sessions, omp config, session state and updates all run through the `omp` CLI,',
32
+ 'so the binary must be resolvable before the server starts.',
33
+ 'Install AI Oh-My-Pi and put `omp` on your PATH, or set OMPCHAMBER_OMP_BIN=/path/to/omp.',
34
+ 'To run on demo data without an omp install, start with MOCK=true.',
35
+ ].join('\n');
36
+ }
37
+
38
+ interface OmpStartupPaths {
39
+ /** Resolved `omp` executable, or null when unresolved (mock mode). */
40
+ bin: string | null;
41
+ /** `~/.omp` — PI_CONFIG_DIR rename applied. */
42
+ configDir: string;
43
+ /** `~/.omp/agent` — PI_CODING_AGENT_DIR override applied. */
44
+ agentDir: string;
45
+ }
46
+
47
+ /** Startup banner lines. The caller owns the prefix.
48
+ *
49
+ * These are the locations the running process actually uses: the executable it
50
+ * spawns and the agent tree it reads. Printing them makes a misdirected install
51
+ * (two omps on PATH, a stray PI_CODING_AGENT_DIR) visible in the log instead of
52
+ * inferred from empty session lists. */
53
+ export function ompStartupLogLines(): string[] {
54
+ const paths: OmpStartupPaths = {
55
+ bin: resolveOmpBin(),
56
+ configDir: getConfigRoot(),
57
+ agentDir: getAgentDir(),
58
+ };
59
+ return [
60
+ `omp binary: ${paths.bin ?? 'not found (MOCK mode — presets only)'}`,
61
+ `omp config dir: ${paths.configDir}`,
62
+ `omp agent dir: ${paths.agentDir}`,
63
+ ];
64
+ }
@@ -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 {
@@ -46,7 +46,7 @@ export class RpcProcess {
46
46
  const resolveBin = options.dependencies?.resolveOmpBin ?? resolveOmpBin;
47
47
  const bin = resolveBin();
48
48
  if (!bin) {
49
- throw new Error('omp binary not found. Install oh-my-pi or set OMP_WEB_OMP_BIN.');
49
+ throw new Error('omp binary not found. Install oh-my-pi or set OMPCHAMBER_OMP_BIN.');
50
50
  }
51
51
  this.cwd = options.cwd;
52
52
  if (options.onFrame) this.frameListeners.add(options.onFrame);
@@ -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
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Liveness of a session's subagents, folded from the frames the wrapper already
8
+ * receives (`set_subagent_subscription` in AgentSessionWrapper.initialize).
9
+ *
10
+ * Every other "this session is working" flag on the wrapper (promptRunning /
11
+ * streaming / compacting / bashRunning) is blind to subagents: a parent turn
12
+ * can end — or a detached subagent outlive it — while subagents keep working,
13
+ * and a frame-silent subagent otherwise looks exactly like an idle session to
14
+ * the idle reaper. Used only for lifecycle decisions (idle reclaim, spawn-mode
15
+ * reconcile), never for the UI's running set.
16
+ *
17
+ * Identity mirrors the UI fold (`SubagentList`): frames carry an id, a
18
+ * lifecycle may also carry `index`, and progress frames may be id-less and are
19
+ * then matched by index — hence the index→key alias, so one subagent is never
20
+ * tracked under two keys (a leftover key would pin the session alive until it
21
+ * goes stale).
22
+ */
23
+
24
+ import { parseSubagentLifecycle, parseSubagentProgress } from '@/shared/lib/omp/subagent/parse';
25
+ import { isRecord } from '@/shared/lib/util/guards';
26
+ import type { SubagentProgress } from '@/shared/types/omp/subagent';
27
+
28
+ /** Statuses that end a subagent. Mirrors omp's own registry, which deletes an
29
+ * entry the moment a non-`started` lifecycle arrives. */
30
+ const TERMINAL_STATUS: Record<string, true> = { completed: true, failed: true, aborted: true };
31
+
32
+ /** Progress frames arrive AgentProgress-shaped; some wrappers nest the snapshot. */
33
+ function readProgress(payload: unknown): SubagentProgress | undefined {
34
+ const direct = parseSubagentProgress(payload);
35
+ if (!isRecord(payload) || !isRecord(payload.progress)) return direct;
36
+ const nested = parseSubagentProgress(payload.progress);
37
+ return nested ? { ...nested, id: nested.id ?? direct?.id, index: nested.index ?? direct?.index } : direct;
38
+ }
39
+
40
+ export class SubagentLiveness {
41
+ private readonly lastSeen = new Map<string, number>();
42
+ /** index → key, so an id-less progress frame lands on the entry its lifecycle
43
+ * created instead of opening a second one. */
44
+ private readonly keyByIndex = new Map<number, string>();
45
+
46
+ /** Fold one `subagent_lifecycle|subagent_progress|subagent_event` frame. */
47
+ observe(frame: { type: string; payload?: unknown }, now: number): void {
48
+ if (frame.type === 'subagent_lifecycle') {
49
+ const entry = parseSubagentLifecycle(frame.payload);
50
+ if (!entry) return;
51
+ if (TERMINAL_STATUS[entry.status] === true) this.forget(entry.id);
52
+ else this.mark(entry.id, entry.index >= 0 ? entry.index : undefined, now);
53
+ return;
54
+ }
55
+
56
+ if (frame.type === 'subagent_progress') {
57
+ const progress = readProgress(frame.payload);
58
+ if (!progress) return;
59
+ const index = progress.index !== undefined && progress.index >= 0 ? progress.index : undefined;
60
+ const key = progress.id ?? (index !== undefined ? this.keyByIndex.get(index) ?? `idx:${index}` : undefined);
61
+ if (key === undefined) return;
62
+ if (progress.status !== undefined && TERMINAL_STATUS[progress.status] === true) this.forget(key);
63
+ else this.mark(key, index, now);
64
+ return;
65
+ }
66
+
67
+ if (frame.type === 'subagent_event') {
68
+ // An event proves *some* subagent is working but carries no identity of
69
+ // its own: refresh what is already tracked, and never invent an entry —
70
+ // an invented one could never be cleared by a terminal frame.
71
+ for (const key of this.lastSeen.keys()) this.lastSeen.set(key, now);
72
+ }
73
+ }
74
+
75
+ /** Live subagents as of `now`. Entries silent past `staleMs` are dropped
76
+ * first: a terminal frame lost to a protocol hiccup must not pin a session
77
+ * alive forever (the price of the window is at most one idle omp process). */
78
+ liveCount(now: number, staleMs: number): number {
79
+ for (const [key, lastSeen] of this.lastSeen) {
80
+ if (now - lastSeen > staleMs) this.forget(key);
81
+ }
82
+ return this.lastSeen.size;
83
+ }
84
+
85
+ private mark(key: string, index: number | undefined, now: number): void {
86
+ this.lastSeen.set(key, now);
87
+ if (index === undefined) return;
88
+ // A lifecycle naming the same index supersedes the synthetic key an earlier
89
+ // id-less progress frame created for it.
90
+ if (key !== `idx:${index}`) this.lastSeen.delete(`idx:${index}`);
91
+ this.keyByIndex.set(index, key);
92
+ }
93
+
94
+ private forget(key: string): void {
95
+ this.lastSeen.delete(key);
96
+ for (const [index, alias] of this.keyByIndex) {
97
+ if (alias === key) this.keyByIndex.delete(index);
98
+ }
99
+ }
100
+ }