ompchamber 0.8.0 → 1.1.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 (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b859cc1cc5.css +2 -0
  3. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
  4. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  5. package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
  6. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  7. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
  8. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  9. package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
  10. package/dist/client/static/js/index.6e065017eb.js +33 -0
  11. package/package.json +2 -1
  12. package/src/client/components/common/ComposerPicker.tsx +16 -4
  13. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  14. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  15. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  16. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  17. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  18. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  19. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  20. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  21. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
  22. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  23. package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
  24. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  25. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  26. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  29. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
  30. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  31. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  32. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  33. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  34. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  36. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  37. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  38. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  39. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  40. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  41. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  42. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  43. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  44. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  45. package/src/client/data/theme/terminal.ts +27 -29
  46. package/src/client/hooks/chat/composer/index.ts +28 -8
  47. package/src/client/hooks/chat/omp/session-list.tsx +19 -2
  48. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  49. package/src/client/hooks/chat/timeline/actions.ts +47 -28
  50. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  51. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  52. package/src/client/hooks/chat/timeline/index.ts +25 -21
  53. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  54. package/src/client/hooks/chat/timeline/send.ts +16 -5
  55. package/src/client/hooks/workspace/available-width.ts +33 -0
  56. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  57. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  58. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  59. package/src/server/db.server.ts +19 -0
  60. package/src/server/lib/fs/git-sync.ts +191 -0
  61. package/src/server/lib/models/preferences.server.ts +110 -0
  62. package/src/server/lib/models/registry.server.ts +206 -0
  63. package/src/server/lib/omp/rpc/manager.ts +58 -3
  64. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  65. package/src/server/lib/omp/rpc/utility.ts +7 -1
  66. package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
  67. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  68. package/src/server/lib/queue/delivery.server.ts +144 -0
  69. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  70. package/src/server/lib/queue/store.server.ts +185 -0
  71. package/src/server/lib/terminal/foreground.ts +73 -0
  72. package/src/server/lib/terminal/history.test.ts +118 -0
  73. package/src/server/lib/terminal/history.ts +205 -0
  74. package/src/server/lib/terminal/runtime.server.ts +338 -0
  75. package/src/server/lib/terminal/session.server.ts +215 -0
  76. package/src/server/lib/terminal/shell.test.ts +97 -0
  77. package/src/server/lib/terminal/shell.ts +103 -0
  78. package/src/server/routes/fs/git.ts +22 -12
  79. package/src/server/routes/index.ts +2 -1
  80. package/src/server/routes/models/root.ts +88 -215
  81. package/src/server/routes/sessions/index.ts +11 -3
  82. package/src/server/routes/sessions/queue.ts +121 -0
  83. package/src/server/routes/sessions/session.ts +1 -132
  84. package/src/server/routes/settings/commands.ts +59 -18
  85. package/src/server/routes/terminal/index.ts +2 -2
  86. package/src/server/routes/terminal/run.ts +8 -100
  87. package/src/server/routes/terminal/sessions.ts +24 -0
  88. package/src/server/routes/terminal/ws.ts +149 -0
  89. package/src/shared/lib/chat/composer/client.ts +17 -8
  90. package/src/shared/lib/chat/composer/commands.ts +290 -0
  91. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  92. package/src/shared/lib/chat/composer/filter.ts +21 -7
  93. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  94. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  95. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  96. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  97. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  98. package/src/shared/lib/models/preferences.test.ts +131 -0
  99. package/src/shared/lib/models/preferences.ts +88 -0
  100. package/src/shared/lib/models/provider-models.ts +8 -1
  101. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  102. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  103. package/src/shared/lib/omp/subagent/label.ts +84 -0
  104. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  105. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  106. package/src/shared/lib/workspace/right-panels.ts +50 -11
  107. package/src/shared/lib/workspace/session-state/store.ts +4 -4
  108. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  109. package/src/shared/types/chat.ts +38 -65
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/models.ts +101 -0
  112. package/src/shared/types/omp/agent.ts +12 -0
  113. package/src/shared/types/omp/subagent.ts +4 -0
  114. package/src/shared/types/settings/command.ts +13 -0
  115. package/src/shared/types/terminal.ts +17 -15
  116. package/src/shared/types/workspace.ts +2 -2
  117. package/dist/client/static/css/index.5c0447cefe.css +0 -2
  118. package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
  119. package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
  120. package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
  121. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  122. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  123. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  124. package/src/client/hooks/workspace/terminal.ts +0 -283
  125. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  126. package/src/server/routes/terminal/stream.ts +0 -100
  127. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  128. /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
  129. /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  130. /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The follow-up queue must never wedge.
8
+ *
9
+ * A scheduled delivery whose timer never runs (a `bun --hot` module
10
+ * re-evaluation discards the pending closure; a wedged event loop drops the
11
+ * callback) used to poison the session permanently: the in-flight guard
12
+ * early-returned on the leftover entry, so no later trigger — another
13
+ * agent_end, a tab's mount nudge — could ever schedule again. The queue simply
14
+ * stopped draining, with no error anywhere. Observed live: a dev server whose
15
+ * `setTimeout` from request handlers had stopped firing left every queued
16
+ * follow-up stranded, while a freshly started server delivered the identical
17
+ * item correctly.
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.
23
+ */
24
+
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
+ });
80
+
81
+ afterEach(() => {
82
+ jest.useRealTimers();
83
+ });
84
+
85
+ afterAll(() => {
86
+ mock.restore();
87
+ });
88
+
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')]);
92
+ const { host, sent } = makeHost(sessionId);
93
+
94
+ scheduleQueueDelivery(host);
95
+ jest.advanceTimersByTime(600);
96
+ await flush();
97
+
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([]);
104
+ });
105
+
106
+ test("an 'auto' thinking level is left to the session", async () => {
107
+ queue.set(sessionId, [queueItem('no level', { ...MODEL, thinkingLevel: 'auto' })]);
108
+ const { host, sent } = makeHost(sessionId);
109
+
110
+ scheduleQueueDelivery(host);
111
+ jest.advanceTimersByTime(600);
112
+ await flush();
113
+
114
+ expect(sent).toEqual([
115
+ { type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
116
+ { type: 'prompt', message: 'no level', accessMode: 'yolo' },
117
+ ]);
118
+ });
119
+
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);
123
+
124
+ scheduleQueueDelivery(host);
125
+ jest.advanceTimersByTime(600);
126
+ await flush();
127
+
128
+ expect(sent).toEqual([{ type: 'prompt', message: 'session model' }]);
129
+ });
130
+
131
+ test('a busy session holds the item instead of sending it', async () => {
132
+ queue.set(sessionId, [queueItem('held')]);
133
+ const { host, sent } = makeHost(sessionId);
134
+ host.isBusy = () => true;
135
+
136
+ scheduleQueueDelivery(host);
137
+ jest.advanceTimersByTime(600);
138
+ await flush();
139
+
140
+ expect(sent).toEqual([]);
141
+ expect(queue.get(sessionId)?.length).toBe(1);
142
+ });
143
+
144
+ test('a failed dispatch returns the item to the head', async () => {
145
+ queue.set(sessionId, [queueItem('retry me')]);
146
+ const { host } = makeHost(sessionId);
147
+ host.send = async () => {
148
+ throw new Error('rpc down');
149
+ };
150
+
151
+ scheduleQueueDelivery(host);
152
+ jest.advanceTimersByTime(600);
153
+ await flush();
154
+
155
+ expect(queue.get(sessionId)?.map((i) => i.text)).toEqual(['retry me']);
156
+ });
157
+
158
+ test('repeated triggers deliver the head exactly once', async () => {
159
+ queue.set(sessionId, [queueItem('once')]);
160
+ const { host, sent } = makeHost(sessionId);
161
+
162
+ scheduleQueueDelivery(host);
163
+ scheduleQueueDelivery(host);
164
+ scheduleQueueDelivery(host);
165
+ jest.advanceTimersByTime(600);
166
+ await flush();
167
+
168
+ expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
169
+ expect(queue.get(sessionId)).toEqual([]);
170
+ });
171
+
172
+ test('a lost timer delays the queue instead of wedging it', async () => {
173
+ queue.set(sessionId, [queueItem('recovered')]);
174
+ const { host, sent } = makeHost(sessionId);
175
+
176
+ // A delivery was scheduled but its callback never ran — the entry is left
177
+ // behind, which is exactly what a hot-reloaded module or a wedged event
178
+ // loop leaves. `clearAllTimers` drops the pending callback WITHOUT running
179
+ // it, so the bookkeeping outlives the timer.
180
+ scheduleQueueDelivery(host);
181
+ jest.clearAllTimers();
182
+ jest.advanceTimersByTime(600);
183
+ await flush();
184
+ expect(sent).toEqual([]);
185
+
186
+ // Inside the staleness bound a new trigger still defers to the pending
187
+ // entry: the head must not be double-booked.
188
+ jest.advanceTimersByTime(1000);
189
+ scheduleQueueDelivery(host);
190
+ jest.advanceTimersByTime(600);
191
+ await flush();
192
+ expect(sent).toEqual([]);
193
+
194
+ // Past it, a later trigger recovers the queue rather than giving up.
195
+ jest.advanceTimersByTime(5000);
196
+ scheduleQueueDelivery(host);
197
+ jest.advanceTimersByTime(600);
198
+ await flush();
199
+
200
+ expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
201
+ expect(queue.get(sessionId)).toEqual([]);
202
+ });
203
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Server-side follow-up queue auto-delivery: the run's own `agent_end` — as
8
+ * observed by the session wrapper, the process that owns the omp child —
9
+ * triggers the next queued prompt. This replaces the client-driven
10
+ * auto-delivery whose state races (tab reload wiping the table, session
11
+ * switches sending a queue into the wrong session) made queued messages
12
+ * disappear or land in the wrong conversation.
13
+ *
14
+ * Semantics:
15
+ * - The head is CLAIMED (transactional DELETE) before dispatch, so a second
16
+ * trigger — another tab's nudge, a racing run end — can never double-send.
17
+ * - A failed dispatch re-inserts the item at the head; nothing is lost.
18
+ * - A user Stop clears the pending-delivery timer instead of delivering (the
19
+ * item stays claimed-but-returned... it is re-queued first), so stop-all
20
+ * semantics live on the server now.
21
+ */
22
+
23
+ import { claimHeadQueueItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
24
+ import type { QueuedMessage } from '@/shared/types/chat';
25
+
26
+ /** Settling time between agent_end and the next prompt dispatch. Small on
27
+ * purpose: omp's own turn-end bookkeeping (JSONL flush, title slot) runs on
28
+ * the same child, and an instant dispatch races it. */
29
+ const DELIVERY_DELAY_MS = 500;
30
+
31
+ /**
32
+ * How long a scheduled-but-not-yet-run delivery may sit before the next
33
+ * trigger supersedes it.
34
+ *
35
+ * Without this, one lost timer wedges the session's queue FOREVER: the guard
36
+ * below early-returns on any existing entry, so nothing would ever schedule
37
+ * again. A timer can be lost without the process dying — `bun --hot`
38
+ * re-evaluating this module discards the pending closure, and a wedged event
39
+ * loop drops the callback — and the symptom is silent: the queue simply never
40
+ * drains and no error is reported. Past this bound the entry is presumed lost
41
+ * and rescheduled, so a client nudge (sent whenever a session with queued
42
+ * items mounts or regains focus) always recovers the queue.
43
+ */
44
+ const DELIVERY_STALE_MS = 5_000;
45
+
46
+ interface PendingDelivery {
47
+ timer: ReturnType<typeof setTimeout>;
48
+ /** Wall-clock after which this entry is presumed lost and superseded. */
49
+ expiresAt: number;
50
+ }
51
+
52
+ /** Per-session pending delivery timer, so a second agent_end while one is
53
+ * already scheduled cannot double-book the head. */
54
+ const pendingTimers = new Map<string, PendingDelivery>();
55
+
56
+ function clearPendingTimer(sessionId: string): void {
57
+ const entry = pendingTimers.get(sessionId);
58
+ if (entry === undefined) return;
59
+ clearTimeout(entry.timer);
60
+ pendingTimers.delete(sessionId);
61
+ }
62
+
63
+ /** Send one queued item to the session as a normal prompt. The model snapshot
64
+ * rides first (set_model / set_thinking_level) exactly like the composer's
65
+ * queued delivery did. Returns false when the dispatch failed. */
66
+ async function dispatchQueuedItem(session: QueueDeliveryHost, item: QueuedMessage): Promise<boolean> {
67
+ try {
68
+ if (item.model) {
69
+ await session.send({ type: 'set_model', provider: item.model.provider, modelId: item.model.modelId });
70
+ if (item.model.thinkingLevel !== 'auto') {
71
+ await session.send({ type: 'set_thinking_level', level: item.model.thinkingLevel });
72
+ }
73
+ }
74
+ const images = item.attachments
75
+ .filter((a) => typeof a?.preview === 'string' && a.preview.startsWith('data:image/') && typeof a?.dataBase64 === 'string')
76
+ .map((a) => ({ type: 'image' as const, data: a.dataBase64 as string, mimeType: a.preview.slice(5).split(';')[0] || 'image/png' }));
77
+ await session.send({
78
+ type: 'prompt',
79
+ message: item.text,
80
+ ...(images.length ? { images } : {}),
81
+ ...(item.model ? { accessMode: item.model.accessMode } : {}),
82
+ });
83
+ return true;
84
+ } catch (error) {
85
+ console.error(`[queue] delivery failed for session ${session.sessionId}:`, error);
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /** Minimal surface delivery needs from a live session wrapper. */
91
+ export interface QueueDeliveryHost {
92
+ sessionId: string;
93
+ isAlive(): boolean;
94
+ isBusy(): boolean;
95
+ send(command: Record<string, unknown>): Promise<unknown>;
96
+ }
97
+
98
+ /**
99
+ * Schedule the next queued delivery for this session. Called on terminal
100
+ * agent_end and on a client's mount nudge. Idempotent: an in-flight timer is
101
+ * reused, a claimed head can only be won by one caller.
102
+ *
103
+ * A pending entry older than {@link DELIVERY_STALE_MS} is presumed lost (see
104
+ * that constant) and replaced, so a dropped timer delays the queue instead of
105
+ * wedging it.
106
+ */
107
+ export function scheduleQueueDelivery(session: QueueDeliveryHost): void {
108
+ const sessionId = session.sessionId;
109
+ if (!sessionId) return;
110
+ const pending = pendingTimers.get(sessionId);
111
+ if (pending !== undefined) {
112
+ if (Date.now() < pending.expiresAt) return;
113
+ // Lost timer: clear it so its callback cannot also fire and double-deliver.
114
+ clearTimeout(pending.timer);
115
+ }
116
+ const timer = setTimeout(() => {
117
+ pendingTimers.delete(sessionId);
118
+ void deliverNext(session);
119
+ }, DELIVERY_DELAY_MS);
120
+ pendingTimers.set(sessionId, { timer, expiresAt: Date.now() + DELIVERY_STALE_MS });
121
+ }
122
+
123
+ async function deliverNext(session: QueueDeliveryHost): Promise<void> {
124
+ const sessionId = session.sessionId;
125
+ if (!session.isAlive() || session.isBusy()) return;
126
+ const item = claimHeadQueueItem(sessionId);
127
+ if (!item) return;
128
+ const ok = await dispatchQueuedItem(session, item);
129
+ if (!ok) await requeueHeadQueueItem(sessionId, item);
130
+ }
131
+
132
+ /**
133
+ * A user Stop: return anything already claimed for this session's pending
134
+ * delivery to the queue and cancel the timer. The item keeps its head
135
+ * position; nothing is sent until the next run end or explicit send.
136
+ */
137
+ export function cancelQueuedDelivery(sessionId: string): void {
138
+ clearPendingTimer(sessionId);
139
+ }
140
+
141
+ /** Forget a session's timer state (process teardown, session destroy). */
142
+ export function forgetQueuedDelivery(sessionId: string): void {
143
+ clearPendingTimer(sessionId);
144
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Schema migration for `queued_messages`: installs older databases created the
8
+ * table with an FK to `sessions`, but omp session ids have no row there —
9
+ * every queue insert for a real session failed with FOREIGN KEY constraint
10
+ * failed. Rebuilds the table without the FK, once, marker-keyed in
11
+ * `app_settings` (same pattern as project-settings-migration).
12
+ */
13
+
14
+ import type { DbClient } from '@/server/lib/db/client';
15
+
16
+ const QUEUE_FK_MARKER = 'queue_fk_removed';
17
+
18
+ const QUEUE_TABLE_SQL = `
19
+ CREATE TABLE queued_messages_new (
20
+ id TEXT PRIMARY KEY,
21
+ session_id TEXT NOT NULL,
22
+ position INTEGER NOT NULL,
23
+ message TEXT NOT NULL DEFAULT '',
24
+ attachments TEXT NOT NULL DEFAULT '[]',
25
+ provider TEXT,
26
+ model_id TEXT,
27
+ thinking_level TEXT,
28
+ access_mode TEXT,
29
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
30
+ );
31
+ INSERT INTO queued_messages_new
32
+ (id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode, created_at)
33
+ SELECT id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode, created_at
34
+ FROM queued_messages;
35
+ DROP TABLE queued_messages;
36
+ ALTER TABLE queued_messages_new RENAME TO queued_messages;
37
+ `;
38
+
39
+ /** Drop the invalid queue FK on pre-migration databases (idempotent). */
40
+ export async function migrateQueueTableFk(db: DbClient): Promise<void> {
41
+ const marker = await db.get('SELECT value FROM app_settings WHERE key = ?', [QUEUE_FK_MARKER]);
42
+ if (marker?.value) return;
43
+
44
+ const table = await db.get(
45
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'queued_messages'",
46
+ );
47
+ const hasLegacyFk = Boolean(table?.sql) && String(table.sql).includes('FOREIGN KEY');
48
+ if (hasLegacyFk) await db.exec(QUEUE_TABLE_SQL);
49
+ await db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', [QUEUE_FK_MARKER, '1']);
50
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * SQLite-backed follow-up queue store (`queued_messages` table).
8
+ *
9
+ * The server is the single source of truth: the client panel is a view that
10
+ * mutates through per-item operations, and every mutation returns the
11
+ * canonical queue so callers reconcile from the response instead of merging
12
+ * local state. Rows carry a model snapshot (provider, model_id,
13
+ * thinking_level, access_mode) so auto-delivery replays the settings the item
14
+ * was queued with.
15
+ *
16
+ * Delivery claims the head with a transactional DELETE (bun:sqlite is
17
+ * synchronous, so BEGIN…COMMIT cannot interleave with another request), which
18
+ * makes "exactly one consumer sends the head" hold across tabs, reloads and
19
+ * concurrent nudges. A failed send re-inserts the claimed row at the head.
20
+ */
21
+
22
+ import { getDb, getDbSync } from '@/server/db.server';
23
+ import { withTransaction } from '@/shared/lib/db/transaction.server';
24
+ import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
25
+ import type { QueuedMessage, QueuedMessageModel } from '@/shared/types/chat';
26
+
27
+ interface QueueRow {
28
+ id: string;
29
+ session_id: string;
30
+ position: number;
31
+ message: string;
32
+ attachments: string;
33
+ provider: string | null;
34
+ model_id: string | null;
35
+ thinking_level: string | null;
36
+ access_mode: string | null;
37
+ }
38
+
39
+ export function rowToQueuedMessage(row: QueueRow): QueuedMessage {
40
+ let attachments: unknown = [];
41
+ try {
42
+ attachments = JSON.parse(row.attachments);
43
+ } catch {
44
+ attachments = [];
45
+ }
46
+ const model: QueuedMessageModel | null =
47
+ row.provider && row.model_id
48
+ ? {
49
+ provider: row.provider,
50
+ modelId: row.model_id,
51
+ thinkingLevel: row.thinking_level ?? 'auto',
52
+ accessMode: isApprovalMode(row.access_mode) ? row.access_mode : 'always-ask',
53
+ }
54
+ : null;
55
+ return {
56
+ id: row.id,
57
+ text: row.message,
58
+ attachments: Array.isArray(attachments) ? (attachments as QueuedMessage['attachments']) : [],
59
+ model,
60
+ };
61
+ }
62
+
63
+ export function normalizeModel(raw: unknown): QueuedMessageModel | null {
64
+ if (!raw || typeof raw !== 'object') return null;
65
+ const m = raw as Record<string, unknown>;
66
+ if (typeof m.provider !== 'string' || typeof m.modelId !== 'string') return null;
67
+ return {
68
+ provider: m.provider,
69
+ modelId: m.modelId,
70
+ thinkingLevel: typeof m.thinkingLevel === 'string' ? m.thinkingLevel : 'auto',
71
+ accessMode: isApprovalMode(m.accessMode) ? m.accessMode : 'always-ask',
72
+ };
73
+ }
74
+
75
+ function serializeAttachments(attachments: unknown): string {
76
+ return JSON.stringify(Array.isArray(attachments) ? attachments : []);
77
+ }
78
+
79
+ interface QueueItemInput {
80
+ text: string;
81
+ attachments?: unknown;
82
+ model?: unknown;
83
+ }
84
+
85
+ /** The session's queued follow-ups in delivery order. */
86
+ export async function listQueue(sessionId: string): Promise<QueuedMessage[]> {
87
+ const db = await getDb();
88
+ const rows = (await db.all(
89
+ 'SELECT * FROM queued_messages WHERE session_id = ? ORDER BY position ASC',
90
+ [sessionId],
91
+ )) as QueueRow[];
92
+ return rows.map(rowToQueuedMessage);
93
+ }
94
+
95
+ /** Append one item; returns the canonical queue. */
96
+ export async function appendQueueItem(sessionId: string, input: QueueItemInput): Promise<QueuedMessage[]> {
97
+ const db = await getDb();
98
+ const model = normalizeModel(input.model);
99
+ const id = crypto.randomUUID();
100
+ db.raw.run(
101
+ `INSERT INTO queued_messages (id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode)
102
+ VALUES (?, ?, COALESCE((SELECT MAX(position) FROM queued_messages WHERE session_id = ?), -1) + 1, ?, ?, ?, ?, ?, ?)`,
103
+ [id, sessionId, sessionId, input.text, serializeAttachments(input.attachments),
104
+ model?.provider ?? null, model?.modelId ?? null, model?.thinkingLevel ?? null, model?.accessMode ?? null],
105
+ );
106
+ return listQueue(sessionId);
107
+ }
108
+
109
+ /** Patch one item's text/model; null when the id is unknown. */
110
+ export async function patchQueueItem(
111
+ sessionId: string,
112
+ id: string,
113
+ patch: { text?: unknown; model?: unknown },
114
+ ): Promise<QueuedMessage[] | null> {
115
+ const db = await getDb();
116
+ const sets: string[] = [];
117
+ const values: unknown[] = [];
118
+ if (typeof patch.text === 'string') {
119
+ sets.push('message = ?');
120
+ values.push(patch.text);
121
+ }
122
+ if (patch.model !== undefined) {
123
+ const model = normalizeModel(patch.model);
124
+ sets.push('provider = ?', 'model_id = ?', 'thinking_level = ?', 'access_mode = ?');
125
+ values.push(model?.provider ?? null, model?.modelId ?? null, model?.thinkingLevel ?? null, model?.accessMode ?? null);
126
+ }
127
+ if (sets.length === 0) return listQueue(sessionId);
128
+ const result = await db.run(
129
+ `UPDATE queued_messages SET ${sets.join(', ')} WHERE session_id = ? AND id = ?`,
130
+ [...values, sessionId, id],
131
+ );
132
+ if (!result.changes) return null;
133
+ return listQueue(sessionId);
134
+ }
135
+
136
+ /** Remove one item by id; false when it was already claimed or absent. */
137
+ export async function deleteQueueItem(sessionId: string, id: string): Promise<boolean> {
138
+ const db = await getDb();
139
+ const result = await db.run('DELETE FROM queued_messages WHERE session_id = ? AND id = ?', [sessionId, id]);
140
+ return result.changes > 0;
141
+ }
142
+
143
+ /** Reorder the queue to exactly `orderedIds` (unknown ids are ignored). */
144
+ export async function reorderQueue(sessionId: string, orderedIds: string[]): Promise<QueuedMessage[]> {
145
+ const db = await getDb();
146
+ withTransaction(db, () => {
147
+ const update = db.raw.query('UPDATE queued_messages SET position = ? WHERE session_id = ? AND id = ?');
148
+ orderedIds.forEach((id, index) => update.run(index, sessionId, id));
149
+ });
150
+ return listQueue(sessionId);
151
+ }
152
+
153
+ /**
154
+ * Atomically claim the head for delivery: read + delete inside one
155
+ * transaction, so a concurrent claimant (second tab, reload nudge racing a
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.
159
+ */
160
+ export function claimHeadQueueItem(sessionId: string): QueuedMessage | null {
161
+ const db = getDbSync();
162
+ return withTransaction(db, () => {
163
+ const row = db.raw
164
+ .query('SELECT * FROM queued_messages WHERE session_id = ? ORDER BY position ASC LIMIT 1')
165
+ .get(sessionId) as QueueRow | undefined;
166
+ if (!row) return null;
167
+ db.raw.run('DELETE FROM queued_messages WHERE id = ?', [row.id]);
168
+ return rowToQueuedMessage(row);
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Put a failed delivery back at the head (position below the current minimum)
174
+ * so it is retried before anything else. The next run end or mount nudge
175
+ * picks it up again.
176
+ */
177
+ export async function requeueHeadQueueItem(sessionId: string, item: QueuedMessage): Promise<void> {
178
+ const db = await getDb();
179
+ db.raw.run(
180
+ `INSERT INTO queued_messages (id, session_id, position, message, attachments, provider, model_id, thinking_level, access_mode)
181
+ VALUES (?, ?, COALESCE((SELECT MIN(position) FROM queued_messages WHERE session_id = ?), 0) - 1, ?, ?, ?, ?, ?, ?)`,
182
+ [item.id, sessionId, sessionId, item.text, serializeAttachments(item.attachments),
183
+ item.model?.provider ?? null, item.model?.modelId ?? null, item.model?.thinkingLevel ?? null, item.model?.accessMode ?? null],
184
+ );
185
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Is a shell busy, or sitting at its prompt?
8
+ *
9
+ * The PTY's foreground process group answers this exactly, and the kernel keeps
10
+ * it up to date: when a command runs in the foreground the terminal's `tpgid`
11
+ * becomes that command's group, and when the shell regains control `tpgid`
12
+ * returns to the shell's own pid. Verified on macOS against a real PTY —
13
+ * `sleep 30` moved `tpgid` off the shell's pid, `Ctrl+C` and a backgrounded job
14
+ * both left it on the shell's pid (a background job is by definition not the
15
+ * foreground job).
16
+ *
17
+ * Why it matters: the terminal cap counts *live* shells. "Live" has to mean
18
+ * "has work in flight", otherwise a panel that was opened, restarted, or
19
+ * switched a few times pins the budget with shells that are all idle at a
20
+ * prompt, and the next attach is rejected with nothing actually running.
21
+ *
22
+ * Cost is one `ps` call for the whole registry, so this is safe to run on every
23
+ * attach. It is advisory: any failure resolves to "not busy", because refusing
24
+ * a shell to save a slot is worse than letting one extra through.
25
+ */
26
+
27
+ const PS_TIMEOUT_MS = 1500;
28
+
29
+ /** `pid` is the shell's process (it is the PTY session leader, so pgid === pid). */
30
+ export interface ShellProcess {
31
+ id: string;
32
+ pid: number;
33
+ }
34
+
35
+ export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<string>> {
36
+ const busy = new Set<string>();
37
+ if (shells.length === 0 || process.platform === 'win32') return busy;
38
+
39
+ const byPid = new Map<number, string>();
40
+ for (const shell of shells) byPid.set(shell.pid, shell.id);
41
+
42
+ const ps = Bun.spawn(['ps', '-o', 'pid=,tpgid=', '-p', shells.map((s) => s.pid).join(',')], {
43
+ stdout: 'pipe',
44
+ stderr: 'ignore',
45
+ });
46
+
47
+ let output = '';
48
+ try {
49
+ output = await Promise.race([
50
+ new Response(ps.stdout).text(),
51
+ Bun.sleep(PS_TIMEOUT_MS).then(() => ''),
52
+ ]);
53
+ } catch {
54
+ // The pipe died; fall through to the kill and the empty result.
55
+ }
56
+ try {
57
+ ps.kill('SIGKILL');
58
+ } catch {
59
+ // Already exited.
60
+ }
61
+ void ps.exited.catch(() => {});
62
+
63
+ for (const line of output.split('\n')) {
64
+ const [pidText, tpgidText] = line.trim().split(/\s+/);
65
+ const pid = Number(pidText);
66
+ const tpgid = Number(tpgidText);
67
+ if (!Number.isFinite(pid) || !Number.isFinite(tpgid)) continue;
68
+ const id = byPid.get(pid);
69
+ // The shell holds the terminal exactly when it is its own foreground group.
70
+ if (id !== undefined && tpgid !== pid) busy.add(id);
71
+ }
72
+ return busy;
73
+ }