ompchamber 1.2.0 → 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 +129 -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.57b39ffa7f.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
  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.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -16,67 +16,13 @@
16
16
  * follow-up stranded, while a freshly started server delivered the identical
17
17
  * item correctly.
18
18
  *
19
- * The store is mocked so the contract under test is delivery's own: which RPCs
20
- * a claimed item produces, and that a lost timer is superseded rather than
21
- * allowed to wedge the session. Time is driven by fake timers, so nothing here
22
- * waits on the wall clock.
19
+ * This file covers the SCHEDULING contract — when a delivery runs, when it
20
+ * defers, and how it recovers. What a dispatch actually sends lives in
21
+ * `delivery-dispatch.server.test.ts`; both share `delivery-harness.ts`.
23
22
  */
24
23
 
25
- import { afterAll, afterEach, beforeEach, describe, expect, jest, mock, test } from 'bun:test';
26
- import type { QueuedMessage } from '@/shared/types/chat';
27
- import type { QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
28
-
29
- /** In-memory stand-in for the SQLite-backed queue. */
30
- const queue = new Map<string, QueuedMessage[]>();
31
-
32
- mock.module('@/server/lib/queue/store.server', () => ({
33
- claimHeadQueueItem: (sessionId: string): QueuedMessage | null => {
34
- const items = queue.get(sessionId);
35
- return items?.shift() ?? null;
36
- },
37
- requeueHeadQueueItem: async (sessionId: string, item: QueuedMessage): Promise<void> => {
38
- const items = queue.get(sessionId) ?? [];
39
- items.unshift(item);
40
- queue.set(sessionId, items);
41
- },
42
- }));
43
-
44
- const { scheduleQueueDelivery } = await import('@/server/lib/queue/delivery.server');
45
-
46
- const MODEL = { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max', accessMode: 'yolo' as const };
47
-
48
- function queueItem(text: string, model: QueuedMessage['model'] = MODEL): QueuedMessage {
49
- return { id: `item-${text}`, text, attachments: [], model };
50
- }
51
-
52
- /** Records the RPCs a delivery pushes at the session. */
53
- function makeHost(sessionId: string) {
54
- const sent: Record<string, unknown>[] = [];
55
- const host: QueueDeliveryHost = {
56
- sessionId,
57
- isAlive: () => true,
58
- isBusy: () => false,
59
- send: async (command) => {
60
- sent.push(command);
61
- return {};
62
- },
63
- };
64
- return { host, sent };
65
- }
66
-
67
- /** Let the delivery's promise chain settle without advancing the clock. */
68
- async function flush(): Promise<void> {
69
- for (let i = 0; i < 20; i++) await Promise.resolve();
70
- }
71
-
72
- let sessionId = '';
73
- let counter = 0;
74
-
75
- beforeEach(() => {
76
- sessionId = `queue-test-${counter++}`;
77
- queue.clear();
78
- jest.useFakeTimers();
79
- });
24
+ import { afterAll, afterEach, describe, expect, jest, mock, test } from 'bun:test';
25
+ import { beginQueueTest, flush, makeHost, queue, queueItem, scheduleQueueDelivery, settleDelivery } from '@/server/lib/queue/delivery-harness';
80
26
 
81
27
  afterEach(() => {
82
28
  jest.useRealTimers();
@@ -86,147 +32,130 @@ afterAll(() => {
86
32
  mock.restore();
87
33
  });
88
34
 
89
- describe('queue delivery', () => {
90
- test('delivers the head with the model snapshot it was queued with', async () => {
91
- queue.set(sessionId, [queueItem('queued hello')]);
35
+ describe('queue delivery scheduling', () => {
36
+ test('a busy session holds the item instead of sending it', async () => {
37
+ const sessionId = beginQueueTest();
38
+ queue.set(sessionId, [queueItem('held')]);
92
39
  const { host, sent } = makeHost(sessionId);
40
+ host.isBusy = () => true;
93
41
 
94
- scheduleQueueDelivery(host);
95
- jest.advanceTimersByTime(600);
96
- await flush();
42
+ await settleDelivery(host);
97
43
 
98
- expect(sent).toEqual([
99
- { type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
100
- { type: 'set_thinking_level', level: 'max' },
101
- { type: 'prompt', message: 'queued hello', accessMode: 'yolo' },
102
- ]);
103
- expect(queue.get(sessionId)).toEqual([]);
44
+ expect(sent).toEqual([]);
45
+ expect(queue.get(sessionId)?.length).toBe(1);
104
46
  });
105
47
 
106
- test("an 'auto' thinking level is left to the session", async () => {
107
- queue.set(sessionId, [queueItem('no level', { ...MODEL, thinkingLevel: 'auto' })]);
48
+ // The regression this file exists for: a delivery that finds the session busy
49
+ // at its tick used to return and stay returned. The run-end that scheduled it
50
+ // is the LAST trigger that run produces, so nothing re-armed it — the queue
51
+ // stalled with no error, and even a manual nudge could not recover it because
52
+ // `scheduleQueueDelivery` early-returns while a fresh entry sits in the map.
53
+ test('a delivery that finds the session busy retries until it goes idle', async () => {
54
+ const sessionId = beginQueueTest();
55
+ queue.set(sessionId, [queueItem('held then sent')]);
108
56
  const { host, sent } = makeHost(sessionId);
57
+ let busy = true;
58
+ host.isBusy = () => busy;
109
59
 
110
- scheduleQueueDelivery(host);
111
- jest.advanceTimersByTime(600);
112
- await flush();
60
+ await settleDelivery(host);
113
61
 
114
- expect(sent).toEqual([
115
- { type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
116
- { type: 'prompt', message: 'no level', accessMode: 'yolo' },
117
- ]);
118
- });
62
+ // Held at the first tick — the item must survive untouched.
63
+ expect(sent).toEqual([]);
64
+ expect(queue.get(sessionId)?.length).toBe(1);
119
65
 
120
- test('an item with no model snapshot is sent without touching the model', async () => {
121
- queue.set(sessionId, [queueItem('session model', null)]);
122
- const { host, sent } = makeHost(sessionId);
66
+ // Still busy across several retry intervals: the item must not be lost.
67
+ jest.advanceTimersByTime(6_000);
68
+ await flush();
69
+ expect(sent).toEqual([]);
70
+ expect(queue.get(sessionId)?.length).toBe(1);
123
71
 
124
- scheduleQueueDelivery(host);
125
- jest.advanceTimersByTime(600);
72
+ // The session frees up on its own (the subagent finished, the dialog was
73
+ // answered). No new agent_end arrives; the retry is the only thing that can
74
+ // deliver it.
75
+ busy = false;
76
+ jest.advanceTimersByTime(2_000);
126
77
  await flush();
127
78
 
128
- expect(sent).toEqual([{ type: 'prompt', message: 'session model' }]);
79
+ expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
80
+ expect(queue.get(sessionId)).toEqual([]);
129
81
  });
130
82
 
131
- test('a busy session holds the item instead of sending it', async () => {
132
- queue.set(sessionId, [queueItem('held')]);
83
+ test('a retry stops arming once the queue has emptied elsewhere', async () => {
84
+ const sessionId = beginQueueTest();
85
+ queue.set(sessionId, [queueItem('taken by another tab')]);
133
86
  const { host, sent } = makeHost(sessionId);
134
87
  host.isBusy = () => true;
135
88
 
136
- scheduleQueueDelivery(host);
137
- jest.advanceTimersByTime(600);
89
+ await settleDelivery(host);
90
+
91
+ // Another tab claimed it while this session was busy.
92
+ queue.set(sessionId, []);
93
+ jest.advanceTimersByTime(2_000);
138
94
  await flush();
139
95
 
140
- expect(sent).toEqual([]);
141
- expect(queue.get(sessionId)?.length).toBe(1);
96
+ // No poll loop is left behind: the host is never asked to deliver again.
97
+ const probesAfterEmpty = sent.length;
98
+ jest.advanceTimersByTime(60_000);
99
+ await flush();
100
+ expect(sent.length).toBe(probesAfterEmpty);
142
101
  });
143
102
 
144
103
  test('a failed dispatch returns the item to the head', async () => {
104
+ const sessionId = beginQueueTest();
145
105
  queue.set(sessionId, [queueItem('retry me')]);
146
106
  const { host } = makeHost(sessionId);
147
107
  host.send = async () => {
148
108
  throw new Error('rpc down');
149
109
  };
150
110
 
151
- scheduleQueueDelivery(host);
152
- jest.advanceTimersByTime(600);
153
- await flush();
111
+ await settleDelivery(host);
154
112
 
155
113
  expect(queue.get(sessionId)?.map((i) => i.text)).toEqual(['retry me']);
156
114
  });
157
115
 
158
- test('repeated triggers deliver the head exactly once', async () => {
159
- queue.set(sessionId, [queueItem('once')]);
116
+ // A failed dispatch re-arms, so a transient RPC failure drains on its own
117
+ // rather than waiting for a run end that may never come.
118
+ test('a failed dispatch retries and delivers once the send recovers', async () => {
119
+ const sessionId = beginQueueTest();
120
+ queue.set(sessionId, [queueItem('transient')]);
160
121
  const { host, sent } = makeHost(sessionId);
122
+ let failing = true;
123
+ host.send = async (command) => {
124
+ if (failing) throw new Error('rpc down');
125
+ sent.push(command);
126
+ return {};
127
+ };
161
128
 
162
- scheduleQueueDelivery(host);
163
- scheduleQueueDelivery(host);
164
- scheduleQueueDelivery(host);
165
- jest.advanceTimersByTime(600);
129
+ await settleDelivery(host);
130
+ expect(sent).toEqual([]);
131
+
132
+ failing = false;
133
+ jest.advanceTimersByTime(2_000);
166
134
  await flush();
167
135
 
168
136
  expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
169
137
  expect(queue.get(sessionId)).toEqual([]);
170
138
  });
171
139
 
172
- // Attachments reach the server as the persisted display fields only: the
173
- // composer's `File` never survives the queue row's JSON round trip, and its
174
- // image preview is a `blob:` object URL the server cannot read. These pin the
175
- // fields the delivery must use instead — `dataBase64` for the bytes, `type`
176
- // for the media type, `content` for an inline-able text file.
177
- test('a queued image rides its persisted base64 payload', async () => {
178
- const item = queueItem('with image', null);
179
- item.attachments = [
180
- { id: 'i', name: 'shot.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: 'iVBORw0KGgo=' },
181
- ] as QueuedMessage['attachments'];
182
- queue.set(sessionId, [item]);
140
+ test('repeated triggers deliver the head exactly once', async () => {
141
+ const sessionId = beginQueueTest();
142
+ queue.set(sessionId, [queueItem('once')]);
183
143
  const { host, sent } = makeHost(sessionId);
184
144
 
145
+ // Three triggers land before the settle delay elapses — the head must be
146
+ // claimed once, not once per trigger.
185
147
  scheduleQueueDelivery(host);
186
- jest.advanceTimersByTime(600);
187
- await flush();
188
-
189
- expect(sent).toEqual([
190
- {
191
- type: 'prompt',
192
- message: 'with image',
193
- images: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }],
194
- },
195
- ]);
196
- });
197
-
198
- test('a queued text file is inlined into the delivered prompt', async () => {
199
- const item = queueItem('review this', null);
200
- item.attachments = [
201
- { id: 't', name: 'notes.md', type: 'text/markdown', size: 5, preview: '', content: '# hi' },
202
- ] as QueuedMessage['attachments'];
203
- queue.set(sessionId, [item]);
204
- const { host, sent } = makeHost(sessionId);
205
-
206
148
  scheduleQueueDelivery(host);
207
- jest.advanceTimersByTime(600);
208
- await flush();
209
-
210
- const prompt = sent.find((c) => c.type === 'prompt');
211
- expect(prompt?.message).toBe('review this\n\nAttached file: notes.md\n```markdown\n# hi\n```');
212
- });
213
-
214
- test('an attachment without a payload is not sent as a blank image', async () => {
215
- const item = queueItem('no payload', null);
216
- item.attachments = [
217
- { id: 'i', name: 'broken.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: '' },
218
- ] as QueuedMessage['attachments'];
219
- queue.set(sessionId, [item]);
220
- const { host, sent } = makeHost(sessionId);
221
-
222
149
  scheduleQueueDelivery(host);
223
150
  jest.advanceTimersByTime(600);
224
151
  await flush();
225
152
 
226
- expect(sent).toEqual([{ type: 'prompt', message: 'no payload' }]);
153
+ expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
154
+ expect(queue.get(sessionId)).toEqual([]);
227
155
  });
228
156
 
229
157
  test('a lost timer delays the queue instead of wedging it', async () => {
158
+ const sessionId = beginQueueTest();
230
159
  queue.set(sessionId, [queueItem('recovered')]);
231
160
  const { host, sent } = makeHost(sessionId);
232
161
 
@@ -20,7 +20,7 @@
20
20
  * semantics live on the server now.
21
21
  */
22
22
 
23
- import { claimHeadQueueItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
23
+ import { claimHeadQueueItem, hasQueuedItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
24
24
  import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
25
25
  import type { QueuedMessage } from '@/shared/types/chat';
26
26
 
@@ -35,26 +35,57 @@ const DELIVERY_DELAY_MS = 500;
35
35
  *
36
36
  * Without this, one lost timer wedges the session's queue FOREVER: the guard
37
37
  * below early-returns on any existing entry, so nothing would ever schedule
38
- * again. A timer can be lost without the process dying — `bun --hot`
39
- * re-evaluating this module discards the pending closure, and a wedged event
40
- * loop drops the callback — and the symptom is silent: the queue simply never
38
+ * again. A timer can be lost without the process dying — a wedged event loop
39
+ * drops the callback — and the symptom is silent: the queue simply never
41
40
  * drains and no error is reported. Past this bound the entry is presumed lost
42
41
  * and rescheduled, so a client nudge (sent whenever a session with queued
43
42
  * items mounts or regains focus) always recovers the queue.
43
+ *
44
+ * This covers only a timer that never RUNS. A delivery that runs and cannot
45
+ * proceed re-arms itself (see {@link DELIVERY_RETRY_MS}); the two paths are
46
+ * independent, so neither depends on a client trigger arriving.
44
47
  */
45
48
  const DELIVERY_STALE_MS = 5_000;
46
49
 
50
+ /**
51
+ * Retry interval for a delivery that could not run: the session was busy at its
52
+ * tick, or the attempt itself failed.
53
+ *
54
+ * A busy session is the common case, not an edge one — a subagent outlives the
55
+ * parent turn's `agent_end`, a compaction runs after it, and an ask/approval
56
+ * dialog parks it indefinitely — and every one of those is exactly when the
57
+ * run-end trigger fires. `isBusy()` is an in-memory flag read, so polling it
58
+ * costs nothing; the alternative (a wake hook per busy source) would need
59
+ * re-wiring every time a new source is added.
60
+ */
61
+ const DELIVERY_RETRY_MS = 2_000;
62
+
47
63
  interface PendingDelivery {
48
64
  timer: ReturnType<typeof setTimeout>;
49
65
  /** Wall-clock after which this entry is presumed lost and superseded. */
50
66
  expiresAt: number;
51
67
  }
52
68
 
69
+ declare global {
70
+ // eslint-disable-next-line no-var
71
+ var __ompQueueDeliveryTimers: Map<string, PendingDelivery> | undefined;
72
+ }
73
+
53
74
  /** Per-session pending delivery timer, so a second agent_end while one is
54
- * already scheduled cannot double-book the head. */
55
- const pendingTimers = new Map<string, PendingDelivery>();
75
+ * already scheduled cannot double-book the head.
76
+ *
77
+ * On `globalThis` for the same reason the session registry is: a `bun --hot`
78
+ * soft reload re-evaluates this module but keeps globalThis, so a module-local
79
+ * map would silently orphan every pending entry — the reloaded module would
80
+ * see an empty map, `clearPendingTimer` could no longer cancel the orphan, and
81
+ * the session's queue would stall with nothing logged. */
82
+ function getPendingTimers(): Map<string, PendingDelivery> {
83
+ if (!globalThis.__ompQueueDeliveryTimers) globalThis.__ompQueueDeliveryTimers = new Map();
84
+ return globalThis.__ompQueueDeliveryTimers;
85
+ }
56
86
 
57
87
  function clearPendingTimer(sessionId: string): void {
88
+ const pendingTimers = getPendingTimers();
58
89
  const entry = pendingTimers.get(sessionId);
59
90
  if (entry === undefined) return;
60
91
  clearTimeout(entry.timer);
@@ -129,26 +160,87 @@ export interface QueueDeliveryHost {
129
160
  export function scheduleQueueDelivery(session: QueueDeliveryHost): void {
130
161
  const sessionId = session.sessionId;
131
162
  if (!sessionId) return;
163
+ const pendingTimers = getPendingTimers();
132
164
  const pending = pendingTimers.get(sessionId);
133
165
  if (pending !== undefined) {
134
166
  if (Date.now() < pending.expiresAt) return;
135
167
  // Lost timer: clear it so its callback cannot also fire and double-deliver.
136
168
  clearTimeout(pending.timer);
137
169
  }
170
+ armDelivery(session, DELIVERY_DELAY_MS);
171
+ }
172
+
173
+ /**
174
+ * Deliver the head right now, with no timer involved. Returns whether an item
175
+ * was actually sent.
176
+ *
177
+ * The nudge route's entry point: the failure a nudge recovers from is a lost
178
+ * timer, so the nudge itself must not depend on one. Busy sessions re-arm the
179
+ * normal retry (which is timer-based, and harmless — the nudge is a bonus path,
180
+ * not the only one), and an empty queue is a no-op.
181
+ */
182
+ export async function deliverQueueNow(session: QueueDeliveryHost): Promise<boolean> {
183
+ return deliverNext(session);
184
+ }
185
+
186
+ /** Arm (or re-arm) this session's delivery tick. */
187
+ function armDelivery(session: QueueDeliveryHost, delayMs: number): void {
188
+ const sessionId = session.sessionId;
189
+ const pendingTimers = getPendingTimers();
138
190
  const timer = setTimeout(() => {
139
191
  pendingTimers.delete(sessionId);
140
- void deliverNext(session);
141
- }, DELIVERY_DELAY_MS);
192
+ // The attempt is fire-and-forget from the timer's perspective, but it must
193
+ // never reject silently: an unhandled rejection here is how a throwing
194
+ // `claimHeadQueueItem` (a `getDbSync()` before the db is resolved) or a
195
+ // failing RPC turned into a queue that simply stopped draining with nothing
196
+ // in the log. `deliverNext` owns its own failure handling; this catch is
197
+ // the last resort so the failure is at least reported.
198
+ deliverNext(session).catch((error) => {
199
+ console.error(`[queue] delivery attempt failed for session ${sessionId}:`, error);
200
+ });
201
+ }, delayMs);
142
202
  pendingTimers.set(sessionId, { timer, expiresAt: Date.now() + DELIVERY_STALE_MS });
143
203
  }
144
204
 
145
- async function deliverNext(session: QueueDeliveryHost): Promise<void> {
205
+ /**
206
+ * Re-arm the retry, but only while there is still something to deliver.
207
+ *
208
+ * A busy session whose queue emptied in the meantime (another tab took the
209
+ * head, or the user deleted the row) must not leave a poll loop running
210
+ * forever.
211
+ */
212
+ async function armRetryIfQueued(session: QueueDeliveryHost): Promise<void> {
213
+ if (await hasQueuedItem(session.sessionId)) armDelivery(session, DELIVERY_RETRY_MS);
214
+ }
215
+
216
+ /**
217
+ * Deliver the head, or re-arm and try again later. True when an item was sent.
218
+ *
219
+ * A busy session is the normal case at the moment this runs — the run-end that
220
+ * scheduled it fires while a subagent, a compaction or an ask dialog is still
221
+ * live, and `isBusy()` is true for all three. Returning there is what used to
222
+ * strand the queue permanently: the timer had already been deleted from the map
223
+ * by its own callback, so nothing remained to retry it and no later trigger was
224
+ * guaranteed to arrive. Re-arming keeps the retry owned by this module.
225
+ */
226
+ async function deliverNext(session: QueueDeliveryHost): Promise<boolean> {
146
227
  const sessionId = session.sessionId;
147
- if (!session.isAlive() || session.isBusy()) return;
148
- const item = claimHeadQueueItem(sessionId);
149
- if (!item) return;
228
+ if (!session.isAlive()) return false;
229
+ if (session.isBusy()) {
230
+ await armRetryIfQueued(session);
231
+ return false;
232
+ }
233
+ const item = await claimHeadQueueItem(sessionId);
234
+ if (!item) return false;
150
235
  const ok = await dispatchQueuedItem(session, item);
151
- if (!ok) await requeueHeadQueueItem(sessionId, item);
236
+ if (!ok) {
237
+ await requeueHeadQueueItem(sessionId, item);
238
+ // The dispatch threw (provider error, dead child). Retry on the same cadence
239
+ // a busy session gets, so a transient failure drains without user action.
240
+ await armRetryIfQueued(session);
241
+ return false;
242
+ }
243
+ return true;
152
244
  }
153
245
 
154
246
  /**
@@ -19,7 +19,7 @@
19
19
  * concurrent nudges. A failed send re-inserts the claimed row at the head.
20
20
  */
21
21
 
22
- import { getDb, getDbSync } from '@/server/db.server';
22
+ import { getDb } from '@/server/db.server';
23
23
  import { withTransaction } from '@/shared/lib/db/transaction.server';
24
24
  import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
25
25
  import type { QueuedMessage, QueuedMessageModel } from '@/shared/types/chat';
@@ -154,11 +154,20 @@ export async function reorderQueue(sessionId: string, orderedIds: string[]): Pro
154
154
  * Atomically claim the head for delivery: read + delete inside one
155
155
  * transaction, so a concurrent claimant (second tab, reload nudge racing a
156
156
  * run end) can never observe — and send — the same item twice. Null when the
157
- * queue is empty. Synchronous by construction: `bun:sqlite` is sync, so the
158
- * BEGIN…COMMIT block cannot interleave with another request.
157
+ * queue is empty.
158
+ *
159
+ * Uses the ASYNC `getDb()` handle rather than `getDbSync()`, even though the
160
+ * transaction block itself is synchronous (`bun:sqlite` is sync, so BEGIN…COMMIT
161
+ * cannot interleave with another request and the atomicity above is unaffected
162
+ * by the await before it). `getDbSync()` throws when its module-local resolved
163
+ * handle is unset, which a `bun --hot` reload of `db.server.ts` does until some
164
+ * other request calls `getDb()` — and the delivery path runs from a timer, so
165
+ * nothing else necessarily would. That throw was swallowed by a fire-and-forget
166
+ * call and stranded the queue silently, with nudges failing the same way.
167
+ * `getDb()` re-initializes instead, so a delivery heals the state it needs.
159
168
  */
160
- export function claimHeadQueueItem(sessionId: string): QueuedMessage | null {
161
- const db = getDbSync();
169
+ export async function claimHeadQueueItem(sessionId: string): Promise<QueuedMessage | null> {
170
+ const db = await getDb();
162
171
  return withTransaction(db, () => {
163
172
  const row = db.raw
164
173
  .query('SELECT * FROM queued_messages WHERE session_id = ? ORDER BY position ASC LIMIT 1')
@@ -169,6 +178,22 @@ export function claimHeadQueueItem(sessionId: string): QueuedMessage | null {
169
178
  });
170
179
  }
171
180
 
181
+ /**
182
+ * Whether this session has anything queued, without claiming it.
183
+ *
184
+ * Used by the delivery retry to decide whether a retry is still worth arming:
185
+ * a busy session whose queue emptied (delivered elsewhere, or deleted by the
186
+ * user) must not leave a poll loop running. Async for the same reason the claim
187
+ * is — `getDb()` heals a hot-reloaded module where `getDbSync()` would throw.
188
+ */
189
+ export async function hasQueuedItem(sessionId: string): Promise<boolean> {
190
+ const db = await getDb();
191
+ const row = db.raw
192
+ .query('SELECT 1 AS present FROM queued_messages WHERE session_id = ? LIMIT 1')
193
+ .get(sessionId) as { present?: number } | undefined;
194
+ return row?.present === 1;
195
+ }
196
+
172
197
  /**
173
198
  * Put a failed delivery back at the head (position below the current minimum)
174
199
  * so it is retried before anything else. The next run end or mount nudge