ompchamber 1.2.1 → 2.0.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 (223) hide show
  1. package/README.md +131 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.74ff36419f.js +2 -0
  14. package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
  15. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  16. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  17. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  18. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  19. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  20. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  21. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  22. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  23. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  24. package/dist/client/static/js/async/q.33d6779f98.js +21 -0
  25. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  26. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  27. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  28. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  29. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  31. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  32. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  33. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  34. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  35. package/dist/client/static/js/index.c05535dbde.js +38 -0
  36. package/package.json +3 -3
  37. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  38. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  39. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  40. package/src/client/components/settings/Modal.tsx +11 -2
  41. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  42. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  43. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  45. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  46. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  47. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  48. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  49. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  50. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  51. package/src/client/components/workspace/btw-panel/index.tsx +124 -45
  52. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  53. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  54. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  59. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  63. package/src/client/components/workspace/editor/index.tsx +11 -2
  64. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  65. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  66. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  67. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  68. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  69. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  70. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  71. package/src/client/data/theme/terminal.ts +50 -52
  72. package/src/client/hooks/chat/btw/index.ts +139 -18
  73. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  74. package/src/client/hooks/chat/btw/mode.ts +111 -16
  75. package/src/client/hooks/chat/composer/index.ts +10 -4
  76. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  77. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  78. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  79. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  80. package/src/client/hooks/chat/timeline/index.ts +8 -20
  81. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  82. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  83. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  84. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  85. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  86. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  87. package/src/client/hooks/settings/providers.ts +34 -3
  88. package/src/client/hooks/ui/open-file.ts +7 -1
  89. package/src/client/hooks/ui/theme.ts +82 -57
  90. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  91. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  92. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  93. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  94. package/src/client/hooks/workspace/search-stream.ts +24 -5
  95. package/src/client/main.tsx +6 -0
  96. package/src/client/tailwind.css +19 -56
  97. package/src/server/lib/btw/child.server.ts +41 -0
  98. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  99. package/src/server/lib/btw/prompt.ts +13 -5
  100. package/src/server/lib/btw/registry.server.ts +13 -4
  101. package/src/server/lib/btw/runtime.server.ts +149 -156
  102. package/src/server/lib/btw/schema.server.ts +27 -0
  103. package/src/server/lib/btw/service.server.ts +106 -5
  104. package/src/server/lib/btw/store.server.ts +57 -6
  105. package/src/server/lib/db/schema.ts +4 -0
  106. package/src/server/lib/db/settings-store.test.ts +65 -0
  107. package/src/server/lib/db/settings-store.ts +25 -0
  108. package/src/server/lib/fs/atomic-write.ts +42 -4
  109. package/src/server/lib/fs/config-lock.ts +74 -0
  110. package/src/server/lib/fs/git-ignore.ts +55 -5
  111. package/src/server/lib/fs/ripgrep.ts +119 -0
  112. package/src/server/lib/lifecycle/identity.ts +10 -64
  113. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  114. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  115. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  116. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  117. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  118. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  119. package/src/server/lib/models/provider-registry.server.ts +2 -2
  120. package/src/server/lib/models/registry.server.ts +1 -1
  121. package/src/server/lib/omp/config/behavior.ts +17 -17
  122. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  123. package/src/server/lib/omp/config/document.ts +128 -0
  124. package/src/server/lib/omp/config/extensions.ts +11 -10
  125. package/src/server/lib/omp/config/mcp.ts +4 -57
  126. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  127. package/src/server/lib/omp/config/models-config.ts +92 -0
  128. package/src/server/lib/omp/config/models-validation.ts +159 -0
  129. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  130. package/src/server/lib/omp/config/providers.ts +241 -211
  131. package/src/server/lib/omp/config/roles.ts +6 -14
  132. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  133. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  134. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  135. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  136. package/src/server/lib/omp/rpc/manager.ts +21 -21
  137. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  138. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  139. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  140. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  141. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  142. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  143. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  144. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  145. package/src/server/lib/queue/delivery-harness.ts +88 -0
  146. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  147. package/src/server/lib/queue/delivery.server.ts +105 -13
  148. package/src/server/lib/queue/store.server.ts +30 -5
  149. package/src/server/lib/terminal/foreground.test.ts +164 -0
  150. package/src/server/lib/terminal/foreground.ts +34 -6
  151. package/src/server/lib/terminal/history.ts +8 -7
  152. package/src/server/plugins/compress.ts +33 -5
  153. package/src/server/plugins/ssr.ts +14 -7
  154. package/src/server/routes/btw/topics.ts +89 -2
  155. package/src/server/routes/fs/git.ts +5 -6
  156. package/src/server/routes/fs/search.ts +17 -26
  157. package/src/server/routes/sessions/queue.ts +17 -4
  158. package/src/server/routes/settings/index.ts +2 -0
  159. package/src/server/routes/settings/model-override.ts +69 -0
  160. package/src/server/routes/settings/provider-models.ts +35 -12
  161. package/src/server/routes/settings/providers.ts +31 -1
  162. package/src/server/routes/settings/root.ts +8 -3
  163. package/src/shared/lib/chat/btw/client.ts +34 -0
  164. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  165. package/src/shared/lib/code/shiki-themes.ts +9 -5
  166. package/src/shared/lib/fs/file-tree.ts +17 -12
  167. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  168. package/src/shared/lib/markdown/autolink.ts +50 -0
  169. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  170. package/src/shared/lib/markdown/document-urls.ts +118 -0
  171. package/src/shared/lib/markdown/marked.ts +27 -15
  172. package/src/shared/lib/markdown/mermaid.ts +2 -2
  173. package/src/shared/lib/models/catalog.ts +1 -1
  174. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  175. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  176. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  177. package/src/shared/lib/settings/client.ts +10 -4
  178. package/src/shared/lib/settings/diff.test.ts +31 -0
  179. package/src/shared/lib/settings/diff.ts +37 -0
  180. package/src/shared/lib/theme/catalog.test.ts +168 -0
  181. package/src/shared/lib/theme/catalog.ts +98 -0
  182. package/src/shared/lib/theme/color.ts +88 -0
  183. package/src/shared/lib/theme/css.ts +70 -0
  184. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  185. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  186. package/src/shared/lib/theme/palettes/light.ts +313 -0
  187. package/src/shared/types/btw.ts +83 -4
  188. package/src/shared/types/index.ts +1 -0
  189. package/src/shared/types/settings/provider.ts +8 -1
  190. package/src/shared/types/settings/state.ts +10 -4
  191. package/src/shared/types/theme.ts +48 -0
  192. package/dist/client/static/css/index.b938f4a693.css +0 -2
  193. package/dist/client/static/js/77.8022a800b5.js +0 -139
  194. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  195. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  196. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  197. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  198. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  199. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  200. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  201. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  202. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  203. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  204. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  205. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  206. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  207. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  208. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  209. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  210. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  211. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  212. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  213. package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
  215. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  216. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  217. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  218. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  219. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  220. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  221. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
  222. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -51,8 +51,6 @@ export interface SessionFrameHost extends AutoTitleHost, QueueDeliveryHost {
51
51
 
52
52
  /** What the caller must do after the fold. */
53
53
  export interface FrameFoldResult {
54
- /** The session list is stale and needs a rebuild. */
55
- refreshSessionList: boolean;
56
54
  /** The fold already handled this frame's user-visible effect and the caller
57
55
  * must NOT forward it: a failed `prompt` response has emitted its own
58
56
  * `prompt_error`, and a `command_output` belonging to the chamber's own
@@ -88,7 +86,7 @@ async function markEndStatus(sessionId: string, messages: unknown): Promise<void
88
86
 
89
87
  /** Apply one frame to the wrapper's runtime state. */
90
88
  export function foldSessionFrame(host: SessionFrameHost, event: AgentEvent): FrameFoldResult {
91
- const result: FrameFoldResult = { refreshSessionList: false, suppressForward: false };
89
+ const result: FrameFoldResult = { suppressForward: false };
92
90
 
93
91
  switch (event.type) {
94
92
  case 'agent_start':
@@ -97,8 +95,12 @@ export function foldSessionFrame(host: SessionFrameHost, event: AgentEvent): Fra
97
95
  host.awaitingAgentStart = false;
98
96
  host.awaitingAgentStartDeadline = 0;
99
97
  host.continuationGraceUntil = 0;
98
+ // One early auto-title attempt per run, not per conversation: the flag is
99
+ // reset here so a run that opens after an aborted one (which skipped the
100
+ // settle attempt) still gets its own early try. The eligibility latch is
101
+ // what bounds this to the conversation's first run.
102
+ host.autoTitleRequested = false;
100
103
  clearSessionFileCaches();
101
- result.refreshSessionList = true;
102
104
  if (host.sessionId) void markStreamStatus(host.sessionId, 'stream');
103
105
  break;
104
106
  case 'turn_start':
@@ -113,19 +115,34 @@ export function foldSessionFrame(host: SessionFrameHost, event: AgentEvent): Fra
113
115
  // status sits there (upsert overwrites any terminal badge).
114
116
  if (host.sessionId) void markStreamStatus(host.sessionId, 'stream');
115
117
  break;
116
- case 'turn_end': {
117
- // A multi-turn run emits turn_end for EVERY turn — intermediates end with
118
- // `toolUse`/`stop` while the run keeps going (verified against omp
119
- // 18.2.8: agent_start → turn_start → turn_end('toolUse') → turn_start →
120
- // turn_end('stop') → agent_end, and the frame carries no isTerminal
121
- // field). Only `aborted` is unambiguous here: the run is over, so the
122
- // abort badge can be written before agent_end arrives. Everything else
123
- // waits for agent_end, which knows isTerminal.
124
- if (!host.sessionId) break;
125
- const turn = event.message as Record<string, unknown> | undefined;
126
- if (turn?.stopReason === 'aborted') void markStreamStatus(host.sessionId, 'abort');
118
+ case 'message_end': {
119
+ // A settled USER message is the earliest point at which the transcript
120
+ // carries the conversation's opening intent, so it is the earliest a
121
+ // title can be derived from it.
122
+ //
123
+ // `agent_start` is too early, and that is not an implementation detail
124
+ // but a property of omp's event order: the agent loop pushes
125
+ // `agent_start` BEFORE the turn opens, and the user's message rides
126
+ // `emitInputMessages` inside that turn. Measured on omp 18.3.0: the
127
+ // user message lands ~120ms after `agent_start`, so a `/rename` fired
128
+ // there reads `messageCount: 0`, builds an empty title context, and omp
129
+ // answers "Could not generate a session title". `message_start` is
130
+ // equally too early — the message is appended to the agent's state on
131
+ // `message_end`, so only here does `get_state` report it.
132
+ //
133
+ // The latch makes a later user message (a queued steer) a no-op, and a
134
+ // session that resumed an existing conversation is ineligible from the
135
+ // start. `agent_end` below retries if this attempt produced nothing.
136
+ const message = event.message as Record<string, unknown> | undefined;
137
+ if (message?.role === 'user') void triggerAutoSessionTitle(host, 'opening');
127
138
  break;
128
139
  }
140
+ // `turn_end` is deliberately NOT handled here: a multi-turn run emits it for
141
+ // EVERY turn (verified against omp 18.2.8: agent_start → turn_start →
142
+ // turn_end('toolUse') → turn_start → turn_end('stop') → agent_end) and the
143
+ // frame carries no isTerminal field, so no turn-level stopReason can be read
144
+ // as "the run is over". The terminal badge is written from `agent_end`
145
+ // alone (markEndStatus), which is the only frame that knows.
129
146
  case 'agent_end':
130
147
  if (event.isTerminal !== false) {
131
148
  host.streaming = false;
@@ -136,12 +153,15 @@ export function foldSessionFrame(host: SessionFrameHost, event: AgentEvent): Fra
136
153
  clearSessionFileCaches();
137
154
  const aborted = endedAborted(event.messages);
138
155
  if (host.sessionId) void markEndStatus(host.sessionId, event.messages);
139
- // A settled run is the moment a session becomes nameable: omp's own
140
- // first-message titling is suppressed under `--mode rpc-ui`
141
- // (PI_NO_TITLE), so the chamber asks for the same title itself. The
142
- // trigger is a no-op once the session carries a name — see
143
- // triggerAutoSessionTitle for why that check is the safety property.
144
- if (!aborted) void triggerAutoSessionTitle(host);
156
+ // Fallback for the early attempt: the first user message fires `/rename`
157
+ // before the turn runs, and that attempt can come back empty (a provider
158
+ // error, a timeout, a child that went away, or the tiny model
159
+ // declining). The run end is the last moment a title can still be
160
+ // derived from this conversation's OPENING turn, so retry here.
161
+ //
162
+ // An aborted run still skips: the operator stopped the turn, and the
163
+ // next completed run is a better moment than a half-run transcript.
164
+ if (!aborted) void triggerAutoSessionTitle(host, 'settle');
145
165
  // The run truly ended — the server, not the browser, decides whether a
146
166
  // queued follow-up goes out next. A user-aborted run holds the queue
147
167
  // (stop-all semantics): the next run end or an explicit send picks it up.
@@ -164,8 +184,8 @@ export function foldSessionFrame(host: SessionFrameHost, event: AgentEvent): Fra
164
184
  break;
165
185
  case 'command_output':
166
186
  // A `command_output` for a command the OPERATOR typed renders as a notice
167
- // row. But the chamber fires its own `/rename` after a settled run, and
168
- // omp answers it on the same frame — "Session renamed to …", or "Could not
187
+ // row. But the chamber fires its own `/rename` in the background, and omp
188
+ // answers it on the same frame — "Session renamed to …", or "Could not
169
189
  // generate a session title" for a low-signal first message. Neither was
170
190
  // asked for, and the second would be pure noise. The frame carries no
171
191
  // correlation id, so attribution is by the request window.
@@ -176,7 +196,6 @@ export function foldSessionFrame(host: SessionFrameHost, event: AgentEvent): Fra
176
196
  // keyed on file mtime, which that write cannot move, so the list must be
177
197
  // rebuilt explicitly.
178
198
  clearSessionFileCaches();
179
- result.refreshSessionList = true;
180
199
  break;
181
200
  case 'extension_ui_request':
182
201
  host.trackUiDialog(event);
@@ -60,6 +60,33 @@ async function rejectionOf(operation: Promise<unknown>): Promise<WebRpcError> {
60
60
  return error as WebRpcError;
61
61
  }
62
62
 
63
+ /** A process that completes the readiness handshake, so `waitUntilReady`
64
+ * reaches the `get_state` probe whose message count seeds the title latch. */
65
+ function makeReadyProc(messageCount: number): RpcProcess {
66
+ const proc = {
67
+ isAlive: true,
68
+ pid: 4243,
69
+ onFrame() {
70
+ return () => {};
71
+ },
72
+ waitReady() {
73
+ return Promise.resolve({ type: 'ready' });
74
+ },
75
+ negotiateProtocol() {
76
+ return Promise.resolve(1);
77
+ },
78
+ sendCommand(command: { type: string }) {
79
+ if (command.type === 'get_state') {
80
+ return Promise.resolve({ sessionId: 'sess-1', sessionFile: '/tmp/s.jsonl', messageCount, isStreaming: false, isCompacting: false });
81
+ }
82
+ return Promise.resolve(undefined);
83
+ },
84
+ sendFrame() {},
85
+ async dispose() {},
86
+ };
87
+ return proc as unknown as RpcProcess;
88
+ }
89
+
63
90
  describe('AgentSessionWrapper lifecycle', () => {
64
91
  // Every command arms the wrapper's idle timer; keep the clock fake so a test
65
92
  // never schedules a real ten-minute window.
@@ -135,4 +162,18 @@ describe('AgentSessionWrapper lifecycle', () => {
135
162
 
136
163
  expect(stub.disposeCount()).toBe(1);
137
164
  });
165
+
166
+ test('a fresh conversation is title-eligible, a resumed one is not', async () => {
167
+ // omp reports the messages it restored, so `messageCount` is the only
168
+ // signal that separates "this session has no first turn yet" from "this
169
+ // child resumed a conversation that already has one". Getting it wrong
170
+ // re-titles an existing session from its newest turn.
171
+ const fresh = new AgentSessionWrapper(makeReadyProc(0), '/tmp');
172
+ await fresh.waitUntilReady();
173
+ expect(fresh.autoTitlePending).toBe(true);
174
+
175
+ const resumed = new AgentSessionWrapper(makeReadyProc(2), '/tmp');
176
+ await resumed.waitUntilReady();
177
+ expect(resumed.autoTitlePending).toBe(false);
178
+ });
138
179
  });
@@ -18,7 +18,6 @@
18
18
  import { RpcProcess, type RpcFrame } from '@/server/lib/omp/rpc/process';
19
19
  import { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
20
20
  import { foldSessionFrame } from '@/server/lib/omp/rpc/frame-fold';
21
- import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
22
21
  import { dispatchSessionCommand } from '@/server/lib/omp/rpc/session-commands';
23
22
  import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
24
23
  import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
@@ -67,10 +66,19 @@ export class AgentSessionWrapper {
67
66
  // set in initialize(). No other flag on this wrapper can see them, and an
68
67
  // idle reclaim or a spawn-mode reconcile must never kill a running subagent.
69
68
  private readonly subagents = new SubagentLiveness();
70
- // One background `/rename` at a time. A settled run may be followed by
71
- // another (queue delivery, a second prompt), and two overlapping generations
72
- // would race to write the same title slot.
69
+ // One background `/rename` at a time. The first run makes up to two attempts
70
+ // (the early one at the first user message, the fallback at run end), and two
71
+ // overlapping generations would race to write the same title slot.
73
72
  autoTitleInFlight = false;
73
+ // One-shot: the FIRST run of this conversation is the only one the chamber
74
+ // may ask omp to name it from. Seeded in applyIdentity from omp's own message
75
+ // count, so a session reclaimed and respawned with `--resume` (which reports
76
+ // the messages it restored) never re-titles from its newest turn.
77
+ autoTitlePending = true;
78
+ // True once this run's early attempt (the first settled user message) has
79
+ // been made. Reset at each `agent_start`, so a queued steer message inside the
80
+ // same run cannot re-ask and cancel the generation already in flight.
81
+ autoTitleRequested = false;
74
82
  // Epoch ms until which `command_output` frames belong to our own background
75
83
  // rename and must not reach the timeline. 0 when nothing is outstanding.
76
84
  autoTitleWindowUntil = 0;
@@ -129,7 +137,6 @@ export class AgentSessionWrapper {
129
137
  start(): void {
130
138
  this.unsubscribeFrames = this.proc.onFrame((frame) => this.handleFrame(frame));
131
139
  this.resetIdleTimer();
132
- notifyRunningChange();
133
140
  }
134
141
 
135
142
  /** Resolves once the child announced readiness and identity is known. */
@@ -162,6 +169,12 @@ export class AgentSessionWrapper {
162
169
  this.streaming = state.isStreaming;
163
170
  this.compacting = state.isCompacting;
164
171
  this.fastModeEnabled = state.fastModeEnabled ?? state.fastMode ?? this.fastModeEnabled;
172
+ // omp reports the messages it restored, so a child spawned for an EXISTING
173
+ // conversation (a `--resume` after the idle reclaim, or a session opened
174
+ // from the sidebar) already has a first turn behind it — it must never be
175
+ // titled from whatever the operator asks next. Only a conversation with no
176
+ // messages at all is eligible, which is exactly the fresh-spawn case.
177
+ this.autoTitlePending = state.messageCount === 0;
165
178
  }
166
179
 
167
180
  handleProcessExit(stderrTail: string): void {
@@ -194,14 +207,11 @@ export class AgentSessionWrapper {
194
207
  const event = frame as AgentEvent;
195
208
  // The state machine and its settle-time side effects live in frame-fold.ts;
196
209
  // this method owns only the wrapper's own bookkeeping around it.
197
- const { refreshSessionList, suppressForward } = foldSessionFrame(this, event);
210
+ const { suppressForward } = foldSessionFrame(this, event);
198
211
  // `suppressForward` withholds only the FRAME — a failed prompt response
199
212
  // already emitted its own `prompt_error`, and the chamber's own background
200
- // rename must not surface its diagnostics. The running-state notification
201
- // still fires: the fold just cleared `promptRunning`, and that flip is what
202
- // releases the sidebar's spinner.
213
+ // rename must not surface its diagnostics.
203
214
  if (!suppressForward) this.emit(event);
204
- notifyRunningChange({ refreshSessionList });
205
215
  }
206
216
 
207
217
  emit(event: AgentEvent): void {
@@ -255,14 +265,6 @@ export class AgentSessionWrapper {
255
265
  this.onDestroyCallback = cb;
256
266
  }
257
267
 
258
- async withFinalRunningNotification<T>(operation: () => Promise<T>): Promise<T> {
259
- try {
260
- return await operation();
261
- } finally {
262
- notifyRunningChange();
263
- }
264
- }
265
-
266
268
  /** Persist the session identity omp reports in a get_state payload. */
267
269
  adoptSessionIdentity(state: RpcSessionState): void {
268
270
  if (!state.sessionId) return;
@@ -298,11 +300,9 @@ export class AgentSessionWrapper {
298
300
  this.continuationGraceUntil = 0;
299
301
  const disposed = this.proc.dispose().catch(() => {});
300
302
  this.destroyPromise = disposed;
301
- notifyRunningChange();
302
303
  await disposed;
303
304
  this.onDestroyCallback?.();
304
305
  }
305
306
  }
306
307
 
307
- export type { RunningRpcSession, RunningSessionUpdate } from '@/server/lib/omp/rpc/session-registry';
308
- export { getRpcSession, getRunningRpcSessions, getRunningRpcSessionIds, subscribeRunningSessions, notifyRunningChange, startRpcSession, prewarmRpcSession, startNewRpcSession } from '@/server/lib/omp/rpc/session-registry';
308
+ export { getRpcSession, startRpcSession, prewarmRpcSession, startNewRpcSession } from '@/server/lib/omp/rpc/session-registry';
@@ -26,22 +26,26 @@ export class PendingUiDialogs {
26
26
  // answer them in — a Map preserves it.
27
27
  private readonly requests = new Map<string, RpcFrame>();
28
28
 
29
- /** Record an answerable request, or forget one omp withdrew (`cancel`). */
30
- track(frame: RpcFrame): void {
29
+ /** Record an answerable request, or forget one omp withdrew (`cancel`).
30
+ * Returns whether the pending set changed — a caller that republishes state
31
+ * on every frame would otherwise republish for `notify`/`open_url` too. */
32
+ track(frame: RpcFrame): boolean {
31
33
  if (frame.method === 'cancel') {
32
34
  const targetId = typeof frame.targetId === 'string' ? frame.targetId : '';
33
- if (targetId) this.requests.delete(targetId);
34
- return;
35
+ return targetId ? this.requests.delete(targetId) : false;
35
36
  }
36
37
  const id = typeof frame.id === 'string' ? frame.id : '';
37
- if (!id || typeof frame.method !== 'string' || !ANSWERABLE_UI_METHODS.has(frame.method)) return;
38
+ if (!id || typeof frame.method !== 'string' || !ANSWERABLE_UI_METHODS.has(frame.method)) return false;
39
+ // A repeat of the same id is not a change.
40
+ if (this.requests.has(id)) return false;
38
41
  this.requests.set(id, frame);
42
+ return true;
39
43
  }
40
44
 
41
45
  /** Forget a request once its response is on the wire — omp will not raise it
42
46
  * again, so replaying it to a later client would only show a dead modal. */
43
- resolve(id: string): void {
44
- this.requests.delete(id);
47
+ resolve(id: string): boolean {
48
+ return this.requests.delete(id);
45
49
  }
46
50
 
47
51
  /** Still-blocking requests, oldest first. */
@@ -12,7 +12,6 @@
12
12
  import { RpcCommandTimeoutError, type RpcProcess } from '@/server/lib/omp/rpc/process';
13
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
- import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
16
15
  import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
17
16
  import { clearStreamStatus, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
18
17
  import { buildWebState, type WebStateHost } from '@/server/lib/omp/rpc/web-state';
@@ -34,7 +33,6 @@ export interface SessionCommandHost extends WebStateHost {
34
33
  resetIdleTimer(force?: boolean): void;
35
34
  /** Forget a pending ask/approval dialog once its response is sent. */
36
35
  resolvePendingUiDialog(id: string): void;
37
- withFinalRunningNotification<T>(operation: () => Promise<T>): Promise<T>;
38
36
  destroyAndWait(): Promise<void>;
39
37
  }
40
38
 
@@ -87,7 +85,6 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
87
85
  host.awaitingAgentStartDeadline = 0;
88
86
  host.continuationGraceUntil = 0;
89
87
  if (ownsStreamRow) void markStreamStatus(host.sessionId, 'stream');
90
- notifyRunningChange();
91
88
  }
92
89
  try {
93
90
  const ack = await host.proc.sendCommand<{ agentInvoked?: boolean } | undefined>({
@@ -102,7 +99,6 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
102
99
  host.awaitingAgentStartDeadline = 0;
103
100
  releaseStreamRow();
104
101
  host.emit({ type: 'prompt_result', agentInvoked: false });
105
- notifyRunningChange();
106
102
  // Nothing ran (agent was idle and declined) — the queue may hold the
107
103
  // next item; give it the same delivery window a run end would.
108
104
  scheduleQueueDelivery(host);
@@ -115,7 +111,6 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
115
111
  host.awaitingAgentStart = false;
116
112
  host.awaitingAgentStartDeadline = 0;
117
113
  releaseStreamRow();
118
- notifyRunningChange();
119
114
  // The ack may simply be queued behind a running turn: omp accepts the
120
115
  // prompt before the turn it starts. No reset, and the client must not
121
116
  // resend — a duplicate prompt would run twice.
@@ -130,14 +125,12 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
130
125
  }
131
126
 
132
127
  case 'abort':
133
- await host.withFinalRunningNotification(async () => {
134
- await host.proc.sendCommand({ type: 'abort' });
135
- host.promptRunning = false;
136
- host.awaitingAgentStart = false;
137
- host.awaitingAgentStartDeadline = 0;
138
- host.continuationGraceUntil = 0;
139
- if (host.sessionId) void markStreamStatus(host.sessionId, 'abort');
140
- });
128
+ await host.proc.sendCommand({ type: 'abort' });
129
+ host.promptRunning = false;
130
+ host.awaitingAgentStart = false;
131
+ host.awaitingAgentStartDeadline = 0;
132
+ host.continuationGraceUntil = 0;
133
+ if (host.sessionId) void markStreamStatus(host.sessionId, 'abort');
141
134
  return null;
142
135
 
143
136
  // Escape hatch behind the Stop button: `abort` resolves only once the turn
@@ -173,26 +166,23 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
173
166
 
174
167
  case 'compact': {
175
168
  try {
176
- return await host.withFinalRunningNotification(async () => {
177
- host.compacting = true;
178
- notifyRunningChange();
179
- try {
180
- const result = await host.proc.sendCommand<{ summary?: string; tokensBefore?: number; estimatedTokensAfter?: number }>({
181
- type: 'compact',
182
- ...(command.customInstructions ? { customInstructions: command.customInstructions } : {}),
183
- });
184
- return result;
185
- } finally {
186
- host.compacting = false;
187
- }
188
- });
169
+ host.compacting = true;
170
+ try {
171
+ const result = await host.proc.sendCommand<{ summary?: string; tokensBefore?: number; estimatedTokensAfter?: number }>({
172
+ type: 'compact',
173
+ ...(command.customInstructions ? { customInstructions: command.customInstructions } : {}),
174
+ });
175
+ return result;
176
+ } finally {
177
+ host.compacting = false;
178
+ }
189
179
  } finally {
190
180
  clearSessionFileCaches();
191
181
  }
192
182
  }
193
183
 
194
184
  case 'abort_compaction':
195
- await host.withFinalRunningNotification(() => host.proc.sendCommand({ type: 'abort' }));
185
+ await host.proc.sendCommand({ type: 'abort' });
196
186
  return null;
197
187
 
198
188
  case 'set_session_name': {
@@ -215,13 +205,11 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
215
205
  throw new Error('Cannot run a shell command while the session is busy');
216
206
  }
217
207
  host.bashRunning = true;
218
- notifyRunningChange();
219
208
  try {
220
209
  return await host.proc.sendCommand<{ output?: string; exitCode?: number }>({ type: 'bash', command: command.command as string });
221
210
  } finally {
222
211
  host.bashRunning = false;
223
212
  clearSessionFileCaches();
224
- notifyRunningChange();
225
213
  }
226
214
  }
227
215
 
@@ -4,12 +4,11 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Global omp session registry: keeps one AgentSessionWrapper per session id,
8
- * exposes lookups, and broadcasts running-session changes to subscribers.
9
- * Split out of rpc-manager.ts so each file stays under the repo's per-file
10
- * size ceiling. Importing AgentSessionWrapper here is a runtime-only circular
11
- * reference with rpc-manager (the wrapper calls notifyRunningChange) — both
12
- * usages happen inside functions, never at module init.
7
+ * Global omp session registry: keeps one AgentSessionWrapper per session id and
8
+ * exposes lookups. Split out of rpc-manager.ts so each file stays under the
9
+ * repo's per-file size ceiling. Importing AgentSessionWrapper here is a
10
+ * runtime-only circular reference with rpc-manager — every usage happens inside
11
+ * a function, never at module init.
13
12
  */
14
13
 
15
14
  import { RpcProcess } from '@/server/lib/omp/rpc/process';
@@ -17,24 +16,11 @@ import { buildSessionSpawnArgs } from '@/server/lib/omp/rpc/constants';
17
16
  import { AgentSessionWrapper } from '@/server/lib/omp/rpc/manager';
18
17
  import { DEFAULT_APPROVAL_MODE, type ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
18
 
20
- export interface RunningRpcSession {
21
- id: string;
22
- cwd: string;
23
- }
24
-
25
- export interface RunningSessionUpdate {
26
- ids: string[];
27
- runningSessions: RunningRpcSession[];
28
- refreshSessionList: boolean;
29
- }
30
-
31
19
  declare global {
32
20
  // eslint-disable-next-line no-var
33
21
  var __ompSessions: Map<string, AgentSessionWrapper> | undefined;
34
22
  // eslint-disable-next-line no-var
35
23
  var __ompStartLocks: Map<string, Promise<{ session: AgentSessionWrapper; realSessionId: string }>> | undefined;
36
- // eslint-disable-next-line no-var
37
- var __ompRunningListeners: Set<(update: RunningSessionUpdate) => void> | undefined;
38
24
  }
39
25
 
40
26
  function getRegistry(): Map<string, AgentSessionWrapper> {
@@ -57,21 +43,6 @@ export function getRpcSession(sessionId: string): AgentSessionWrapper | undefine
57
43
  return getRegistry().get(sessionId);
58
44
  }
59
45
 
60
- export function getRunningRpcSessions(): RunningRpcSession[] {
61
- const map = new Map<string, string>();
62
- for (const [sessionId, session] of getRegistry()) {
63
- if (session.isRunning()) {
64
- const realId = session.sessionId || sessionId;
65
- map.set(realId, session.cwd);
66
- }
67
- }
68
- return [...map.entries()].map(([id, cwd]) => ({ id, cwd }));
69
- }
70
-
71
- export function getRunningRpcSessionIds(): string[] {
72
- return getRunningRpcSessions().map((s) => s.id);
73
- }
74
-
75
46
  /**
76
47
  * Sessions whose omp process is parked on a dialog only the user can release —
77
48
  * an `ask` question or an approval gate. Deliberately independent of
@@ -90,38 +61,6 @@ export function getAwaitingInputSessionIds(): string[] {
90
61
  return [...ids];
91
62
  }
92
63
 
93
- function getRunningListeners(): Set<(update: RunningSessionUpdate) => void> {
94
- if (!globalThis.__ompRunningListeners) globalThis.__ompRunningListeners = new Set();
95
- return globalThis.__ompRunningListeners;
96
- }
97
-
98
- /** Subscribe to running-session-id changes and session-list refreshes. */
99
- export function subscribeRunningSessions(listener: (update: RunningSessionUpdate) => void): () => void {
100
- const listeners = getRunningListeners();
101
- listeners.add(listener);
102
- return () => { listeners.delete(listener); };
103
- }
104
-
105
- let lastRunningSnapshot = '';
106
-
107
- /**
108
- * Recompute the running-session-id set and, when it changes, broadcast it.
109
- * A session file may first appear after its id starts running, so callers can
110
- * force one otherwise-identical update to refresh sidebar session metadata.
111
- */
112
- export function notifyRunningChange({ refreshSessionList = false }: { refreshSessionList?: boolean } = {}): void {
113
- const runningSessions = getRunningRpcSessions();
114
- const ids = runningSessions.map((s) => s.id);
115
- if (runningSessions.length === 0 && lastRunningSnapshot === '[]' && !refreshSessionList) return;
116
- const snapshot = JSON.stringify(runningSessions.slice().sort((a, b) => a.id.localeCompare(b.id)));
117
- if (snapshot === lastRunningSnapshot && !refreshSessionList) return;
118
- lastRunningSnapshot = snapshot;
119
- const update: RunningSessionUpdate = { ids, runningSessions, refreshSessionList };
120
- for (const listener of getRunningListeners()) {
121
- try { listener(update); } catch { /* ignore listener errors */ }
122
- }
123
- }
124
-
125
64
  // The approval mode each wrapper's omp child was actually spawned with. omp has
126
65
  // no RPC setter for it, so a live wrapper is stale once the desired mode
127
66
  // differs and must be respawned with the new --approval-mode flag.
@@ -14,7 +14,6 @@
14
14
  * rpc-manager.ts stays under the repo's per-file size ceiling.
15
15
  */
16
16
 
17
- import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
18
17
  import type { RpcSessionState, WebSessionState } from '@/server/lib/omp/rpc/constants';
19
18
 
20
19
  /** Wrapper state the reconciliation reads and writes. */
@@ -34,8 +33,6 @@ export interface WebStateHost {
34
33
  }
35
34
 
36
35
  export function buildWebState(host: WebStateHost, state: RpcSessionState): WebSessionState {
37
- const wasRunning = host.isRunning();
38
-
39
36
  host.streaming = state.isStreaming;
40
37
  host.compacting = state.isCompacting;
41
38
  host.adoptSessionIdentity(state);
@@ -56,9 +53,6 @@ export function buildWebState(host: WebStateHost, state: RpcSessionState): WebSe
56
53
  host.awaitingAgentStartDeadline = 0;
57
54
  }
58
55
 
59
- if (wasRunning && !host.isRunning()) {
60
- notifyRunningChange();
61
- }
62
56
  return {
63
57
  sessionId: state.sessionId,
64
58
  sessionFile: state.sessionFile ?? '',