ompchamber 1.0.0 → 1.2.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 (233) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -1,34 +1,152 @@
1
- import type { ComposerPickItem, ComposerTrigger } from '@/shared/types';
1
+ import type { CommandItem, ComposerMatchItem, ComposerPickItem, ComposerTrigger } from '@/shared/types';
2
2
 
3
3
  /** Chars that may legally precede an `@` agent trigger (besides string start). */
4
4
  const AT_PRECEDING_RE = /[\s([{]/;
5
5
 
6
+ /** oh-my-pi's `SKILL_NAMESPACE`: skills are addressed as `/skill:<name>`. */
7
+ export const SKILL_NAMESPACE = 'skill:';
8
+
9
+ /** The collapsed namespace row's token (no trailing space on accept). */
10
+ export const SKILL_NAMESPACE_TOKEN = `/${SKILL_NAMESPACE}`;
11
+
12
+ /**
13
+ * Slash commands the chamber answers itself, reserved exactly like oh-my-pi
14
+ * reserves its own tokens.
15
+ *
16
+ * omp's `/btw` is TUI-only — its registry entry has `handleTui` and no
17
+ * text-mode `handle`, and `get_available_commands` only advertises entries the
18
+ * RPC dispatcher can run — so the composer would offer every command except the
19
+ * one it intercepts on send. The entry is byte-for-byte omp's own metadata
20
+ * (description and inline hint), so the popup reads the same in both clients.
21
+ */
22
+ export const CHAMBER_COMMANDS: CommandItem[] = [
23
+ {
24
+ id: 'chamber-btw',
25
+ name: 'btw',
26
+ description: "Ask a side question, or browse this session's BTW history",
27
+ scope: 'system',
28
+ template: '',
29
+ isBuiltIn: true,
30
+ inputHint: '[question]',
31
+ },
32
+ ];
33
+
34
+ /** True when the chamber intercepts this command instead of forwarding it to
35
+ * omp as chat text. */
36
+ function isChamberOwnedCommand(name: string): boolean {
37
+ const lower = name.toLowerCase();
38
+ return CHAMBER_COMMANDS.some((command) => command.name.toLowerCase() === lower);
39
+ }
40
+
41
+ /**
42
+ * Index of a `/` that starts the draft, ignoring leading whitespace —
43
+ * oh-my-pi's `findLeadingSlashCommandStart`.
44
+ */
45
+ function leadingSlashStart(text: string): number | null {
46
+ const trimmed = text.trimStart();
47
+ if (!trimmed.startsWith('/')) return null;
48
+ return text.length - trimmed.length;
49
+ }
50
+
51
+ /**
52
+ * Index of a `/` that opens the trailing token (`/(?:^|\s)\/([^\s/]*)$`) —
53
+ * oh-my-pi's `findTrailingSlashCommandStart`.
54
+ */
55
+ function trailingSlashStart(text: string): number | null {
56
+ const match = /(?:^|\s)\/([^\s/]*)$/.exec(text);
57
+ if (!match || match.index === undefined) return null;
58
+ const slashOffset = match[0].indexOf('/');
59
+ return match.index + slashOffset;
60
+ }
61
+
62
+ /**
63
+ * Whether prose precedes the slash token: any earlier non-blank line, or
64
+ * non-whitespace before it on this line — oh-my-pi's
65
+ * `hasPromptTextBeforeSlash`. Such a token is a mid-prompt skill lookup,
66
+ * which only ever surfaces skills.
67
+ */
68
+ function hasPromptTextBeforeSlash(lines: readonly string[], line: number, before: string, slashStart: number): boolean {
69
+ for (let i = 0; i < line; i += 1) {
70
+ if ((lines[i] ?? '').trim() !== '') return true;
71
+ }
72
+ return before.slice(0, slashStart).trim() !== '';
73
+ }
74
+
6
75
  /**
7
76
  * Detect an `@`-mention (files + agents) or `/`-command trigger at the given
8
- * caret position.
77
+ * caret position, mirroring oh-my-pi's `CombinedAutocompleteProvider`:
78
+ *
79
+ * - `/` starts a command at the draft start (leading whitespace allowed) or as
80
+ * a trailing token after whitespace on the caret's line. A `/` in the middle
81
+ * of a token (`a/b`) never matches.
82
+ * - Once `<command> ` is typed, the popup switches to the `args` phase, which
83
+ * completes that command's declarative subcommands.
84
+ * - `@` is a trigger at index 0 or when preceded by whitespace, `(`, `[`, `{`.
9
85
  *
10
- * Scans backwards from `caret - 1`: a whitespace/newline before any trigger
11
- * char aborts the scan. `/` is only a trigger at index 0; `@` is a trigger at
12
- * index 0 or when preceded by whitespace, `(`, `[`, or `{`. The query is the
13
- * run of non-whitespace characters between the trigger char and the caret, so
14
- * `foo@bar` and `a/b` (query containing another trigger char) never match.
86
+ * `lines` is the whole draft split on newlines and `line` the caret's line
87
+ * index, because the mid-prompt rule inspects earlier lines.
15
88
  */
16
89
  export function detectComposerTrigger(text: string, caret: number): ComposerTrigger | null {
17
90
  const end = Math.max(0, Math.min(caret, text.length));
91
+ const before = text.slice(0, end);
92
+ // Mid-prompt detection needs the earlier lines, and every rule below is
93
+ // scoped to the caret's own line.
94
+ const lines = text.split('\n');
95
+ const line = (before.match(/\n/g) ?? []).length;
96
+ const lineStart = before.lastIndexOf('\n') + 1;
97
+ const beforeOnLine = before.slice(lineStart);
18
98
 
19
- for (let i = end - 1; i >= 0; i--) {
20
- const ch = text[i];
99
+ const leadStart = leadingSlashStart(beforeOnLine);
100
+ const trailStart = trailingSlashStart(beforeOnLine);
101
+ const midPrompt = trailStart !== null && hasPromptTextBeforeSlash(lines, line, beforeOnLine, trailStart);
102
+ const leadIsMidPrompt = leadStart !== null && hasPromptTextBeforeSlash(lines, line, beforeOnLine, leadStart);
21
103
 
22
- if (/\s/.test(ch)) return null;
104
+ const slashStart = midPrompt ? trailStart : leadIsMidPrompt ? null : leadStart;
23
105
 
24
- if (ch === '/' || ch === '@') {
25
- if (ch === '/') {
26
- if (i !== 0) return null;
27
- return { kind: 'command', query: text.slice(i + 1, end), start: i, end };
28
- }
106
+ if (slashStart !== null) {
107
+ const commandText = beforeOnLine.slice(slashStart);
108
+ const spaceIndex = commandText.indexOf(' ');
109
+
110
+ if (spaceIndex === -1) {
111
+ return {
112
+ kind: 'command',
113
+ query: commandText.slice(1),
114
+ start: lineStart + slashStart,
115
+ end,
116
+ phase: 'name',
117
+ replaceFrom: lineStart + slashStart,
118
+ ...(midPrompt ? { midPrompt: true } : {}),
119
+ };
120
+ }
121
+
122
+ if (!midPrompt) {
123
+ // Argument phase: everything after `<command> ` completes that command's
124
+ // subcommands. oh-my-pi anchors the replacement on the argument text
125
+ // only, so earlier arguments survive acceptance.
126
+ const argStart = lineStart + slashStart + spaceIndex + 1;
127
+ return {
128
+ kind: 'command',
129
+ query: before.slice(argStart),
130
+ start: lineStart + slashStart,
131
+ end,
132
+ phase: 'args',
133
+ replaceFrom: argStart,
134
+ command: commandText.slice(1, spaceIndex),
135
+ };
136
+ }
137
+ }
138
+
139
+ // `@` mentions scan back to the token start; whitespace aborts the scan so
140
+ // `foo@bar` never triggers.
141
+ for (let i = beforeOnLine.length - 1; i >= 0; i--) {
142
+ const ch = beforeOnLine[i];
29
143
 
30
- if (i === 0 || AT_PRECEDING_RE.test(text[i - 1])) {
31
- return { kind: 'mention', query: text.slice(i + 1, end), start: i, end };
144
+ if (/\s/.test(ch)) return null;
145
+
146
+ if (ch === '@') {
147
+ if (i === 0 || AT_PRECEDING_RE.test(beforeOnLine[i - 1])) {
148
+ const at = lineStart + i;
149
+ return { kind: 'mention', query: before.slice(at + 1), start: at, end, phase: 'name', replaceFrom: at };
32
150
  }
33
151
  return null;
34
152
  }
@@ -37,9 +155,24 @@ export function detectComposerTrigger(text: string, caret: number): ComposerTrig
37
155
  return null;
38
156
  }
39
157
 
40
- /** Insertion text for an item: its token followed by a single trailing space. */
158
+ /** Insertion text for an item: its token, plus a trailing space unless suppressed. */
41
159
  export function tokenForItem(item: ComposerPickItem): string {
42
- return `${item.token} `;
160
+ return item.insertWithoutSpace ? item.token : `${item.token} `;
161
+ }
162
+
163
+ /**
164
+ * Whether a popup accept should stand aside and let Enter mean "send".
165
+ *
166
+ * Only the chamber's own commands qualify: the composer intercepts those on
167
+ * send, so for `/btw` the Enter *is* the action and accept-on-Enter would
168
+ * swallow the very keystroke the user is aiming at. Every command omp owns
169
+ * keeps accepting, which is what opens its argument completions — including the
170
+ * ones that declare no subcommands and take free-form arguments.
171
+ */
172
+ export function sendInsteadOfAccept(trigger: ComposerTrigger, item: ComposerMatchItem | undefined): boolean {
173
+ if (!item || trigger.kind !== 'command' || trigger.phase !== 'name') return false;
174
+ if (item.name.toLowerCase() !== trigger.query.toLowerCase()) return false;
175
+ return isChamberOwnedCommand(item.name);
43
176
  }
44
177
 
45
178
  /** Splice `token` over the trigger span in `value`, returning the new value and caret. */
@@ -48,6 +181,6 @@ export function insertToken(
48
181
  trigger: ComposerTrigger,
49
182
  token: string,
50
183
  ): { value: string; caret: number } {
51
- const next = value.slice(0, trigger.start) + token + value.slice(trigger.end);
52
- return { value: next, caret: trigger.start + token.length };
184
+ const next = value.slice(0, trigger.replaceFrom) + token + value.slice(trigger.end);
185
+ return { value: next, caret: trigger.replaceFrom + token.length };
53
186
  }
@@ -20,40 +20,20 @@ import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
20
20
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
21
21
  import { describeAssistantPhase, describeToolActivity } from '@/shared/lib/chat/timeline/tool-verbs';
22
22
  import { FILE_MUTATION_EVENT, isFileMutatingTool } from '@/shared/lib/chat/omp/file-mutations';
23
+ import {
24
+ pairToolOutputs,
25
+ putToolResult,
26
+ recordToolResult,
27
+ refreshToolMessage,
28
+ type ToolResultHost,
29
+ type ToolResultRecord,
30
+ } from '@/shared/lib/chat/omp/tool-results';
23
31
 
24
- /** Tool output accumulated between a `toolCall` block and its result frame. */
25
- export interface ToolResultRecord {
26
- output: string;
27
- isError?: boolean;
28
- details?: Record<string, any>;
29
- }
30
-
31
- /** Per-entry cap on stored tool output; the tail of a build log is what matters. */
32
- const MAX_TOOL_OUTPUT_CHARS = 200_000;
33
- /** Max tool results retained per session; oldest entries are dropped first. */
34
- const MAX_TRACKED_TOOL_RESULTS = 200;
35
-
36
- /** Truncate tool output to the cap, keeping the tail and marking the cut. */
37
- function capToolOutput(output: string): string {
38
- if (output.length <= MAX_TOOL_OUTPUT_CHARS) return output;
39
- return `…[truncated]\n${output.slice(-MAX_TOOL_OUTPUT_CHARS)}`;
40
- }
32
+ // Re-exported so stream.ts and the timeline hook keep their existing import
33
+ // site; the implementation lives in tool-results.ts.
34
+ export type { ToolResultRecord } from '@/shared/lib/chat/omp/tool-results';
41
35
 
42
- /** Write a tool result into the map, enforcing both caps. */
43
- function storeToolResult(
44
- map: Map<string, ToolResultRecord>,
45
- callId: string,
46
- record: ToolResultRecord,
47
- ): void {
48
- map.set(callId, { ...record, output: capToolOutput(record.output) });
49
- while (map.size > MAX_TRACKED_TOOL_RESULTS) {
50
- const oldest = map.keys().next().value;
51
- if (oldest === undefined) break;
52
- map.delete(oldest);
53
- }
54
- }
55
-
56
- export interface OmpAgentFoldDeps {
36
+ export interface OmpAgentFoldDeps extends ToolResultHost {
57
37
  sessionId: string;
58
38
  setState: Dispatch<SetStateAction<OmpAgentState>>;
59
39
  callbacksRef: RefObject<OmpAgentCallbacks>;
@@ -78,45 +58,9 @@ function setActivity(verb: string | undefined, deps: OmpAgentFoldDeps): void {
78
58
  deps.callbacksRef.current?.onActivity?.(verb);
79
59
  }
80
60
 
81
- /** Re-emit the last tool-carrying assistant message with its results paired. */
82
- function pairToolOutputs(
83
- msg: ChatMessageData,
84
- toolResultsRef: RefObject<Map<string, ToolResultRecord>>,
85
- ): ChatMessageData {
86
- if (!msg.toolCalls?.length) return msg;
87
- const toolCalls = msg.toolCalls.map(tc => {
88
- const res = toolResultsRef.current?.get(tc.id);
89
- return res
90
- ? {
91
- ...tc,
92
- output: res.output,
93
- details: tc.details || res.details || undefined,
94
- status: (res.isError ? 'error' : 'success') as ToolCallData['status'],
95
- }
96
- : tc;
97
- });
98
- return { ...msg, toolCalls };
99
- }
100
-
101
- /** Record a toolResult frame's output against its tool call id. */
102
- function recordToolResult(
103
- raw: Record<string, unknown>,
104
- deps: OmpAgentFoldDeps,
105
- ): void {
106
- const callId = typeof raw.toolCallId === 'string' ? raw.toolCallId : undefined;
107
- if (!callId) return;
108
- deps.toolResultsRef.current?.set(callId, {
109
- output: extractTextFromContent(raw.content),
110
- details: (raw.details && typeof raw.details === 'object' ? raw.details : undefined) as ToolResultRecord['details'],
111
- });
112
- }
113
-
114
- /** Re-emit the last tool-carrying message when `callId` belongs to it. */
115
- function refreshToolMessage(callId: string | undefined, deps: OmpAgentFoldDeps): void {
116
- if (!callId) return;
117
- const last = deps.lastToolMessageRef.current;
118
- if (!last?.toolCalls?.some(tc => tc.id === callId)) return;
119
- deps.callbacksRef.current?.onMessageUpdate?.(pairToolOutputs(last, deps.toolResultsRef));
61
+ /** Narrow a deps record to the tool-output host, adding the re-emit sink. */
62
+ function toolHost(deps: OmpAgentFoldDeps): ToolResultHost {
63
+ return { ...deps, onMessageUpdate: deps.callbacksRef.current?.onMessageUpdate };
120
64
  }
121
65
 
122
66
  /** Flush assistant turns that stopped abnormally and were never streamed as
@@ -141,7 +85,7 @@ function materializeTerminalMessages(
141
85
  if (raw.stopReason !== 'aborted' && raw.stopReason !== 'error') continue;
142
86
  const converted = toChatMessage(raw, false);
143
87
  if (!converted) continue;
144
- const paired = pairToolOutputs(converted, deps.toolResultsRef);
88
+ const paired = pairToolOutputs(converted, deps);
145
89
  if (paired.toolCalls?.length) deps.lastToolMessageRef.current = paired;
146
90
  callbacks?.onMessageEnd?.(paired);
147
91
  if (typeof raw.errorMessage === 'string') errorMessage = raw.errorMessage;
@@ -205,7 +149,7 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
205
149
  const completed = data.message as Record<string, unknown> | undefined;
206
150
  if (!completed) break;
207
151
  if (completed.role === 'toolResult') {
208
- recordToolResult(completed, deps);
152
+ recordToolResult(completed, toolHost(deps));
209
153
  break;
210
154
  }
211
155
  if (completed.role === 'custom') break;
@@ -219,7 +163,7 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
219
163
  if (converted.role !== 'user' && deps.currentThinkingLevelRef.current) {
220
164
  converted.thinkingLevel = deps.currentThinkingLevelRef.current;
221
165
  }
222
- const paired = pairToolOutputs(converted, deps.toolResultsRef);
166
+ const paired = pairToolOutputs(converted, deps);
223
167
  if (paired.toolCalls?.length) deps.lastToolMessageRef.current = paired;
224
168
  callbacks?.onMessageEnd?.(paired);
225
169
  break;
@@ -254,9 +198,8 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
254
198
  const partial = toolResultText(data.partialResult);
255
199
  if (!callId || !partial) break;
256
200
  const prev = deps.toolResultsRef.current?.get(callId)?.output ?? '';
257
- const map = deps.toolResultsRef.current;
258
- if (map) storeToolResult(map, callId, { output: prev + partial });
259
- refreshToolMessage(callId, deps);
201
+ putToolResult(deps, callId, { output: prev + partial });
202
+ refreshToolMessage(callId, toolHost(deps));
260
203
  break;
261
204
  }
262
205
 
@@ -264,13 +207,12 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
264
207
  const callId = typeof data.toolCallId === 'string' ? data.toolCallId : undefined;
265
208
  if (!callId) break;
266
209
  setActivity(PHASE_VERBS.thinking, deps);
267
- const map = deps.toolResultsRef.current;
268
- if (map) storeToolResult(map, callId, {
210
+ putToolResult(deps, callId, {
269
211
  output: toolResultText(data.result),
270
212
  isError: data.isError === true,
271
213
  details: (data.details && typeof data.details === 'object' ? data.details : undefined) as ToolResultRecord['details'],
272
214
  });
273
- refreshToolMessage(callId, deps);
215
+ refreshToolMessage(callId, toolHost(deps));
274
216
  // A file-mutating tool just finished — tell the data-bearing right panels
275
217
  // (files / git / context) to re-read now, not on their next poll tick.
276
218
  // Guarded: the fold also runs headless under `bun test` (no window).
@@ -328,6 +270,17 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
328
270
  break;
329
271
  }
330
272
 
273
+ // omp renamed the session (auto-title generation, /rename, set_session_name).
274
+ // Nothing in the timeline changes, but the sidebar reads titles from the
275
+ // session file — and the rename is a 256-byte in-place slot write that the
276
+ // scan cache's mtime key cannot see — so this frame is the only push
277
+ // signal that the list is stale.
278
+ case 'session_info_update': {
279
+ const title = typeof data.title === 'string' ? data.title.trim() : '';
280
+ if (title) callbacks?.onSessionTitleChanged?.(title);
281
+ break;
282
+ }
283
+
331
284
  case 'notice': {
332
285
  callbacks?.onNotice?.(
333
286
  typeof data.level === 'string' ? data.level : 'info',
@@ -18,8 +18,7 @@
18
18
  * route's observer-only contract.
19
19
  */
20
20
 
21
- import type { OmpAgentEvent } from '@/shared/types';
22
- import type { AgentStreamConnection, AgentStreamHandlers } from '@/shared/lib/chat/omp/transport';
21
+ import type { AgentStreamConnection, AgentStreamHandlers, StreamConnection, StreamHandlers } from '@/shared/lib/chat/omp/transport';
23
22
  import { agentSocketUrl } from '@/shared/lib/chat/omp/transport';
24
23
 
25
24
  const RECONNECT_BASE_MS = 500;
@@ -27,7 +26,8 @@ const RECONNECT_MAX_MS = 8_000;
27
26
  /** Consecutive re-dials that never opened before the stream is given up on. */
28
27
  const MAX_FAILED_DIALS = 4;
29
28
 
30
- export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
29
+ /** One duplex JSON socket: reconnect with capped backoff, terminal first dial. */
30
+ export function connectSocket<TFrame>(url: string, handlers: StreamHandlers<TFrame>): StreamConnection {
31
31
  let socket: WebSocket | null = null;
32
32
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
33
33
  let failures = 0;
@@ -35,7 +35,7 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
35
35
  let released = false;
36
36
 
37
37
  const dial = () => {
38
- const ws = new WebSocket(agentSocketUrl(sessionId));
38
+ const ws = new WebSocket(url);
39
39
  socket = ws;
40
40
 
41
41
  ws.onopen = () => {
@@ -47,9 +47,9 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
47
47
 
48
48
  ws.onmessage = (event) => {
49
49
  if (released) return;
50
- let data: OmpAgentEvent;
50
+ let data: TFrame;
51
51
  try {
52
- data = JSON.parse(String(event.data)) as OmpAgentEvent;
52
+ data = JSON.parse(String(event.data)) as TFrame;
53
53
  } catch {
54
54
  return;
55
55
  }
@@ -93,3 +93,8 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
93
93
  },
94
94
  };
95
95
  }
96
+
97
+ /** WebSocket transport for a session's agent event stream. */
98
+ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
99
+ return connectSocket(agentSocketUrl(sessionId), handlers);
100
+ }
@@ -12,12 +12,12 @@
12
12
  * (the server closed the stream) is reported upward.
13
13
  */
14
14
 
15
- import type { OmpAgentEvent } from '@/shared/types';
16
- import type { AgentStreamConnection, AgentStreamHandlers } from '@/shared/lib/chat/omp/transport';
15
+ import type { AgentStreamConnection, AgentStreamHandlers, StreamConnection, StreamHandlers } from '@/shared/lib/chat/omp/transport';
17
16
  import { agentEventsUrl } from '@/shared/lib/chat/omp/transport';
18
17
 
19
- export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
20
- const source = new EventSource(agentEventsUrl(sessionId));
18
+ /** One EventSource stream: the browser owns reconnection on transient drops. */
19
+ export function connectEvents<TFrame>(url: string, handlers: StreamHandlers<TFrame>): StreamConnection {
20
+ const source = new EventSource(url);
21
21
  let released = false;
22
22
 
23
23
  source.onopen = () => {
@@ -27,9 +27,9 @@ export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandl
27
27
 
28
28
  source.onmessage = (event) => {
29
29
  if (released) return;
30
- let data: OmpAgentEvent;
30
+ let data: TFrame;
31
31
  try {
32
- data = JSON.parse(event.data) as OmpAgentEvent;
32
+ data = JSON.parse(event.data) as TFrame;
33
33
  } catch {
34
34
  return;
35
35
  }
@@ -51,3 +51,8 @@ export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandl
51
51
  },
52
52
  };
53
53
  }
54
+
55
+ /** SSE transport for a session's agent event stream. */
56
+ export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
57
+ return connectEvents(agentEventsUrl(sessionId), handlers);
58
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Tool-output bookkeeping for the live agent stream.
8
+ *
9
+ * omp reports a tool call in an assistant message and its output in separate
10
+ * frames (`tool_execution_update` partials, then `tool_execution_end`). The
11
+ * timeline needs the two halves re-joined on the message that carries the call,
12
+ * so this module owns the call-id → output map and the re-emit that pairs them.
13
+ *
14
+ * Split out of agent-events.ts so that file stays under the repo's per-file
15
+ * size ceiling. The host is a narrow structural view of the fold's deps — not
16
+ * the deps type itself — so neither module imports the other.
17
+ */
18
+
19
+ import type { RefObject } from 'preact/compat';
20
+ import type { ChatMessageData, ToolCallData } from '@/shared/types';
21
+ import { extractTextFromContent } from '@/shared/lib/omp/session/mapper';
22
+
23
+ /** Tool output accumulated between a `toolCall` block and its result frame. */
24
+ export interface ToolResultRecord {
25
+ output: string;
26
+ isError?: boolean;
27
+ details?: Record<string, any>;
28
+ }
29
+
30
+ /** Per-entry cap on stored tool output; the tail of a build log is what matters. */
31
+ const MAX_TOOL_OUTPUT_CHARS = 200_000;
32
+ /** Max tool results retained per session; oldest entries are dropped first. */
33
+ const MAX_TRACKED_TOOL_RESULTS = 200;
34
+
35
+ /** The slice of the fold's deps this module needs. */
36
+ export interface ToolResultHost {
37
+ toolResultsRef: RefObject<Map<string, ToolResultRecord>>;
38
+ lastToolMessageRef: RefObject<ChatMessageData>;
39
+ /** Re-emit an updated assistant message into the timeline. */
40
+ onMessageUpdate?: (msg: ChatMessageData) => void;
41
+ }
42
+
43
+ /** Truncate tool output to the cap, keeping the tail and marking the cut. */
44
+ function capToolOutput(output: string): string {
45
+ if (output.length <= MAX_TOOL_OUTPUT_CHARS) return output;
46
+ return `…[truncated]\n${output.slice(-MAX_TOOL_OUTPUT_CHARS)}`;
47
+ }
48
+
49
+ /** Write a tool result into the map, enforcing both caps. */
50
+ function storeToolResult(
51
+ map: Map<string, ToolResultRecord>,
52
+ callId: string,
53
+ record: ToolResultRecord,
54
+ ): void {
55
+ map.set(callId, { ...record, output: capToolOutput(record.output) });
56
+ while (map.size > MAX_TRACKED_TOOL_RESULTS) {
57
+ const oldest = map.keys().next().value;
58
+ if (oldest === undefined) break;
59
+ map.delete(oldest);
60
+ }
61
+ }
62
+
63
+ /** Write a result into the map, enforcing both caps. */
64
+ export function putToolResult(host: ToolResultHost, callId: string, record: ToolResultRecord): void {
65
+ const map = host.toolResultsRef.current;
66
+ if (map) storeToolResult(map, callId, record);
67
+ }
68
+
69
+ /** Record a toolResult frame's output against its tool call id. */
70
+ export function recordToolResult(raw: Record<string, unknown>, host: ToolResultHost): void {
71
+ const callId = typeof raw.toolCallId === 'string' ? raw.toolCallId : undefined;
72
+ if (!callId) return;
73
+ host.toolResultsRef.current?.set(callId, {
74
+ output: extractTextFromContent(raw.content),
75
+ details: (raw.details && typeof raw.details === 'object' ? raw.details : undefined) as ToolResultRecord['details'],
76
+ });
77
+ }
78
+
79
+ /** Re-emit the last tool-carrying assistant message with its results paired. */
80
+ export function pairToolOutputs(msg: ChatMessageData, host: ToolResultHost): ChatMessageData {
81
+ if (!msg.toolCalls?.length) return msg;
82
+ const toolCalls = msg.toolCalls.map(tc => {
83
+ const res = host.toolResultsRef.current?.get(tc.id);
84
+ return res
85
+ ? {
86
+ ...tc,
87
+ output: res.output,
88
+ details: tc.details || res.details || undefined,
89
+ status: (res.isError ? 'error' : 'success') as ToolCallData['status'],
90
+ }
91
+ : tc;
92
+ });
93
+ return { ...msg, toolCalls };
94
+ }
95
+
96
+ /** Re-emit the last tool-carrying message when `callId` belongs to it. */
97
+ export function refreshToolMessage(callId: string | undefined, host: ToolResultHost): void {
98
+ if (!callId) return;
99
+ const last = host.lastToolMessageRef.current;
100
+ if (!last?.toolCalls?.some(tc => tc.id === callId)) return;
101
+ host.onMessageUpdate?.(pairToolOutputs(last, host));
102
+ }
@@ -14,20 +14,25 @@
14
14
  import type { OmpAgentEvent, StreamTransport } from '@/shared/types';
15
15
  import { readChamberSetting } from '@/shared/lib/settings/client';
16
16
 
17
- export interface AgentStreamHandlers {
17
+ export interface StreamHandlers<TFrame> {
18
18
  /** Transport is attached; frames may arrive. */
19
19
  onOpen: () => void;
20
20
  /** One decoded event frame. */
21
- onFrame: (data: OmpAgentEvent) => void;
21
+ onFrame: (data: TFrame) => void;
22
22
  /** Transport is gone for good (no further frames without a re-dial). */
23
23
  onClose: () => void;
24
24
  }
25
25
 
26
- export interface AgentStreamConnection {
26
+ export interface StreamConnection {
27
27
  /** Permanently release the transport; no callbacks fire afterwards. */
28
28
  close: () => void;
29
29
  }
30
30
 
31
+ /** Agent-stream handlers — the socket's frames feed the timeline's fold. */
32
+ export type AgentStreamHandlers = StreamHandlers<OmpAgentEvent>;
33
+
34
+ export type AgentStreamConnection = StreamConnection;
35
+
31
36
  export const DEFAULT_STREAM_TRANSPORT: StreamTransport = 'websocket';
32
37
 
33
38
  /** WebSocket endpoint for a session's agent event stream. */
@@ -41,6 +46,17 @@ export function agentEventsUrl(sessionId: string): string {
41
46
  return `/api/agent/${encodeURIComponent(sessionId)}/events`;
42
47
  }
43
48
 
49
+ /** WebSocket endpoint for a session's BTW frame stream. */
50
+ export function btwSocketUrl(sessionId: string): string {
51
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
52
+ return `${protocol}//${window.location.host}/api/btw/${encodeURIComponent(sessionId)}/ws`;
53
+ }
54
+
55
+ /** SSE endpoint for a session's BTW frame stream. */
56
+ export function btwEventsUrl(sessionId: string): string {
57
+ return `/api/btw/${encodeURIComponent(sessionId)}/events`;
58
+ }
59
+
44
60
  /**
45
61
  * Resolve the configured stream transport from the chamber settings snapshot.
46
62
  */