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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b859cc1cc5.css +2 -0
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +2 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/composer/index.ts +28 -8
- package/src/client/hooks/chat/omp/session-list.tsx +19 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +47 -28
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +25 -21
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +16 -5
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/manager.ts +58 -3
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +144 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/composer/client.ts +17 -8
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +157 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +109 -20
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +4 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/chat.ts +38 -65
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +12 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +2 -2
- package/dist/client/static/css/index.5c0447cefe.css +0 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
- package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
- package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /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
|
+
}
|