ompchamber 1.1.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 (192) 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.a5ccbc19dc.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/file-icon/index.tsx +4 -1
  36. package/src/client/components/common/image-viewer/index.tsx +224 -0
  37. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  38. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  39. package/src/client/components/settings/Modal.tsx +1 -0
  40. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  41. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  42. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  43. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  44. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  45. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  46. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  47. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  48. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
  49. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  53. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
  54. package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  56. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  57. package/src/client/components/workspace/editor/index.tsx +45 -36
  58. package/src/client/hooks/chat/btw/index.ts +180 -0
  59. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  60. package/src/client/hooks/chat/btw/mode.ts +179 -0
  61. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  62. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  63. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  64. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  65. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  66. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  67. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  68. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  69. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  70. package/src/client/hooks/chat/composer/index.ts +7 -1
  71. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  72. package/src/client/hooks/chat/omp/index.ts +3 -3
  73. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  74. package/src/client/hooks/chat/timeline/actions.ts +16 -1
  75. package/src/client/hooks/chat/timeline/index.ts +16 -8
  76. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  77. package/src/client/hooks/chat/timeline/send.ts +29 -65
  78. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  79. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  80. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  81. package/src/client/hooks/editor/file-actions.ts +49 -0
  82. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  83. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  84. package/src/client/tailwind.css +22 -0
  85. package/src/server/db.server.ts +21 -287
  86. package/src/server/lib/btw/frames.test.ts +68 -0
  87. package/src/server/lib/btw/frames.ts +56 -0
  88. package/src/server/lib/btw/prompt.ts +28 -0
  89. package/src/server/lib/btw/registry.server.ts +141 -0
  90. package/src/server/lib/btw/runtime.server.ts +324 -0
  91. package/src/server/lib/btw/schema.server.ts +44 -0
  92. package/src/server/lib/btw/service.server.ts +171 -0
  93. package/src/server/lib/btw/session-copy.server.ts +213 -0
  94. package/src/server/lib/btw/session-copy.test.ts +166 -0
  95. package/src/server/lib/btw/store.server.ts +240 -0
  96. package/src/server/lib/db/schema.ts +110 -0
  97. package/src/server/lib/db/seed.ts +160 -0
  98. package/src/server/lib/db/workspace-sync.ts +70 -0
  99. package/src/server/lib/fs/root.ts +37 -0
  100. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  101. package/src/server/lib/omp/rpc/manager.ts +27 -123
  102. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  103. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  104. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  105. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  106. package/src/server/lib/omp/session/file-scan.ts +174 -0
  107. package/src/server/lib/omp/session/files.test.ts +147 -0
  108. package/src/server/lib/omp/session/files.ts +52 -35
  109. package/src/server/lib/omp/session/locator.ts +2 -2
  110. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  111. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  112. package/src/server/lib/omp/session/messages.ts +34 -0
  113. package/src/server/lib/omp/session/reader.ts +2 -2
  114. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  115. package/src/server/lib/queue/delivery.server.test.ts +57 -0
  116. package/src/server/lib/queue/delivery.server.ts +25 -3
  117. package/src/server/plugins/ssr.ts +29 -2
  118. package/src/server/routes/btw/events.ts +70 -0
  119. package/src/server/routes/btw/index.ts +8 -0
  120. package/src/server/routes/btw/topics.ts +87 -0
  121. package/src/server/routes/btw/ws.ts +136 -0
  122. package/src/server/routes/chat/index.ts +2 -0
  123. package/src/server/routes/chat/session.ts +1 -115
  124. package/src/server/routes/chat/turns.ts +87 -0
  125. package/src/server/routes/fs/index.ts +4 -1
  126. package/src/server/routes/fs/raw.ts +85 -0
  127. package/src/server/routes/fs/read.ts +58 -3
  128. package/src/server/routes/index.ts +7 -1
  129. package/src/shared/lib/chat/attachments.test.ts +208 -0
  130. package/src/shared/lib/chat/attachments.ts +200 -15
  131. package/src/shared/lib/chat/btw/client.ts +94 -0
  132. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  133. package/src/shared/lib/chat/composer/client.ts +6 -1
  134. package/src/shared/lib/chat/composer/composer.test.ts +39 -1
  135. package/src/shared/lib/chat/composer/trigger.ts +45 -1
  136. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  137. package/src/shared/lib/chat/omp/socket.ts +11 -6
  138. package/src/shared/lib/chat/omp/sse.ts +11 -6
  139. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  140. package/src/shared/lib/chat/omp/transport.ts +19 -3
  141. package/src/shared/lib/chat/text-file.ts +148 -0
  142. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  143. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  144. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  145. package/src/shared/lib/fs/file-kind.ts +50 -0
  146. package/src/shared/lib/fs/paths.ts +19 -1
  147. package/src/shared/lib/markdown/math.test.ts +20 -3
  148. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  149. package/src/shared/lib/omp/session/mapper.ts +24 -4
  150. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  151. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  152. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  153. package/src/shared/types/btw.ts +65 -0
  154. package/src/shared/types/chat.ts +39 -1
  155. package/src/shared/types/index.ts +1 -0
  156. package/src/shared/types/omp/agent.ts +14 -1
  157. package/src/shared/types/omp/session.ts +4 -1
  158. package/src/shared/types/settings/state.ts +5 -0
  159. package/dist/client/static/css/index.b859cc1cc5.css +0 -2
  160. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  161. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  162. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  163. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  164. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  165. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  166. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  167. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  168. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  169. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  170. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  171. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  172. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  173. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  174. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  175. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  176. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  177. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  178. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  179. package/dist/client/static/js/index.6e065017eb.js +0 -33
  180. package/dist/client/static/js/jp.50817896f3.js +0 -139
  181. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  182. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  183. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  184. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  185. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  186. /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  187. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  188. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  189. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  190. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  191. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  192. /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -17,13 +17,12 @@
