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
@@ -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
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { afterAll, describe, expect, test } from 'bun:test';
7
+ import fs from 'fs';
8
+ import { join } from 'path';
9
+
10
+ import { loadSessionMessages } from '@/server/lib/omp/session/messages';
11
+ import { isNoticeRow } from '@/shared/lib/chat/notice-row';
12
+
13
+ const tempDirs: string[] = [];
14
+
15
+ /** Write a session JSONL whose lines are exactly the given records, in order. */
16
+ async function writeSession(lines: unknown[]): Promise<string> {
17
+ const dir = await fs.promises.mkdtemp('omp-messages-');
18
+ tempDirs.push(dir);
19
+ const file = join(dir, 'session.jsonl');
20
+ await Bun.write(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`);
21
+ return file;
22
+ }
23
+
24
+ afterAll(() => {
25
+ for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ const at = (second: number) => `2026-09-21T03:00:${String(second).padStart(2, '0')}.000Z`;
29
+
30
+ /** omp records harness notices as their own `custom_message` entry. */
31
+ const notice = (id: string, text: string, second: number) => ({
32
+ type: 'custom_message',
33
+ id,
34
+ customType: 'ultrathink-notice',
35
+ timestamp: at(second),
36
+ content: [{ type: 'text', text: `<system-notice>${text}</system-notice>` }],
37
+ });
38
+
39
+ const turn = (id: string, role: 'user' | 'assistant', text: string, second: number) => ({
40
+ type: 'message',
41
+ id,
42
+ timestamp: at(second),
43
+ message: { role, content: [{ type: 'text', text }] },
44
+ });
45
+
46
+ describe('loadSessionMessages ordering', () => {
47
+ test('returns entries in file order, leaving a notice before its following user turn', async () => {
48
+ const file = await writeSession([
49
+ notice('n1', 'Background job finished.', 0),
50
+ turn('u1', 'user', 'halo', 1),
51
+ turn('a1', 'assistant', 'hai', 2),
52
+ ]);
53
+
54
+ const messages = await loadSessionMessages(file);
55
+
56
+ expect(messages.map((m) => m.id)).toEqual(['n1', 'u1', 'a1']);
57
+ expect(messages[0].notice).toBe('Background job finished.');
58
+ expect(messages[1]).toMatchObject({ role: 'user', content: 'halo' });
59
+ expect(messages[2]).toMatchObject({ role: 'ai', content: 'hai' });
60
+ });
61
+
62
+ test('preserves a notice that sits between two turns', async () => {
63
+ const file = await writeSession([
64
+ turn('u1', 'user', 'satu', 0),
65
+ turn('a1', 'assistant', 'dua', 1),
66
+ notice('n1', 'Job done.', 2),
67
+ turn('u2', 'user', 'tiga', 3),
68
+ turn('a2', 'assistant', 'empat', 4),
69
+ ]);
70
+
71
+ expect((await loadSessionMessages(file)).map((m) => m.id)).toEqual(['u1', 'a1', 'n1', 'u2', 'a2']);
72
+ });
73
+ });
74
+
75
+ describe('loadSessionMessages reminder classification', () => {
76
+ const blocks = (id: string, texts: string[], second: number) => ({
77
+ type: 'message',
78
+ id,
79
+ timestamp: at(second),
80
+ message: { role: 'assistant', content: texts.map((text) => ({ type: 'text', text })), usage: { input: 1, output: 2 } },
81
+ });
82
+
83
+ test('prose that quotes the tag stays the answer', async () => {
84
+ const prose = 'Chip shows the peeled tag (`system-reminder`).\n\nExample: <system-reminder>…</system-reminder> then free text.';
85
+ const file = await writeSession([blocks('a1', [prose], 0)]);
86
+
87
+ const [message] = await loadSessionMessages(file);
88
+
89
+ expect(message.notice).toBeUndefined();
90
+ expect(message.content).toBe(prose);
91
+ expect(isNoticeRow(message)).toBe(false);
92
+ });
93
+
94
+ test('a text block that IS the reminder envelope becomes a notice', async () => {
95
+ const file = await writeSession([blocks('a1', ['<system-reminder>\n10 todo items still open.\n</system-reminder>'], 0)]);
96
+
97
+ const [message] = await loadSessionMessages(file);
98
+
99
+ expect(message.content).toBe('');
100
+ expect(message.notice).toBe('<system-reminder>\n10 todo items still open.\n</system-reminder>');
101
+ expect(isNoticeRow(message)).toBe(true);
102
+ });
103
+
104
+ test('an envelope and prose in one turn split between notice and content', async () => {
105
+ const file = await writeSession([blocks('a1', ['Jawaban akhir.', '<system-reminder>2 items open</system-reminder>'], 0)]);
106
+
107
+ const [message] = await loadSessionMessages(file);
108
+
109
+ expect(message.content).toBe('Jawaban akhir.');
110
+ expect(message.notice).toBe('<system-reminder>2 items open</system-reminder>');
111
+ });
112
+ });
@@ -21,7 +21,6 @@
21
21
  */
22
22
 
23
23
  import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
24
- import { normalizeNoticePositions } from '@/shared/lib/chat/order';
25
24
  import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
26
25
  import type { ChatMessageData } from '@/shared/types/chat';
27
26
  import { collectToolOutputs, noticeFromCustomMessage, toChatMessage, type SequenceState } from '@/shared/lib/omp/session/messages-map';
@@ -53,9 +52,11 @@ function skillUserMessageFromRecord(record: Record<string, unknown>): ChatMessag
53
52
  }
54
53
 
55
54
  /**
56
- * Load a session file and return the chat timeline in chronological order.
57
- * Assistant messages carry thinking accordion + tool calls (with outputs
58
- * paired from their toolResult entries); tool plumbing rows are folded in.
55
+ * Load a session file and return the chat timeline in **file order** — the
56
+ * order omp wrote the entries, which is the session's chronology. The API
57
+ * response is a faithful projection of the JSONL: nothing here reorders rows,
58
+ * and no display-side reordering is applied anywhere either (a notice row
59
+ * renders exactly where omp wrote it).
59
60
  */
60
61
  export async function loadSessionMessages(filePath: string): Promise<ChatMessageData[]> {
61
62
  try {
@@ -120,7 +121,7 @@ export async function loadSessionMessages(filePath: string): Promise<ChatMessage
120
121
  }
121
122
  state.messages.push(mapped);
122
123
  }
123
- return normalizeNoticePositions(state.messages);
124
+ return state.messages;
124
125
  }
125
126
 
126
127
  /** Derive the display title from the JSONL header/title slot (cheap read). */
@@ -1,7 +1,7 @@
1
1
  import { json } from '@/server/lib/remix-compat';
2
2
  import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
3
3
  import { resolveSessionPathOr404 } from '@/server/lib/omp/session/locator';
4
- import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession } from '@/server/lib/omp/rpc/manager';
4
+ import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession, type AgentSessionWrapper } from '@/server/lib/omp/rpc/manager';
5
5
  import { getSpawnApprovalMode, reconcileSpawnApprovalMode } from '@/server/lib/omp/rpc/session-registry';
6
6
  import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
7
7
  import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
@@ -53,29 +53,47 @@ export async function sendCommand({ params, request }: ActionFunctionArgs) {
53
53
  }
54
54
  }
55
55
 
56
+ /** RPC-free view of a busy session. `state` carries only the two flags the
57
+ * attach probe reads; the full snapshot resumes once the turn settles. */
58
+ function busySessionPayload(session: AgentSessionWrapper) {
59
+ return {
60
+ running: true,
61
+ busy: true,
62
+ state: { isStreaming: session.streaming, isPromptRunning: session.promptRunning },
63
+ pendingUiRequests: session.getPendingUiDialogs(),
64
+ };
65
+ }
66
+
56
67
  // GET /api/agent/:sessionId — current agent state (running set + live state).
57
68
  export async function getAgentState({ params }: LoaderFunctionArgs) {
58
69
  const { sessionId } = params;
59
70
  if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
60
71
 
72
+ const session = getRpcSession(sessionId);
73
+ if (!session || !session.isAlive()) {
74
+ return json({ running: false });
75
+ }
76
+
77
+ // A busy session answers from local flags: its `get_state` would queue behind
78
+ // the running turn (omp runs RPC handlers one at a time), and a timeout there
79
+ // is no reason to reset a session that is demonstrably working — subagents
80
+ // included. These flags are all the client needs to reattach its stream.
81
+ if (session.isBusy()) return json(busySessionPayload(session));
82
+
61
83
  try {
62
- const session = getRpcSession(sessionId);
63
- if (!session || !session.isAlive()) {
64
- return json({ running: false });
84
+ const state = await session.send({ type: 'get_state' });
85
+ // Dialogs omp is still blocked on: a client that reloaded mid-ask has no
86
+ // other way to learn the request id it must answer, and omp never
87
+ // re-emits the frame.
88
+ return json({ running: true, state, pendingUiRequests: session.getPendingUiDialogs() });
89
+ } catch (error) {
90
+ if (error instanceof WebRpcError && error.code === 'session_unresponsive') {
91
+ return json({ running: false, recovered: true });
65
92
  }
66
- try {
67
- const state = await session.send({ type: 'get_state' });
68
- // Dialogs omp is still blocked on: a client that reloaded mid-ask has no
69
- // other way to learn the request id it must answer, and omp never
70
- // re-emits the frame.
71
- return json({ running: true, state, pendingUiRequests: session.getPendingUiDialogs() });
72
- } catch (error) {
73
- if (error instanceof WebRpcError && error.code === 'session_unresponsive') {
74
- return json({ running: false, recovered: true });
75
- }
76
- throw error;
93
+ // A turn started between the check above and the RPC: report busy, not dead.
94
+ if (error instanceof WebRpcError && error.code === 'session_busy') {
95
+ return json(busySessionPayload(session));
77
96
  }
78
- } catch (error) {
79
97
  return rpcErrorResponse(error);
80
98
  }
81
99
  }
@@ -11,6 +11,7 @@ import fs from 'fs';
11
11
  import path from 'path';
12
12
  import { isMockMode } from '@/server/mock.server';
13
13
  import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
14
+ import { collectIgnoredPaths } from '@/server/lib/fs/git-ignore';
14
15
 
15
16
  /**
16
17
  * GET /api/fs/browse?path=<abs> — list subdirectories of a folder for the
@@ -92,19 +93,26 @@ const NOISE_DIRS: Record<string, true> = { node_modules: true, '.git': true, dis
92
93
  // fetch them on demand — directories are NOT recursed here (keeps the initial
93
94
  // payload tiny for deep workspaces). Hidden (dot-prefixed) entries are part of
94
95
  // the listing: lazy children keep `.github`, `.config`, `.env`, … cheap.
96
+ // Entries git would refuse to track carry `ignored: true` so the panel can dim
97
+ // them; that is one `git check-ignore` per listing, never one per entry.
95
98
  async function listEntries(dirPath: string, rootPath: string): Promise<any[]> {
96
- const entries = (await fs.promises.readdir(dirPath))
97
- .filter(child => !NOISE_DIRS[child]);
99
+ const listed = (await fs.promises.readdir(dirPath))
100
+ .filter(child => !NOISE_DIRS[child])
101
+ .map(child => {
102
+ const full = path.join(dirPath, child);
103
+ return { child, full, rel: path.relative(rootPath, full) };
104
+ });
98
105
 
99
- const mapped = await Promise.all(entries.map(async child => {
100
- const full = path.join(dirPath, child);
106
+ const ignored = await collectIgnoredPaths(rootPath, listed.map(entry => entry.rel));
107
+
108
+ const mapped = await Promise.all(listed.map(async ({ child, full, rel }) => {
101
109
  try {
102
110
  const st = await Bun.file(full).stat();
103
- const rel = path.relative(rootPath, full);
111
+ const isIgnored = ignored.has(rel);
104
112
  if (st.isDirectory()) {
105
- return { id: rel, name: child, type: 'folder', path: rel, children: null, is_expanded: 0 };
113
+ return { id: rel, name: child, type: 'folder', path: rel, children: null, is_expanded: 0, ignored: isIgnored };
106
114
  }
107
- return { id: rel, name: child, type: 'file', path: rel };
115
+ return { id: rel, name: child, type: 'file', path: rel, ignored: isIgnored };
108
116
  } catch {
109
117
  return null; // unreadable entry / broken symlink — skip
110
118
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ isNoticeRow,
10
+ messageAnswerText,
11
+ noticeIsAssistantText,
12
+ reminderPartIndex,
13
+ } from '@/shared/lib/chat/notice-row';
14
+ import type { ChatMessageData } from '@/shared/types';
15
+
16
+ const row = (over: Partial<ChatMessageData>): ChatMessageData => ({
17
+ id: 'n1',
18
+ role: 'ai',
19
+ content: '',
20
+ ...over,
21
+ });
22
+
23
+ const answer = { model: 'deepseek-v4', usage: { totalTokens: 150_640 }, durationMs: 5_574 };
24
+
25
+ describe('reminderPartIndex', () => {
26
+ test('finds a text block that is a reminder envelope', () => {
27
+ expect(reminderPartIndex(['<system-reminder>\n10 todos open\n</system-reminder>'])).toBe(0);
28
+ });
29
+
30
+ test('ignores a tag quoted inside prose', () => {
31
+ expect(reminderPartIndex(['Chip shows the peeled tag (`system-reminder`).'])).toBeUndefined();
32
+ expect(reminderPartIndex(['Example: <system-reminder>…</system-reminder> then free text'])).toBeUndefined();
33
+ expect(
34
+ reminderPartIndex(['A wrapper is `<a>1</a>`; see <system-reminder> handling below']),
35
+ ).toBeUndefined();
36
+ });
37
+
38
+ test('ignores an unclosed or mismatched tag pair', () => {
39
+ expect(reminderPartIndex(['<system-reminder>\nno closing tag'])).toBeUndefined();
40
+ expect(reminderPartIndex(['<system-reminder><b>10 todos</system-reminder>'])).toBeUndefined();
41
+ });
42
+
43
+ test('picks the envelope out of a multi-block turn', () => {
44
+ expect(reminderPartIndex(['prose first', '<system-reminder>note</system-reminder>'])).toBe(1);
45
+ });
46
+ });
47
+
48
+ describe('notice row classification', () => {
49
+ test('a bare notice row is a notice card', () => {
50
+ const notice = row({ notice: 'Late LSP diagnostics arrived after the edit returned.' });
51
+ expect(noticeIsAssistantText(notice)).toBe(false);
52
+ expect(isNoticeRow(notice)).toBe(true);
53
+ expect(messageAnswerText(notice)).toBe('');
54
+ });
55
+
56
+ test('a notice row that kept the turn metadata is the answer', () => {
57
+ const text = 'Implementasi selesai.\n\n## Aturan';
58
+ const diverted = row({ notice: text, ...answer });
59
+ expect(noticeIsAssistantText(diverted)).toBe(true);
60
+ expect(isNoticeRow(diverted)).toBe(false);
61
+ expect(messageAnswerText(diverted)).toBe(text);
62
+ });
63
+
64
+ test('a wrapped reminder stays a notice card even with turn metadata', () => {
65
+ const reminder = row({ notice: '<system-reminder>10 todo items still open.</system-reminder>', ...answer });
66
+ expect(noticeIsAssistantText(reminder)).toBe(false);
67
+ expect(isNoticeRow(reminder)).toBe(true);
68
+ });
69
+
70
+ test('a task result stays a notice card', () => {
71
+ const task = row({ notice: '<task-result id="Job" status="completed">done</task-result>', ...answer });
72
+ expect(isNoticeRow(task)).toBe(true);
73
+ });
74
+
75
+ test('the live thinking-level stamp alone does not make a notice an answer', () => {
76
+ expect(isNoticeRow(row({ notice: 'Job done.', thinkingLevel: 'high' }))).toBe(true);
77
+ });
78
+
79
+ test('an ordinary answer row is unaffected', () => {
80
+ const plain = row({ content: 'Halo', ...answer });
81
+ expect(isNoticeRow(plain)).toBe(false);
82
+ expect(messageAnswerText(plain)).toBe('Halo');
83
+ });
84
+
85
+ test('an empty notice is not a notice row', () => {
86
+ expect(isNoticeRow(row({ notice: ' ' }))).toBe(false);
87
+ expect(isNoticeRow(row({}))).toBe(false);
88
+ });
89
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `notice` rows — and the one case where the field carries the ANSWER.
8
+ *
9
+ * A real notice row holds nothing but the notice: omp writes harness reminders,
10
+ * task results and late diagnostics as their own entry, so those rows have no
11
+ * model, no usage and no thinking. When the assistant's answer itself ends up
12
+ * in `notice` (omp diverted the turn's text block), the row keeps the turn's
13
+ * own metadata — model/provider/usage/duration/thinking/toolCalls — and is
14
+ * therefore an answer that must render as text, never as a System Notice card.
15
+ *
16
+ * The decision lives here once so every consumer agrees: the timeline row
17
+ * (`MessageItem`), the boundary rules of the run footer, and the live-stream
18
+ * callbacks.
19
+ */
20
+
21
+ import type { ChatMessageData } from '@/shared/types';
22
+ import { isReminderTag, unwrapXmlEnvelope } from '@/shared/lib/chat/xml-envelope';
23
+
24
+ /** Tag that identifies a notice payload rather than prose. A notice keeps its
25
+ * wrapper — that tag is the field's identity, and `SystemNotice` reads it to
26
+ * pick "System Reminder" / "Task Result" over the generic card. */
27
+ const NOTICE_TAG_RE = /<\/?(?:system-reminder|reminder|system-notice|task-result)\b/i;
28
+
29
+ /** Fields only a real assistant turn carries. `thinkingLevel` is deliberately
30
+ * absent: the live stream stamps it on every non-user frame, notice rows
31
+ * included, so it proves nothing about the row being an answer. */
32
+ const TURN_FIELDS = [
33
+ 'model',
34
+ 'provider',
35
+ 'usage',
36
+ 'durationMs',
37
+ 'startedAt',
38
+ 'completedAt',
39
+ 'thinking',
40
+ 'toolCalls',
41
+ ] as const;
42
+
43
+ /**
44
+ * Index of the text part that IS a reminder envelope — `undefined` when the
45
+ * turn's prose merely quotes the tag (`see <system-reminder> handling` is
46
+ * content, not a wrapper). The envelope must open the part and enclose a whole
47
+ * element, so both statuses of a reminder turn are distinguishable before
48
+ * anything is moved into `notice`.
49
+ */
50
+ export function reminderPartIndex(parts: string[]): number | undefined {
51
+ const index = parts.findIndex((part) => {
52
+ const envelope = unwrapXmlEnvelope(part);
53
+ return envelope !== undefined && isReminderTag(envelope.tag);
54
+ });
55
+ return index === -1 ? undefined : index;
56
+ }
57
+
58
+ /** True when the row's `notice` is the assistant answer omp diverted there. */
59
+ export function noticeIsAssistantText(
60
+ msg: ChatMessageData | null | undefined,
61
+ ): msg is ChatMessageData & { notice: string } {
62
+ if (typeof msg?.notice !== 'string' || !msg.notice.trim()) return false;
63
+ if (NOTICE_TAG_RE.test(msg.notice)) return false;
64
+ return TURN_FIELDS.some((field) => msg[field] !== undefined && msg[field] !== null);
65
+ }
66
+
67
+ /** True when the row renders as a notice card (a real notice, not an answer). */
68
+ export function isNoticeRow(
69
+ msg: ChatMessageData | null | undefined,
70
+ ): msg is ChatMessageData & { notice: string } {
71
+ return typeof msg?.notice === 'string' && Boolean(msg.notice.trim()) && !noticeIsAssistantText(msg);
72
+ }
73
+
74
+ /** The row's answer text: `content`, or the notice when the answer landed there. */
75
+ export function messageAnswerText(msg: ChatMessageData | null | undefined): string {
76
+ if (noticeIsAssistantText(msg)) return msg.notice;
77
+ return typeof msg?.content === 'string' ? msg.content : '';
78
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Stop-button escalation for a live session.
8
+ *
9
+ * `abort` is awaited server-side until the turn actually stops, so a session
10
+ * wedged on a subagent — or on a child that stopped answering — never replies
11
+ * to it and the Stop button would hang with it. If the abort request is still
12
+ * pending after the grace period it is cancelled here and the stop escalates to
13
+ * `force_reset`, which the server answers by destroying the session's omp child
14
+ * (and every subagent under it) instead of waiting for a clean stop.
15
+ */
16
+
17
+ /** How long a stop may take before the process is reset instead. */
18
+ const ABORT_GRACE_MS = 10_000;
19
+
20
+ function postCommand(sessionId: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<Response> {
21
+ return fetch(`/api/agent/${encodeURIComponent(sessionId)}`, {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json' },
24
+ body: JSON.stringify(body),
25
+ ...(signal ? { signal } : {}),
26
+ });
27
+ }
28
+
29
+ /** Ask the session to stop, then reset its process if it does not stop in time.
30
+ * Failures are swallowed: the event stream is the source of truth for state. */
31
+ export async function stopAgentSession(sessionId: string): Promise<void> {
32
+ const grace = new AbortController();
33
+ const timer = setTimeout(() => grace.abort(), ABORT_GRACE_MS);
34
+ try {
35
+ const stopped = await postCommand(sessionId, { type: 'abort' }, grace.signal).then(() => true, () => false);
36
+ if (stopped) return;
37
+ await postCommand(sessionId, { type: 'force_reset' }).catch(() => null);
38
+ } finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }