ompchamber 1.0.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 (64) 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/index.6e065017eb.js +33 -0
  5. package/package.json +1 -1
  6. package/src/client/components/common/ComposerPicker.tsx +16 -4
  7. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  8. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  9. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  10. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  11. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  12. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  13. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  14. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  15. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  16. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  17. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  18. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +3 -0
  19. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  20. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  21. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  22. package/src/client/components/workspace/chat-timeline/index.tsx +3 -0
  23. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  24. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  25. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  26. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  27. package/src/client/hooks/chat/composer/index.ts +28 -8
  28. package/src/client/hooks/chat/timeline/actions.ts +24 -3
  29. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  30. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  31. package/src/client/hooks/chat/timeline/index.ts +13 -1
  32. package/src/client/hooks/chat/timeline/send.ts +16 -5
  33. package/src/client/hooks/workspace/available-width.ts +33 -0
  34. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  35. package/src/server/lib/models/preferences.server.ts +110 -0
  36. package/src/server/lib/models/registry.server.ts +206 -0
  37. package/src/server/lib/omp/rpc/utility.ts +7 -1
  38. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  39. package/src/server/lib/queue/delivery.server.ts +38 -8
  40. package/src/server/routes/models/root.ts +88 -215
  41. package/src/server/routes/settings/commands.ts +59 -18
  42. package/src/shared/lib/chat/composer/client.ts +17 -8
  43. package/src/shared/lib/chat/composer/commands.ts +290 -0
  44. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  45. package/src/shared/lib/chat/composer/filter.ts +21 -7
  46. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  47. package/src/shared/lib/models/preferences.test.ts +131 -0
  48. package/src/shared/lib/models/preferences.ts +88 -0
  49. package/src/shared/lib/models/provider-models.ts +8 -1
  50. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  51. package/src/shared/lib/omp/subagent/label.ts +84 -0
  52. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  53. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  54. package/src/shared/lib/workspace/right-panels.ts +50 -11
  55. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  56. package/src/shared/types/chat.ts +38 -65
  57. package/src/shared/types/index.ts +1 -0
  58. package/src/shared/types/models.ts +101 -0
  59. package/src/shared/types/omp/subagent.ts +4 -0
  60. package/src/shared/types/settings/command.ts +13 -0
  61. package/dist/client/static/css/index.e880517fcd.css +0 -2
  62. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  63. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  64. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Assembly of the chat model registry that `GET /api/models` serves: the omp
8
+ * registry (auth providers + models.yml) narrowed by the provider/model hides
9
+ * the operator set, plus the live session's default model.
10
+ *
11
+ * Extracted from `routes/models/root.ts` so the route keeps only its loader and
12
+ * action, and so the preference store can share the same model list.
13
+ */
14
+
15
+ import { getDb } from '@/server/db.server';
16
+ import { PROVIDERS_SETTINGS_KEY } from '@/server/lib/models/provider-registry.server';
17
+ import { readSettingsJson } from '@/server/lib/db/settings-store';
18
+ import { readDisabledProviders } from '@/server/lib/omp/config/roles';
19
+ import { runUtilityCommand, type OmpModel } from '@/server/lib/omp/rpc/utility';
20
+ import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
21
+ import type { ModelsData, ProviderItem } from '@/shared/types';
22
+
23
+ const MODELS_CACHE_TTL_MS = 60_000;
24
+ export const SAFE_MODEL_LOAD_FAILURE_MESSAGE = 'Model list is temporarily unavailable. Check your configuration and try again.';
25
+
26
+ const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
27
+
28
+ function compareModelEntries(a: { id: string; name: string; provider: string }, b: { id: string; name: string; provider: string }): number {
29
+ return modelNameCollator.compare(a.name || a.id, b.name || b.id)
30
+ || modelNameCollator.compare(a.provider, b.provider)
31
+ || modelNameCollator.compare(a.id, b.id);
32
+ }
33
+
34
+ // "off" is always a valid selector; the concrete efforts come from the model's
35
+ // baked thinking metadata (omp: getSupportedEfforts = reasoning ? efforts : []).
36
+ function thinkingLevelsFor(model: OmpModel): string[] {
37
+ if (!model.reasoning) return ['off'];
38
+ return ['off', ...(model.thinking?.efforts ?? [])];
39
+ }
40
+
41
+ function supportsFastMode(model: OmpModel): boolean {
42
+ return model.provider === 'anthropic' || model.provider === 'openai' || model.provider === 'google';
43
+ }
44
+
45
+ /**
46
+ * Disabled providers recorded in the chamber's own provider overlay. Used only
47
+ * by the MOCK path of `/api/models`, which has no config.yml `disabledProviders`
48
+ * to read — real mode gets the same answer natively.
49
+ */
50
+ function isDisabledStoredProvider(value: unknown): value is ProviderItem {
51
+ if (typeof value !== 'object' || value === null) return false;
52
+ const candidate = value as ProviderItem;
53
+ return typeof candidate.slug === 'string' && candidate.disabled === true;
54
+ }
55
+
56
+ export async function readStoredProvidersForModels(): Promise<ProviderItem[]> {
57
+ try {
58
+ const db = await getDb();
59
+ const parsed = await readSettingsJson<unknown>(db, PROVIDERS_SETTINGS_KEY, null);
60
+ if (!Array.isArray(parsed)) return [];
61
+ return parsed.filter(isDisabledStoredProvider);
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ function hiddenModelKey(provider: string, modelId: string): string {
68
+ return `${provider.trim().toLowerCase()}:${modelId}`;
69
+ }
70
+
71
+ async function readHiddenModelKeys(): Promise<Set<string>> {
72
+ try {
73
+ const db = await getDb();
74
+ const parsed = await readSettingsJson<unknown>(db, PROVIDERS_SETTINGS_KEY, null);
75
+ if (!Array.isArray(parsed)) return new Set();
76
+ const hidden = new Set<string>();
77
+ for (const provider of parsed) {
78
+ if (typeof provider !== 'object' || provider === null) continue;
79
+ const { slug, models } = provider as { slug?: unknown; models?: unknown };
80
+ if (typeof slug !== 'string' || !Array.isArray(models)) continue;
81
+ for (const model of models) {
82
+ if (typeof model !== 'object' || model === null) continue;
83
+ const { id, isVisible } = model as { id?: unknown; isVisible?: unknown };
84
+ if (typeof id === 'string' && isVisible === false) hidden.add(hiddenModelKey(slug, id));
85
+ }
86
+ }
87
+ return hidden;
88
+ } catch {
89
+ return new Set();
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A model is offered only when its provider is neither disabled in omp's
95
+ * config.yml (`disabledProviders`) nor carrying a per-model hide in the
96
+ * chamber overlay. Disabling the provider is the coarse switch — Settings →
97
+ * Providers flips it so the whole provider leaves the chat picker.
98
+ */
99
+ function filterSelectableModels(
100
+ available: OmpModel[],
101
+ disabledProviders: Set<string>,
102
+ hiddenModelKeys: Set<string>,
103
+ ): OmpModel[] {
104
+ return available.filter((model) => (
105
+ !disabledProviders.has(model.provider)
106
+ && !hiddenModelKeys.has(hiddenModelKey(model.provider, model.id))
107
+ ));
108
+ }
109
+
110
+ function isOmpModelEntry(value: unknown): value is OmpModel {
111
+ if (typeof value !== 'object' || value === null) return false;
112
+ const candidate = value as OmpModel;
113
+ return typeof candidate.id === 'string' && typeof candidate.provider === 'string';
114
+ }
115
+
116
+ function isLoginProvider(value: unknown): value is { id: string; name: string; authenticated: boolean } {
117
+ if (typeof value !== 'object' || value === null) return false;
118
+ const candidate = value as { id?: unknown; name?: unknown; authenticated?: unknown };
119
+ return typeof candidate.id === 'string'
120
+ && typeof candidate.name === 'string'
121
+ && typeof candidate.authenticated === 'boolean';
122
+ }
123
+
124
+ async function loadModels(): Promise<ModelsData> {
125
+ const availableResponse = await runUtilityCommand<{ models?: unknown }>(
126
+ { type: 'get_available_models' },
127
+ 120_000,
128
+ );
129
+ const available = Array.isArray(availableResponse.models)
130
+ ? availableResponse.models
131
+ .filter(isOmpModelEntry)
132
+ .map((model) => ({
133
+ ...model,
134
+ name: typeof model.name === 'string' && model.name.trim().length > 0 ? model.name : model.id,
135
+ }))
136
+ : [];
137
+
138
+ const disabledProviders = await readDisabledProviders();
139
+ const hiddenModelKeys = await readHiddenModelKeys();
140
+ const nameMap: Record<string, string> = {};
141
+ const thinkingLevels: Record<string, string[]> = {};
142
+ const modelList = filterSelectableModels(available, disabledProviders, hiddenModelKeys)
143
+ .map((m) => ({
144
+ id: m.id,
145
+ name: m.name,
146
+ provider: m.provider,
147
+ thinkingLevels: thinkingLevelsFor(m),
148
+ supportsFastMode: supportsFastMode(m),
149
+ contextWindow: m.contextWindow ?? undefined,
150
+ maxTokens: m.maxTokens ?? undefined,
151
+ cost: m.cost,
152
+ }))
153
+ .sort(compareModelEntries);
154
+
155
+ const loginResponse = await runUtilityCommand<{ providers?: unknown }>(
156
+ { type: 'get_login_providers' },
157
+ 30_000,
158
+ );
159
+ const loginProviders = Array.isArray(loginResponse.providers)
160
+ ? loginResponse.providers.filter(isLoginProvider)
161
+ : [];
162
+ const connectedProviders = loginProviders
163
+ .filter((provider) => provider.authenticated)
164
+ .map((provider) => ({ id: provider.id, name: provider.name, disabled: disabledProviders.has(provider.id) }));
165
+ for (const m of available) {
166
+ const key = `${m.provider}:${m.id}`;
167
+ nameMap[key] = m.name;
168
+ thinkingLevels[key] = thinkingLevelsFor(m);
169
+ }
170
+
171
+ let defaultModel: { provider: string; modelId: string } | null = null;
172
+ try {
173
+ const state = await runUtilityCommand<{ model?: { provider?: string; id?: string } }>(
174
+ { type: 'get_state' },
175
+ 30_000,
176
+ );
177
+ const provider = state.model?.provider;
178
+ const modelId = state.model?.id;
179
+ if (provider && modelId && available.some((m) => m.provider === provider && m.id === modelId)) {
180
+ defaultModel = { provider, modelId };
181
+ }
182
+ } catch {
183
+ // Default model is cosmetic — the models list is still useful without it.
184
+ }
185
+
186
+ return { models: nameMap, modelList, defaultModel, thinkingLevels, connectedProviders };
187
+ }
188
+
189
+ export const EMPTY_MODELS: ModelsData = {
190
+ models: {},
191
+ modelList: [],
192
+ defaultModel: null,
193
+ thinkingLevels: {},
194
+ };
195
+
196
+ export async function loadModelsWithCache(): Promise<ModelsData> {
197
+ const cached = globalThis.__ompChamberModelsCache;
198
+ if (cached && cached.expiresAt > Date.now()) return cached.data;
199
+ const data = await loadModels();
200
+ globalThis.__ompChamberModelsCache = { data, expiresAt: Date.now() + MODELS_CACHE_TTL_MS };
201
+ return data;
202
+ }
203
+
204
+ export function invalidateModelsCache(): void {
205
+ invalidateModelsCaches();
206
+ }
@@ -22,7 +22,13 @@ import { RpcProcess } from '@/server/lib/omp/rpc/process';
22
22
 
23
23
  // Extensions stay ENABLED: they can register models and login providers, and
24
24
  // omitting them made the web UI's model/provider lists disagree with the CLI's.
25
- const UTILITY_EXTRA_ARGS = ['--no-session', '--no-skills', '--no-lsp'];
25
+ //
26
+ // Skills stay ENABLED too. `--no-skills` made `get_available_commands` omit
27
+ // every `skill:<name>` entry (measured: 45 commands without, 52 with), so the
28
+ // composer's `/` popup could not offer the skills omp happily executes — and
29
+ // the chamber's own disk scan missed provider roots omp reads (`~/.agents/skills`).
30
+ // Cost is nil: the ready frame lands at the same ~420ms either way.
31
+ const UTILITY_EXTRA_ARGS = ['--no-session', '--no-lsp'];
26
32
  const READY_TIMEOUT_MS = 60_000;
27
33
  // Longer than the 60s models-cache TTL on purpose: with idle-kill == TTL every
28
34
  // pause past a minute paid a cold multi-second respawn on top of the stale
@@ -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
+ });
@@ -28,16 +28,36 @@ import type { QueuedMessage } from '@/shared/types/chat';
28
28
  * the same child, and an instant dispatch races it. */
29
29
  const DELIVERY_DELAY_MS = 500;
30
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
+
31
52
  /** Per-session pending delivery timer, so a second agent_end while one is
32
53
  * already scheduled cannot double-book the head. */
33
- const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
54
+ const pendingTimers = new Map<string, PendingDelivery>();
34
55
 
35
56
  function clearPendingTimer(sessionId: string): void {
36
- const timer = pendingTimers.get(sessionId);
37
- if (timer !== undefined) {
38
- clearTimeout(timer);
39
- pendingTimers.delete(sessionId);
40
- }
57
+ const entry = pendingTimers.get(sessionId);
58
+ if (entry === undefined) return;
59
+ clearTimeout(entry.timer);
60
+ pendingTimers.delete(sessionId);
41
61
  }
42
62
 
43
63
  /** Send one queued item to the session as a normal prompt. The model snapshot
@@ -79,15 +99,25 @@ export interface QueueDeliveryHost {
79
99
  * Schedule the next queued delivery for this session. Called on terminal
80
100
  * agent_end and on a client's mount nudge. Idempotent: an in-flight timer is
81
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.
82
106
  */
83
107
  export function scheduleQueueDelivery(session: QueueDeliveryHost): void {
84
108
  const sessionId = session.sessionId;
85
- if (!sessionId || pendingTimers.has(sessionId)) return;
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
+ }
86
116
  const timer = setTimeout(() => {
87
117
  pendingTimers.delete(sessionId);
88
118
  void deliverNext(session);
89
119
  }, DELIVERY_DELAY_MS);
90
- pendingTimers.set(sessionId, timer);
120
+ pendingTimers.set(sessionId, { timer, expiresAt: Date.now() + DELIVERY_STALE_MS });
91
121
  }
92
122
 
93
123
  async function deliverNext(session: QueueDeliveryHost): Promise<void> {