17
17
 
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
- import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
20
+ import { foldSessionFrame } from '@/server/lib/omp/rpc/frame-fold';
21
21
  import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
22
22
  import { dispatchSessionCommand } from '@/server/lib/omp/rpc/session-commands';
23
- import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
24
23
  import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
25
- import { loadStreamStatuses, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
26
- import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, NON_TERMINAL_CONTINUATION_GRACE_MS, READY_TIMEOUT_MS, SUBAGENT_STALE_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
24
+ import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
25
+ import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, READY_TIMEOUT_MS, SUBAGENT_STALE_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
27
26
 
28
27
  export type {
29
28
  AgentEvent,
@@ -68,6 +67,13 @@ export class AgentSessionWrapper {
68
67
  // set in initialize(). No other flag on this wrapper can see them, and an
69
68
  // idle reclaim or a spawn-mode reconcile must never kill a running subagent.
70
69
  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.
73
+ autoTitleInFlight = false;
74
+ // Epoch ms until which `command_output` frames belong to our own background
75
+ // rename and must not reach the timeline. 0 when nothing is outstanding.
76
+ autoTitleWindowUntil = 0;
71
77
  readonly idleDestroyMs: number;
72
78
  proc: RpcProcess;
73
79
  readonly cwd: string;
@@ -173,130 +179,28 @@ export class AgentSessionWrapper {
173
179
  this.destroy();
174
180
  }
175
181
 
176
- /** Write the terminal stream badge from the ending turn's own stopReason.
177
- * The `abort` command already wrote `abort` at dispatch time, but the
178
- * agent_end frame arrives later and previously flattened it to `finish`.
179
- * Re-check the current row and only upgrade `stream` rows, so a `finish`
180
- * written here can never clobber a newer run's live `stream`/`abort`. */
181
- private async markEndStatus(messages: unknown): Promise<void> {
182
- if (!this.sessionId) return;
183
- const aborted = Array.isArray(messages) && messages.some((entry) => {
184
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
185
- return (entry as Record<string, unknown>).stopReason === 'aborted';
186
- });
187
- const current = await loadStreamStatuses();
188
- if (current[this.sessionId] !== 'stream') return;
189
- await markStreamStatus(this.sessionId, aborted ? 'abort' : 'finish');
182
+ /** SessionFrameHost adapter: remember a blocking dialog for reattaching clients. */
183
+ trackUiDialog(frame: AgentEvent): void {
184
+ this.pendingUiDialogs.track(frame);
185
+ }
186
+
187
+ /** SessionFrameHost adapter: fold a subagent frame into the liveness roster. */
188
+ observeSubagent(frame: AgentEvent, now: number): void {
189
+ this.subagents.observe(frame, now);
190
190
  }
191
191
 
192
192
  private handleFrame(frame: RpcFrame): void {
193
193
  this.resetIdleTimer();
194
194
  const event = frame as AgentEvent;
195
- let refreshSessionList = false;
196
-
197
- switch (event.type) {
198
- case 'agent_start':
199
- this.promptRunning = true;
200
- this.streaming = true;
201
- this.awaitingAgentStart = false;
202
- this.awaitingAgentStartDeadline = 0;
203
- this.continuationGraceUntil = 0;
204
- clearSessionFileCaches();
205
- refreshSessionList = true;
206
- if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
207
- break;
208
- case 'turn_start':
209
- // Redundant with agent_start in the happy path, but the run-level
210
- // counterpart can be missed (e.g. the state was cleared by a command
211
- // between frames). The turn is proof enough the session is live.
212
- if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
213
- break;
214
- case 'message_start':
215
- // Same recovery as turn_start: a message frame is only emitted inside
216
- // a live run, so its arrival re-arms the `stream` row no matter what
217
- // stale status sits there (upsert overwrites any terminal badge).
218
- if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
219
- break;
220
- case 'turn_end': {
221
- // A multi-turn run emits turn_end for EVERY turn — intermediates end
222
- // with `toolUse`/`stop` while the run keeps going (verified against
223
- // omp 18.2.8: agent_start → turn_start → turn_end('toolUse') →
224
- // turn_start → turn_end('stop') → agent_end, and the frame carries no
225
- // isTerminal field). Only `aborted` is unambiguous here: the run is
226
- // over, so the abort badge can be written before agent_end arrives.
227
- // Everything else waits for agent_end, which knows isTerminal.
228
- if (!this.sessionId) break;
229
- const turn = event.message as Record<string, unknown> | undefined;
230
- if (turn?.stopReason === 'aborted') void markStreamStatus(this.sessionId, 'abort');
231
- break;
232
- }
233
- case 'agent_end':
234
- if (event.isTerminal !== false) {
235
- this.streaming = false;
236
- this.promptRunning = false;
237
- this.awaitingAgentStart = false;
238
- this.awaitingAgentStartDeadline = 0;
239
- this.continuationGraceUntil = 0;
240
- clearSessionFileCaches();
241
- // The turn's own stopReason is the ground truth for how it ended:
242
- // `aborted` = user stop, anything else (`stop`, `toolUse`, and the
243
- // ambiguous `error` that tool failures also produce) completes as
244
- // `finish`. This must not overwrite a fresh `abort` row from a NEWER
245
- // run — guard against downgrades by checking the current row first.
246
- const aborted = Array.isArray(event.messages) && event.messages.some((entry) => {
247
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
248
- return (entry as Record<string, unknown>).stopReason === 'aborted';
249
- });
250
- if (this.sessionId) void this.markEndStatus(event.messages);
251
- // The run truly ended — the server, not the browser, decides whether
252
- // a queued follow-up goes out next. A user-aborted run holds the
253
- // queue (stop-all semantics): the next run end or an explicit send
254
- // picks it up.
255
- if (!aborted) scheduleQueueDelivery(this);
256
- } else {
257
- this.continuationGraceUntil = Date.now() + NON_TERMINAL_CONTINUATION_GRACE_MS;
258
- }
259
- break;
260
- case 'prompt_result':
261
- this.promptRunning = false;
262
- this.awaitingAgentStart = false;
263
- this.awaitingAgentStartDeadline = 0;
264
- break;
265
- case 'auto_compaction_start':
266
- this.compacting = true;
267
- break;
268
- case 'auto_compaction_end':
269
- this.compacting = false;
270
- clearSessionFileCaches();
271
- break;
272
- case 'session_info_update':
273
- clearSessionFileCaches();
274
- refreshSessionList = true;
275
- break;
276
- case 'extension_ui_request':
277
- this.pendingUiDialogs.track(event);
278
- break;
279
- // Subagent frames carry no turn state, but they are the only proof that
280
- // work is still running once the parent turn has ended.
281
- case 'subagent_lifecycle':
282
- case 'subagent_progress':
283
- case 'subagent_event':
284
- this.subagents.observe(event, Date.now());
285
- break;
286
- case 'response': {
287
- if (event.success === false && event.command === 'prompt') {
288
- this.promptRunning = false;
289
- this.awaitingAgentStart = false;
290
- this.awaitingAgentStartDeadline = 0;
291
- this.emit({ type: 'prompt_error', errorMessage: (event.error as string) ?? 'Prompt failed' });
292
- notifyRunningChange();
293
- return;
294
- }
295
- break;
296
- }
297
- }
298
-
299
- this.emit(event);
195
+ // The state machine and its settle-time side effects live in frame-fold.ts;
196
+ // this method owns only the wrapper's own bookkeeping around it.
197
+ const { refreshSessionList, suppressForward } = foldSessionFrame(this, event);
198
+ // `suppressForward` withholds only the FRAME — a failed prompt response
199
+ // 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.
203
+ if (!suppressForward) this.emit(event);
300
204
  notifyRunningChange({ refreshSessionList });
301
205
  }
302
206
 
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ consumeAutoTitleOutput,
10
+ markTitleRequestSent,
11
+ triggerAutoSessionTitle,
12
+ type AutoTitleHost,
13
+ } from '@/server/lib/omp/session/auto-title.server';
14
+
15
+ /**
16
+ * The safety property of auto-titling, and the one thing that must never
17
+ * regress: the chamber fires omp's own `/rename` (which persists through
18
+ * `setSessionName(title, "user")`) in the background, and that write has no
19
+ * "don't overwrite" guard of its own — omp only refuses an `"auto"` write over
20
+ * a `"user"` title. So the decision to skip a NAMED session is ours, and a bug
21
+ * here silently destroys a name the operator typed.
22
+ *
23
+ * These tests drive the guard directly: a fake `get_state` reports a name, and
24
+ * the assertion is that no `/rename` reaches the wire.
25
+ */
26
+
27
+ interface FakeProc {
28
+ commands: Array<Record<string, unknown>>;
29
+ sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T>;
30
+ }
31
+
32
+ function makeHost(options: {
33
+ sessionName?: string;
34
+ sessionId?: string;
35
+ inFlight?: boolean;
36
+ }): { host: AutoTitleHost; proc: FakeProc } {
37
+ const proc: FakeProc = {
38
+ commands: [],
39
+ async sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T> {
40
+ proc.commands.push(command);
41
+ if (command.type === 'get_state') {
42
+ return {
43
+ sessionId: options.sessionId ?? 'sess-1',
44
+ sessionName: options.sessionName,
45
+ } as T;
46
+ }
47
+ return undefined as T;
48
+ },
49
+ };
50
+ return {
51
+ host: {
52
+ sessionId: 'sess-1',
53
+ autoTitleInFlight: options.inFlight ?? false,
54
+ autoTitleWindowUntil: 0,
55
+ proc: proc as unknown as AutoTitleHost['proc'],
56
+ },
57
+ proc,
58
+ };
59
+ }
60
+
61
+ /** Commands other than the `get_state` probe the guard always performs. */
62
+ function sentRenames(proc: FakeProc): Array<Record<string, unknown>> {
63
+ return proc.commands.filter((command) => command.type === 'prompt');
64
+ }
65
+
66
+ describe('triggerAutoSessionTitle', () => {
67
+ test('names an unnamed session through omp own /rename', async () => {
68
+ const { host, proc } = makeHost({});
69
+ await triggerAutoSessionTitle(host);
70
+
71
+ const renames = sentRenames(proc);
72
+ expect(renames).toHaveLength(1);
73
+ expect(renames[0].message).toBe('/rename');
74
+ // The request window opens only after the command is accepted, so the
75
+ // diagnostics omp is about to emit can be attributed to us.
76
+ expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
77
+ });
78
+
79
+ test('never overwrites a name the operator set', async () => {
80
+ const { host, proc } = makeHost({ sessionName: 'My Deliberate Name' });
81
+ await triggerAutoSessionTitle(host);
82
+
83
+ expect(sentRenames(proc)).toHaveLength(0);
84
+ // No request was sent, so nothing may claim the output window either.
85
+ expect(host.autoTitleWindowUntil).toBe(0);
86
+ });
87
+
88
+ test('treats a whitespace-only name as unnamed', async () => {
89
+ const { host, proc } = makeHost({ sessionName: ' ' });
90
+ await triggerAutoSessionTitle(host);
91
+
92
+ expect(sentRenames(proc)).toHaveLength(1);
93
+ });
94
+
95
+ test('skips when the session changed under the read', async () => {
96
+ // `get_state` answered for a different conversation (a reset or switch
97
+ // between the trigger and the read) — titling this session would name the
98
+ // wrong one.
99
+ const { host, proc } = makeHost({ sessionId: 'someone-else' });
100
+ await triggerAutoSessionTitle(host);
101
+
102
+ expect(sentRenames(proc)).toHaveLength(0);
103
+ });
104
+
105
+ test('does not stack a second request while one is in flight', async () => {
106
+ const { host, proc } = makeHost({ inFlight: true });
107
+ await triggerAutoSessionTitle(host);
108
+
109
+ expect(sentRenames(proc)).toHaveLength(0);
110
+ expect(proc.commands).toHaveLength(0);
111
+ });
112
+ });
113
+
114
+ describe('auto-title output window', () => {
115
+ test('claims exactly one frame per request', () => {
116
+ const { host } = makeHost({});
117
+ markTitleRequestSent(host);
118
+
119
+ expect(consumeAutoTitleOutput(host)).toBe(true);
120
+ // omp emits a single command_output per /rename; a second frame inside the
121
+ // window belongs to something the operator typed and must reach the UI.
122
+ expect(consumeAutoTitleOutput(host)).toBe(false);
123
+ });
124
+
125
+ test('claims nothing when no request is outstanding', () => {
126
+ const { host } = makeHost({});
127
+ expect(consumeAutoTitleOutput(host)).toBe(false);
128
+ });
129
+
130
+ test('expires so a stale window cannot swallow an operator command', () => {
131
+ const { host } = makeHost({});
132
+ markTitleRequestSent(host);
133
+ host.autoTitleWindowUntil = Date.now() - 1;
134
+
135
+ expect(consumeAutoTitleOutput(host)).toBe(false);
136
+ });
137
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Auto session titles for OMPChamber.
8
+ *
9
+ * Chamber spawns omp with `--mode rpc-ui`, and that mode force-sets
10
+ * `PI_NO_TITLE=1` (omp's main.ts). The flag gates only the two AUTOMATIC title
11
+ * paths — `AgentSession.maybeStartTitleGeneration` (first user message) and
12
+ * `#scheduleReplanTitleRefresh` (todo replan) — so a chamber session is never
13
+ * named on its own and the sidebar keeps the `New Session - <timestamp>`
14
+ * placeholder for its whole life.
15
+ *
16
+ * The generate path itself is NOT gated: `/rename` with no args reaches
17
+ * `session.generateTitle()` through `executeAcpBuiltinSlashCommand`, which
18
+ * rpc-mode dispatches for a `prompt` frame before any turn starts. Verified
19
+ * against omp 18.2.10 — the ack returns `{agentInvoked:false}` immediately, the
20
+ * generation lands ~4s later as `session_info_update` + `command_output`, and
21
+ * no entry is written to the transcript.
22
+ *
23
+ * WHY THE NAME MUST BE EMPTY FIRST: `/rename` persists through
24
+ * `setSessionName(title, "user")`. omp's own guard only refuses an `"auto"`
25
+ * write over a `"user"` title, so a `/rename` fired against an already-named
26
+ * session would overwrite the operator's own name. The unnamed check below is
27
+ * therefore the safety property, not a nicety — it is re-read from `get_state`
28
+ * on every attempt rather than cached, so a rename made in another tab is
29
+ * respected.
30
+ *
31
+ * Firing after every settled run (not just the first) mirrors omp: a session
32
+ * whose opening message was a bare greeting stays unnamed, and the next real
33
+ * message titles it. The low-signal filter on omp's side returns no title for
34
+ * such input, so the retry costs one tiny-model call and nothing else.
35
+ */
36
+
37
+ import { readSettingsJson } from '@/server/lib/db/settings-store';
38
+ import { getDb } from '@/server/db.server';
39
+ import { GET_STATE_TIMEOUT_MS, PROMPT_ACK_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
40
+
41
+ /** Chamber settings blob key; mirrors `streamTransport` and friends. */
42
+ const CHAMBER_SETTINGS_KEY = 'omp_chamber_settings';
43
+
44
+ /** Auto-titling is opt-in per chamber install. Default ON: an unnamed session
45
+ * renders as `New Session - <timestamp>` forever, which is the visible defect,
46
+ * and the cost is one tiny-model call per session. */
47
+ const AUTO_SESSION_TITLE_DEFAULT = true;
48
+
49
+ /** omp's builtin that generates a title from the conversation. Sent with no
50
+ * args so omp derives it through its own `generateRenameTitle`. */
51
+ const GENERATE_TITLE_COMMAND = '/rename';
52
+
53
+ /**
54
+ * How long the chamber's own `/rename` may keep emitting its diagnostic frames.
55
+ *
56
+ * The command answers on the prompt path with an immediate ack and then streams
57
+ * `session_info_update` + `command_output` once the tiny model returns
58
+ * (measured ~4s). Both carry NO correlation id, so the only way to attribute
59
+ * them to our background request is this window. It is deliberately generous —
60
+ * a slow provider must not leak the message — because the cost of over-holding
61
+ * it is only that a slash command typed in the same few seconds loses its
62
+ * output row.
63
+ */
64
+ const AUTO_TITLE_OUTPUT_WINDOW_MS = 60_000;
65
+
66
+ /** Wrapper state this module reads and writes. The in-flight flag and the
67
+ * output window are owned by the caller because they are per-session runtime
68
+ * state, not policy. */
69
+ export interface AutoTitleHost {
70
+ sessionId: string;
71
+ autoTitleInFlight: boolean;
72
+ /** Epoch ms until which frames belong to our own background rename; 0 when
73
+ * no request is outstanding. */
74
+ autoTitleWindowUntil: number;
75
+ proc: {
76
+ sendCommand<T = unknown>(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<T>;
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Claim the frames our own `/rename` is about to emit, so the wrapper can keep
82
+ * its diagnostics out of the timeline. Called once the request is accepted.
83
+ */
84
+ export function markTitleRequestSent(host: AutoTitleHost): void {
85
+ host.autoTitleWindowUntil = Date.now() + AUTO_TITLE_OUTPUT_WINDOW_MS;
86
+ }
87
+
88
+ /**
89
+ * True when this frame is the tail of our own background rename, consuming the
90
+ * window. One frame per request: omp emits exactly one `command_output` for it,
91
+ * on both the success and the "could not generate" path.
92
+ */
93
+ export function consumeAutoTitleOutput(host: AutoTitleHost): boolean {
94
+ if (host.autoTitleWindowUntil === 0 || Date.now() > host.autoTitleWindowUntil) return false;
95
+ host.autoTitleWindowUntil = 0;
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Read the `autoSessionTitle` flag from the chamber settings blob. Absent or
101
+ * malformed → the default. Never throws: a settings read must not be able to
102
+ * break a turn's settle path.
103
+ */
104
+ async function isAutoTitleEnabled(): Promise<boolean> {
105
+ try {
106
+ const db = await getDb();
107
+ const blob = await readSettingsJson<Record<string, unknown> | null>(db, CHAMBER_SETTINGS_KEY, null);
108
+ const value = blob?.autoSessionTitle;
109
+ return typeof value === 'boolean' ? value : AUTO_SESSION_TITLE_DEFAULT;
110
+ } catch {
111
+ return AUTO_SESSION_TITLE_DEFAULT;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Generate and persist a session title when the session has none yet.
117
+ *
118
+ * Called once per settled run. Every bail-out is deliberate: a named session
119
+ * must never be renamed by this path, and a generation already in flight must
120
+ * not be duplicated. Failures are swallowed — an auto-title is a convenience,
121
+ * and the next turn retries.
122
+ */
123
+ export async function triggerAutoSessionTitle(host: AutoTitleHost): Promise<void> {
124
+ if (host.autoTitleInFlight || !host.sessionId) return;
125
+ if (!(await isAutoTitleEnabled())) return;
126
+
127
+ host.autoTitleInFlight = true;
128
+ try {
129
+ // Re-read the name from omp rather than trusting a cached value: a rename
130
+ // made in another tab (or by omp's own TUI on the same session file) would
131
+ // otherwise be overwritten by the `"user"` write `/rename` performs.
132
+ const state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
133
+ if (state.sessionName?.trim()) return;
134
+ // The session changed under us (a reset, or a `switch_session`): the name
135
+ // we just read belongs to a different conversation.
136
+ if (state.sessionId !== host.sessionId) return;
137
+
138
+ // Direct `proc` write, NOT the chamber's prompt dispatch: this is
139
+ // background work, so it must not mark the session streaming or claim the
140
+ // sidebar's run row. omp's ack is immediate (`agentInvoked:false`); the
141
+ // generation itself streams back later as ordinary frames.
142
+ await host.proc.sendCommand({ type: 'prompt', message: GENERATE_TITLE_COMMAND }, PROMPT_ACK_TIMEOUT_MS);
143
+ // Accepted — the diagnostics omp is about to emit belong to this request,
144
+ // not to anything the operator typed.
145
+ markTitleRequestSent(host);
146
+ } catch {
147
+ // Provider error, timeout, or a child that went away — leave the session
148
+ // unnamed; the next settled run tries again.
149
+ } finally {
150
+ host.autoTitleInFlight = false;
151
+ }
152
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Blob-reference resolution for persisted session images.
8
+ *
9
+ * omp externalizes a large attached image out of the session JSONL: the entry
10
+ * keeps a content-addressed ref (`blob:sha256:<hash>`) and the bytes live in the
11
+ * blob store. Nothing else resolves them, so a reloaded session used to render
12
+ * `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
13
+ * string, which the browser cannot decode.
14
+ *
15
+ * The ref suffix feeds a `path.join` against the blob directory, so the
16
+ * canonical-hash guard is the security boundary and is tested as such.
17
+ *
18
+ * The agent dir is read per call (not at import time), so pointing
19
+ * `PI_CODING_AGENT_DIR` at a temp directory is enough to redirect the store.
20
+ */
21
+
22
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
23
+ import * as fsp from 'node:fs/promises';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import { getBlobStoreDir, isBlobRef, readBlob, readBlobRef } from '@/server/lib/omp/session/blobs.server';
27
+ import { extractUserImageAttachments, isBlobImageRef } from '@/shared/lib/omp/session/parse-message-blocks';
28
+
29
+ /** A minimal but structurally valid PNG, so the assertion is about real bytes. */
30
+ const PNG_BYTES = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
31
+ const HASH = 'a'.repeat(64);
32
+
33
+ let agentDir = '';
34
+
35
+ beforeAll(async () => {
36
+ agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'omp-blob-test-'));
37
+ process.env.PI_CODING_AGENT_DIR = agentDir;
38
+ await fsp.mkdir(getBlobStoreDir(), { recursive: true });
39
+ await fsp.writeFile(path.join(getBlobStoreDir(), HASH), PNG_BYTES);
40
+ });
41
+
42
+ afterAll(async () => {
43
+ delete process.env.PI_CODING_AGENT_DIR;
44
+ await fsp.rm(agentDir, { recursive: true, force: true });
45
+ });
46
+
47
+ describe('blob store reads', () => {
48
+ test('reads the bytes a hash points at', async () => {
49
+ expect(await readBlob(HASH)).toEqual(PNG_BYTES);
50
+ });
51
+
52
+ test('reads through a blob reference', async () => {
53
+ expect(await readBlobRef(`blob:sha256:${HASH}`)).toEqual(PNG_BYTES);
54
+ });
55
+
56
+ test('a missing blob yields null instead of throwing', async () => {
57
+ expect(await readBlob('b'.repeat(64))).toBeNull();
58
+ });
59
+
60
+ test('a non-reference string yields null', async () => {
61
+ expect(await readBlobRef('iVBORw0KGgo=')).toBeNull();
62
+ });
63
+
64
+ // The hash is interpolated into a path, so anything but a canonical digest
65
+ // must be refused before the filesystem is touched.
66
+ test('rejects refs that would escape the blob directory', async () => {
67
+ for (const bad of ['../../../../etc/passwd', '..%2f..%2fetc%2fpasswd', `${'a'.repeat(63)}/`, 'A'.repeat(64), 'abc', '']) {
68
+ expect(await readBlob(bad)).toBeNull();
69
+ expect(await readBlobRef(`blob:sha256:${bad}`)).toBeNull();
70
+ }
71
+ });
72
+
73
+ test('recognises the reference prefix', () => {
74
+ expect(isBlobRef(`blob:sha256:${HASH}`)).toBe(true);
75
+ expect(isBlobRef('data:image/png;base64,AAAA')).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe('image block extraction', () => {
80
+ test('an externalized block carries the ref instead of a fake data URL', () => {
81
+ const ref = `blob:sha256:${HASH}`;
82
+ const [attachment] = extractUserImageAttachments([{ type: 'image', data: ref, mimeType: 'image/png' }]);
83
+ expect(attachment.blobRef).toBe(ref);
84
+ // The bug: `data:image/png;base64,blob:sha256:…` is not decodable.
85
+ expect(attachment.preview).toBe('');
86
+ expect(attachment.preview).not.toContain('base64,blob:');
87
+ expect(attachment.type).toBe('image/png');
88
+ });
89
+
90
+ test('an inlined block still becomes a data URL', () => {
91
+ const [attachment] = extractUserImageAttachments([{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }]);
92
+ expect(attachment.preview).toBe('data:image/png;base64,iVBORw0KGgo=');
93
+ expect(attachment.blobRef).toBeUndefined();
94
+ });
95
+
96
+ test('distinguishes the two payload shapes', () => {
97
+ expect(isBlobImageRef(`blob:sha256:${HASH}`)).toBe(true);
98
+ expect(isBlobImageRef('iVBORw0KGgo=')).toBe(false);
99
+ });
100
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Blob reference resolution for persisted session images.
8
+ *
9
+ * omp keeps an image's bytes out of the session JSONL once they are large: the
10
+ * entry stores a content-addressed reference (`blob:sha256:<hash>`) and the raw
11
+ * bytes live in the blob store (`$OMP_AGENT_DIR/blobs/<hash>`, an XDG-aware
12
+ * path — see `getBlobStoreDir`). Verified against omp 18.2.10:
13
+ * `pi-coding-agent/src/session/blob-store.ts` is the producer, and only omp's
14
+ * own loader (`session-loader.ts:resolveBlobRefs`) resolves the refs back on
15
+ * read.
16
+ *
17
+ * Nothing in the chamber did that, so a reloaded session rendered
18
+ * `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
19
+ * string, which no browser can decode. The timeline showed a broken image and
20
+ * opening it dumped the ref as text.
21
+ *
22
+ * Resolution is confined to the blob store: the suffix must be a canonical
23
+ * 64-char lowercase hex digest, so a crafted ref cannot escape the directory
24
+ * (the same guard omp applies in `parseBlobRef`).
25
+ */
26
+
27
+ import * as fsp from 'node:fs/promises';
28
+ import { existsSync } from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import { getAgentDir } from '@/server/lib/omp/core/paths';
31
+
32
+ const BLOB_PREFIX = 'blob:sha256:';
33
+
34
+ /** Canonical blob hash shape; anything else is rejected before touching the fs. */
35
+ const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
36
+
37
+ /** True when `value` is a content-addressed blob reference. */
38
+ export function isBlobRef(value: string): boolean {
39
+ return value.startsWith(BLOB_PREFIX);
40
+ }
41
+
42
+ /**
43
+ * The blob store directory. Mirrors omp's `getBlobsDir()`: `<agentDir>/blobs`,
44
+ * relocated to `$XDG_DATA_HOME/omp/blobs` when omp is running the XDG layout
45
+ * (which drops the `agent/` prefix). The existing-directory probe decides which
46
+ * layout is live, because omp's own switch is opt-in and file-based.
47
+ */
48
+ export function getBlobStoreDir(): string {
49
+ const xdgDataHome = Bun.env.XDG_DATA_HOME;
50
+ if (xdgDataHome) {
51
+ const xdgPath = path.join(xdgDataHome, 'omp', 'blobs');
52
+ if (existsSync(xdgPath)) return xdgPath;
53
+ }
54
+ return path.join(getAgentDir(), 'blobs');
55
+ }
56
+
57
+ /**
58
+ * Read a blob's raw bytes, or null when the ref is malformed or the blob is
59
+ * gone (a pruned store, a session copied between machines). A missing blob is
60
+ * not an error the caller can act on — the caller falls back to showing the
61
+ * attachment without a preview.
62
+ */
63
+ export async function readBlob(hash: string): Promise<Buffer | null> {
64
+ if (!BLOB_HASH_RE.test(hash)) return null;
65
+ try {
66
+ return await fsp.readFile(path.join(getBlobStoreDir(), hash));
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** Read the bytes behind a `blob:sha256:<hash>` reference, or null. */
73
+ export async function readBlobRef(ref: string): Promise<Buffer | null> {
74
+ if (!isBlobRef(ref)) return null;
75
+ return readBlob(ref.slice(BLOB_PREFIX.length));
76
+ }