@kabmo-labs/kabmo 0.3.1 → 0.4.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/index.js +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/RightSidebar.tsx +8 -5
- package/src/client/components/mobile/SessionSidebar.tsx +6 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
package/src/server/db.server.ts
CHANGED
|
@@ -41,6 +41,19 @@ interface DbSlot {
|
|
|
41
41
|
promise: Promise<DbClient> | null;
|
|
42
42
|
/** Resolved handle for `getDbSync`; hot paths must not await. */
|
|
43
43
|
resolved: DbClient | null;
|
|
44
|
+
/**
|
|
45
|
+
* Set when a caller retired this slot while an open was still in flight.
|
|
46
|
+
*
|
|
47
|
+
* An open is asynchronous (`getDatabasePath` → `createDb` → `initSchema`), so
|
|
48
|
+
* a teardown that clears the slot mid-open does not stop it: the continuation
|
|
49
|
+
* used to keep running and assign `state.resolved` on the DROPPED slot object,
|
|
50
|
+
* leaving an open `bun:sqlite` handle nothing could ever close. On Windows
|
|
51
|
+
* that is a locked temp directory — measured on the Release gate, where
|
|
52
|
+
* `releaseDb` failed with `EBUSY … a handle outlived its test` and reported
|
|
53
|
+
* the two preceding cases as failed. The continuation now closes the handle
|
|
54
|
+
* it opened and rejects instead of caching it.
|
|
55
|
+
*/
|
|
56
|
+
disposed: boolean;
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
declare global {
|
|
@@ -49,7 +62,30 @@ declare global {
|
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
function slot(): DbSlot {
|
|
52
|
-
return (globalThis.__kabmoDb ??= { promise: null, resolved: null });
|
|
65
|
+
return (globalThis.__kabmoDb ??= { promise: null, resolved: null, disposed: false });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Retire the current handle so the next `getDb()` opens the path configured
|
|
70
|
+
* NOW, closing whatever is open.
|
|
71
|
+
*
|
|
72
|
+
* The one caller is `test-support/isolated-db.ts`, but the rule it enforces is
|
|
73
|
+
* a lifecycle one: a handle must never outlive the configuration it was opened
|
|
74
|
+
* under. An in-flight open is marked `disposed` rather than awaited, and the
|
|
75
|
+
* continuation closes its own handle — see {@link DbSlot.disposed}.
|
|
76
|
+
*/
|
|
77
|
+
export function closeDb(): void {
|
|
78
|
+
const state = globalThis.__kabmoDb;
|
|
79
|
+
globalThis.__kabmoDb = undefined;
|
|
80
|
+
if (!state) return;
|
|
81
|
+
state.disposed = true;
|
|
82
|
+
try {
|
|
83
|
+
state.resolved?.raw.close();
|
|
84
|
+
} catch {
|
|
85
|
+
// Already closed by a failed open; the slot is gone either way.
|
|
86
|
+
}
|
|
87
|
+
state.resolved = null;
|
|
88
|
+
state.promise = null;
|
|
53
89
|
}
|
|
54
90
|
|
|
55
91
|
export async function getDatabasePath(): Promise<string> {
|
|
@@ -111,6 +147,18 @@ export async function getDb(): Promise<DbClient> {
|
|
|
111
147
|
|
|
112
148
|
await migrateLegacyProjectSettings(db);
|
|
113
149
|
|
|
150
|
+
// The slot may have been retired while this open was in flight. Assigning
|
|
151
|
+
// `state.resolved` then would cache a handle on an object nothing reaches
|
|
152
|
+
// any more — an open connection whose temp directory can never be deleted.
|
|
153
|
+
if (state.disposed) {
|
|
154
|
+
try {
|
|
155
|
+
db.raw.close();
|
|
156
|
+
} catch {
|
|
157
|
+
// Nothing to do: the caller already moved on.
|
|
158
|
+
}
|
|
159
|
+
throw new Error('database open was retired before it finished');
|
|
160
|
+
}
|
|
161
|
+
|
|
114
162
|
state.resolved = db;
|
|
115
163
|
return db;
|
|
116
164
|
})();
|
package/src/server/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { isMockMode } from '@/server/mock.server';
|
|
|
25
25
|
import { migrateLegacyDataDir } from '@/server/lib/lifecycle/paths';
|
|
26
26
|
import { stopDiscoveryRootsWatch, syncDiscoveryRootsWatch } from '@/server/lib/omp/config/roots-watch.server';
|
|
27
27
|
import { startScheduleRuntime, stopScheduleRuntime } from '@/server/lib/schedule/runtime.server';
|
|
28
|
+
import { startWaitingRuntime, stopWaitingRuntime } from '@/server/lib/waiting/runtime.server';
|
|
28
29
|
import { seedDefaultMarketplace } from '@/server/lib/panels/seed.server';
|
|
29
30
|
import { syncBundledPlugins } from '@/server/lib/panels/sync.server';
|
|
30
31
|
import { startPanelAutoUpdate } from '@/server/lib/panels/auto-update.server';
|
|
@@ -255,6 +256,13 @@ try {
|
|
|
255
256
|
*/
|
|
256
257
|
startScheduleRuntime();
|
|
257
258
|
|
|
259
|
+
/**
|
|
260
|
+
* The waiting-prompt clock: a prompt held until another session finishes. Same
|
|
261
|
+
* shape and the same placement — after the listener and the watchers, because
|
|
262
|
+
* the first sweep may spawn an omp child.
|
|
263
|
+
*/
|
|
264
|
+
startWaitingRuntime();
|
|
265
|
+
|
|
258
266
|
/**
|
|
259
267
|
* Bring installed BUNDLED plugins up to the copy this package ships.
|
|
260
268
|
*
|
|
@@ -320,6 +328,7 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
|
320
328
|
process.once(signal, () => {
|
|
321
329
|
stopDiscoveryRootsWatch();
|
|
322
330
|
stopScheduleRuntime();
|
|
331
|
+
stopWaitingRuntime();
|
|
323
332
|
void app.stop();
|
|
324
333
|
// Plugins own children and listeners nothing else releases, and a detached
|
|
325
334
|
// child outlives its parent — so this is awaited BEFORE the exit. It is
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { clearCompressedCache, compressedAsset, compressedCacheStats } from '@/server/lib/assets/compressed-cache.server';
|
|
9
|
+
|
|
10
|
+
describe('compressedAsset', () => {
|
|
11
|
+
test('computes once for a key and serves the same bytes afterwards', async () => {
|
|
12
|
+
clearCompressedCache();
|
|
13
|
+
let produced = 0;
|
|
14
|
+
const produce = async () => {
|
|
15
|
+
produced += 1;
|
|
16
|
+
return new Uint8Array([1, 2, 3]);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const first = await compressedAsset('k', produce);
|
|
20
|
+
const second = await compressedAsset('k', produce);
|
|
21
|
+
|
|
22
|
+
expect(produced).toBe(1);
|
|
23
|
+
expect(second).toEqual(first);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Two tabs opening a cold instance at the same moment would otherwise both
|
|
27
|
+
// compress a 10 MB bundle. The gate is a deferred promise, not a delay: the
|
|
28
|
+
// second caller must join the first while it is still unresolved.
|
|
29
|
+
test('shares one computation between concurrent callers', async () => {
|
|
30
|
+
clearCompressedCache();
|
|
31
|
+
const gate = Promise.withResolvers<void>();
|
|
32
|
+
let produced = 0;
|
|
33
|
+
const produce = async () => {
|
|
34
|
+
produced += 1;
|
|
35
|
+
await gate.promise;
|
|
36
|
+
return new Uint8Array([9]);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const first = compressedAsset('shared', produce);
|
|
40
|
+
const second = compressedAsset('shared', produce);
|
|
41
|
+
gate.resolve();
|
|
42
|
+
const [a, b] = await Promise.all([first, second]);
|
|
43
|
+
|
|
44
|
+
expect(produced).toBe(1);
|
|
45
|
+
expect(a).toEqual(b);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a different key is a different entry', async () => {
|
|
49
|
+
clearCompressedCache();
|
|
50
|
+
await compressedAsset('one', async () => new Uint8Array([1]));
|
|
51
|
+
await compressedAsset('two', async () => new Uint8Array([2]));
|
|
52
|
+
|
|
53
|
+
expect(compressedCacheStats().entries).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// An entry is keyed by the asset's ETag, so a rebuild is a new key and the
|
|
57
|
+
// old bytes are never asked for again. Retention is bounded by count.
|
|
58
|
+
test('retains a bounded number of entries', async () => {
|
|
59
|
+
clearCompressedCache();
|
|
60
|
+
for (let i = 0; i < 20; i += 1) {
|
|
61
|
+
await compressedAsset(`key-${i}`, async () => new Uint8Array(1024));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stats = compressedCacheStats();
|
|
65
|
+
expect(stats.entries).toBeLessThanOrEqual(8);
|
|
66
|
+
expect(stats.bytes).toBeLessThanOrEqual(48 * 1024 * 1024);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The caller needs the bytes it asked for even when they cannot be kept, or a
|
|
70
|
+
// single asset larger than the budget would fail to serve at all.
|
|
71
|
+
test('returns an asset larger than the whole budget', async () => {
|
|
72
|
+
clearCompressedCache();
|
|
73
|
+
const huge = new Uint8Array(50 * 1024 * 1024);
|
|
74
|
+
const bytes = await compressedAsset('huge', async () => huge);
|
|
75
|
+
|
|
76
|
+
expect(bytes.byteLength).toBe(huge.byteLength);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compressed bytes for a static asset, kept across requests.
|
|
8
|
+
*
|
|
9
|
+
* `plugins/compress.ts` compresses per response, which is right for a 4 KB JSON
|
|
10
|
+
* body and wrong for the client bundle: the production bundle is 10.4 MB, and
|
|
11
|
+
* re-compressing it on every cold request costs ~20 ms of blocked event loop
|
|
12
|
+
* for a result that cannot have changed. Measured on this repo's own bundle:
|
|
13
|
+
* zstd 10.4 MB -> 2.15 MB in 20 ms, gzip -> 2.32 MB in 66 ms.
|
|
14
|
+
*
|
|
15
|
+
* The key is the asset's own ETag plus the encoding, so an entry is valid
|
|
16
|
+
* exactly as long as the bytes it was made from — a rebuild changes the ETag
|
|
17
|
+
* (size and mtime both move) and the old entry simply stops being asked for.
|
|
18
|
+
* That also means no invalidation logic: the map is bounded, and a stale entry
|
|
19
|
+
* is evicted by age, never served.
|
|
20
|
+
*
|
|
21
|
+
* Two bounds, because a bundle-sized entry is not a small one: an entry count
|
|
22
|
+
* and a total byte budget. A single entry larger than the budget is still
|
|
23
|
+
* RETURNED — it just is not retained — since the caller needs the bytes it
|
|
24
|
+
* asked for either way.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Encodings' worth of the biggest asset, plus room for the next build. */
|
|
28
|
+
const MAX_ENTRIES = 8;
|
|
29
|
+
const MAX_TOTAL_BYTES = 48 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const entries = new Map<string, Uint8Array>();
|
|
32
|
+
const inFlight = new Map<string, Promise<Uint8Array>>();
|
|
33
|
+
let totalBytes = 0;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The compressed bytes for `key`, computing them once.
|
|
37
|
+
*
|
|
38
|
+
* Concurrent requests for the same key share one computation: two tabs opening
|
|
39
|
+
* a cold instance at the same moment would otherwise both compress 10 MB.
|
|
40
|
+
*/
|
|
41
|
+
export async function compressedAsset(key: string, produce: () => Promise<Uint8Array>): Promise<Uint8Array> {
|
|
42
|
+
const hit = entries.get(key);
|
|
43
|
+
if (hit) {
|
|
44
|
+
// Re-insert so the map's own order stays least-recently-used first.
|
|
45
|
+
entries.delete(key);
|
|
46
|
+
entries.set(key, hit);
|
|
47
|
+
return hit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pending = inFlight.get(key);
|
|
51
|
+
if (pending) return pending;
|
|
52
|
+
|
|
53
|
+
const work = produce().then((bytes) => {
|
|
54
|
+
retain(key, bytes);
|
|
55
|
+
return bytes;
|
|
56
|
+
}).finally(() => {
|
|
57
|
+
inFlight.delete(key);
|
|
58
|
+
});
|
|
59
|
+
inFlight.set(key, work);
|
|
60
|
+
return work;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function retain(key: string, bytes: Uint8Array): void {
|
|
64
|
+
// Too big to keep. The caller already holds the reference, so the response is
|
|
65
|
+
// built from it; retaining it would evict everything else and then itself.
|
|
66
|
+
if (bytes.byteLength > MAX_TOTAL_BYTES) return;
|
|
67
|
+
entries.set(key, bytes);
|
|
68
|
+
totalBytes += bytes.byteLength;
|
|
69
|
+
// Evict oldest first, and never the entry just stored.
|
|
70
|
+
for (const [candidate, value] of entries) {
|
|
71
|
+
if (entries.size <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) break;
|
|
72
|
+
if (candidate === key) continue;
|
|
73
|
+
entries.delete(candidate);
|
|
74
|
+
totalBytes -= value.byteLength;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The retained byte count and entry count — the observable half of the cache. */
|
|
79
|
+
export function compressedCacheStats(): { entries: number; bytes: number } {
|
|
80
|
+
return { entries: entries.size, bytes: totalBytes };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Drop everything. Tests only; nothing at runtime has a reason to call this. */
|
|
84
|
+
export function clearCompressedCache(): void {
|
|
85
|
+
entries.clear();
|
|
86
|
+
inFlight.clear();
|
|
87
|
+
totalBytes = 0;
|
|
88
|
+
}
|
|
@@ -194,6 +194,7 @@ describe('schema bootstrap', () => {
|
|
|
194
194
|
'workspace_folders', 'sessions', 'app_settings', 'chat_sessions', 'deleted_workspaces',
|
|
195
195
|
'archived_sessions', 'session_ui_state', 'queued_messages', 'session_stream_state',
|
|
196
196
|
'files', 'btw_topics', 'btw_turns', 'scheduled_tasks', 'scheduled_task_runs',
|
|
197
|
+
'waiting_prompts',
|
|
197
198
|
]) {
|
|
198
199
|
expect(first).toContain(expected);
|
|
199
200
|
}
|
|
@@ -15,6 +15,7 @@ import type { DbClient } from '@/server/lib/db/client';
|
|
|
15
15
|
import { migrateQueueTableFk } from '@/server/lib/queue/schema-migration.server';
|
|
16
16
|
import { ensureBtwSchema } from '@/server/lib/btw/schema.server';
|
|
17
17
|
import { ensureScheduleSchema } from '@/server/lib/schedule/schema.server';
|
|
18
|
+
import { ensureWaitingSchema } from '@/server/lib/waiting/schema.server';
|
|
18
19
|
import { ensurePluginSchema } from '@/server/lib/plugins/schema.server';
|
|
19
20
|
import { migrateWorkspaceFolderColumns } from '@/shared/lib/workspace/schema-migrations';
|
|
20
21
|
import { migrateSessionStreamStateColumns } from '@/shared/lib/omp/session/schema-migrations';
|
|
@@ -104,6 +105,8 @@ export async function initSchema(db: DbClient): Promise<void> {
|
|
|
104
105
|
await ensureBtwSchema(db);
|
|
105
106
|
// Scheduled-task tables, same reasoning: their own module.
|
|
106
107
|
await ensureScheduleSchema(db);
|
|
108
|
+
// Prompts held until another session finishes, same reasoning.
|
|
109
|
+
await ensureWaitingSchema(db);
|
|
107
110
|
// The plugin platform's own tables (kv, records, settings).
|
|
108
111
|
await ensurePluginSchema(db);
|
|
109
112
|
|
|
@@ -121,7 +121,7 @@ describe('resolveRoot / resolveReferencedPath / getDefaultFsRoot', () => {
|
|
|
121
121
|
Bun.env.SYNC_WORKSPACE = 'false';
|
|
122
122
|
Bun.env.KABMO_DB_PATH = dbPath;
|
|
123
123
|
delete Bun.env.DB_PATH;
|
|
124
|
-
globalThis.__kabmoDb = { promise: null, resolved: null };
|
|
124
|
+
globalThis.__kabmoDb = { promise: null, resolved: null, disposed: false };
|
|
125
125
|
const db = await getDb();
|
|
126
126
|
await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', registeredDir]);
|
|
127
127
|
});
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type AutoTitleHost,
|
|
33
33
|
} from '@/server/lib/omp/session/auto-title.server';
|
|
34
34
|
import { scheduleQueueDelivery, type QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
|
|
35
|
+
import { requestWaitingSweep } from '@/server/lib/waiting/sweep-trigger';
|
|
35
36
|
import { markStreamStatus, loadStreamStatuses } from '@/shared/lib/omp/session/stream-state.server';
|
|
36
37
|
import { driveGoalAfterTurn } from '@/server/lib/omp/session/goal-driver.server';
|
|
37
38
|
import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
@@ -81,6 +82,12 @@ export function settleTerminalRun(host: TerminalSettleHost, messages: unknown):
|
|
|
81
82
|
void driveGoalAfterTurn({ host, messages });
|
|
82
83
|
scheduleQueueDelivery(host);
|
|
83
84
|
}
|
|
85
|
+
// A prompt held until this session finished is released HERE, and not only by
|
|
86
|
+
// the sweep tick: the run end is the event the wait was about. The delay is
|
|
87
|
+
// the settle grace — the stream row this frame just wrote needs to age past it
|
|
88
|
+
// before the dependency reads as finished, and a queued follow-up armed by the
|
|
89
|
+
// line above needs its window to go out first.
|
|
90
|
+
requestWaitingSweep();
|
|
84
91
|
// A run's work may have touched files and certainly advanced usage; the
|
|
85
92
|
// realtime layer republishes whichever workspace topics are actually watched
|
|
86
93
|
// (see `republishWatchedWorkspaceTopics`).
|
|
@@ -34,6 +34,7 @@ import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
|
34
34
|
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
35
35
|
import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
36
36
|
import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
37
|
+
import { failWaitingOn } from '@/server/lib/waiting/store.server';
|
|
37
38
|
import { siblingDirForSession } from '@/server/lib/omp/subagent/history/paths';
|
|
38
39
|
import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
|
|
39
40
|
|
|
@@ -122,6 +123,13 @@ export async function purgeSessionData(sessionId: string): Promise<SessionPurgeR
|
|
|
122
123
|
const db = await getDb();
|
|
123
124
|
const rowChanges = await purgeKabmoRows(db, sessionId);
|
|
124
125
|
|
|
126
|
+
// A prompt waiting on this session will never be released: the dependency is
|
|
127
|
+
// gone. Fail it BY NAME rather than letting it wait for a session that no
|
|
128
|
+
// longer exists — and keep the row, because its error text is the only thing
|
|
129
|
+
// that tells the user what happened to the prompt they wrote.
|
|
130
|
+
const strandedPrompts = await failWaitingOn(sessionId, 'The session being waited for was deleted.');
|
|
131
|
+
if (strandedPrompts > 0) emitRealtimeSignal('waiting-changed');
|
|
132
|
+
|
|
125
133
|
// Both caches outlive the mutation by their own keys: the file list/scan pair
|
|
126
134
|
// is mtime-keyed (a delete bumps the parent directory, but the memo can still
|
|
127
135
|
// answer first) and the sidebar dataset is TTL'd, so without this the deleted
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
25
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { tmpdir } from 'node:os';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
28
|
import { testPosix } from '@/test-support/platform';
|
|
@@ -39,6 +39,7 @@ import { getRpcSession, startRpcSession } from '@/server/lib/omp/rpc/session-reg
|
|
|
39
39
|
import { removeDir } from '@/test-support/remove-dir';
|
|
40
40
|
|
|
41
41
|
let root: string;
|
|
42
|
+
let holderLog = '';
|
|
42
43
|
let savedXdg: string | undefined;
|
|
43
44
|
let savedDbPath: string | undefined;
|
|
44
45
|
let savedAgentDir: string | undefined;
|
|
@@ -89,7 +90,12 @@ async function holdLeaseInChild(sessionId: string): Promise<number> {
|
|
|
89
90
|
// The background process must NOT inherit the wrapper's stdout pipe, or
|
|
90
91
|
// reading `$!` would block until that process exits.
|
|
91
92
|
const inner = `exec ${JSON.stringify(process.execPath)} -e ${shellQuote(script)} ${shellQuote(lease)}`;
|
|
92
|
-
|
|
93
|
+
// The holder's stderr goes to a FILE, not to `ignore`: when the child fails
|
|
94
|
+
// to take the lock (a missing libc, a broken FFI bind, a Bun that could not
|
|
95
|
+
// start), the reason is the only useful evidence, and discarding it is what
|
|
96
|
+
// made the Linux CI failure a bare "expected promise that rejects".
|
|
97
|
+
holderLog = join(root, 'holder.log');
|
|
98
|
+
const child = Bun.spawn(['sh', '-c', `{ ${inner} >>${shellQuote(holderLog)} 2>&1 & } ; echo $!`], {
|
|
93
99
|
stdout: 'pipe',
|
|
94
100
|
stderr: 'ignore',
|
|
95
101
|
env: { ...Bun.env, XDG_STATE_HOME: root, PI_CODING_AGENT_DIR: join(root, 'agent') },
|
|
@@ -104,10 +110,45 @@ function shellQuote(value: string): string {
|
|
|
104
110
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* How long a detached `sh -c` holder gets to take the lock.
|
|
115
|
+
*
|
|
116
|
+
* Polling real time is unavoidable here: the condition is an OS `flock` taken
|
|
117
|
+
* by ANOTHER process, which no fake timer can advance. The budget is generous
|
|
118
|
+
* on purpose — a cold CI runner spawns Bun and imports the FFI helper before it
|
|
119
|
+
* locks — and a miss is an ERROR, never a silent proceed.
|
|
120
|
+
*/
|
|
121
|
+
const FOREIGN_HOLDER_TIMEOUT_MS = 15_000;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Wait until a process other than this one actually holds the lease.
|
|
125
|
+
*
|
|
126
|
+
* THROWS rather than returning on timeout. It used to give up silently after
|
|
127
|
+
* 3 s, which turned an infrastructure gap into a green-looking test that proved
|
|
128
|
+
* nothing: `startRpcSession` then found no foreign owner, spawned normally, and
|
|
129
|
+
* the assertion below failed with "Expected promise that rejects" — measured on
|
|
130
|
+
* the Linux CI leg, where the helper burned its whole budget (3075 ms) and the
|
|
131
|
+
* real reason (the detached holder never took the lock) was nowhere in the
|
|
132
|
+
* output. A slow cold runner is a reason to wait longer, never to proceed.
|
|
133
|
+
*/
|
|
108
134
|
async function waitForForeignHolder(leasePath: string): Promise<void> {
|
|
109
|
-
|
|
135
|
+
const deadline = Date.now() + FOREIGN_HOLDER_TIMEOUT_MS;
|
|
136
|
+
for (;;) {
|
|
110
137
|
if (leaseHeldByAnother(leasePath) === true && fileHolderPid(leasePath) !== process.pid) return;
|
|
138
|
+
if (Date.now() >= deadline) {
|
|
139
|
+
const holder = fileHolderPid(leasePath);
|
|
140
|
+
let log = '';
|
|
141
|
+
try {
|
|
142
|
+
log = readFileSync(holderLog, 'utf8').trim();
|
|
143
|
+
} catch {
|
|
144
|
+
// The child never wrote anything: it did not start.
|
|
145
|
+
}
|
|
146
|
+
throw new Error(
|
|
147
|
+
`no foreign holder took ${leasePath} within ${FOREIGN_HOLDER_TIMEOUT_MS} ms ` +
|
|
148
|
+
`(held=${String(leaseHeldByAnother(leasePath))}, holderPid=${String(holder)}, ` +
|
|
149
|
+
`childPids=${JSON.stringify(children)})${log ? `\nholder log:\n${log}` : '\nholder log: (empty)'}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
111
152
|
await Bun.sleep(10);
|
|
112
153
|
}
|
|
113
154
|
}
|
|
@@ -46,7 +46,7 @@ import { toManifest, toMarketplaceCatalog } from '@/server/lib/panels/manifest';
|
|
|
46
46
|
import { basenameOf, bundleUrl, cssUrl, iconUrl, readmeUrlFor } from '@/server/lib/panels/assets.server';
|
|
47
47
|
import { readBundledCatalog } from '@/server/lib/panels/bundled-catalog.server';
|
|
48
48
|
import { describeIncompatibility, runningVersions } from '@/server/lib/panels/compat.server';
|
|
49
|
-
import {
|
|
49
|
+
import { pluginEnablementKey } from '@/shared/lib/workspace/panel-ids';
|
|
50
50
|
|
|
51
51
|
/** The marketplace root: `<data>/marketplace`, overridable for tests. */
|
|
52
52
|
export function getMarketplaceDir(): string {
|
|
@@ -205,7 +205,7 @@ async function runPanelScan(): Promise<PanelScan> {
|
|
|
205
205
|
// panel id (`plugin:<id>`), so one stored list answers for both kinds. Testing
|
|
206
206
|
// the bare manifest id here made a disabled plugin still report `enabled:
|
|
207
207
|
// true` while its panel stayed hidden — two answers for one question.
|
|
208
|
-
const enabled = !disabled.has(
|
|
208
|
+
const enabled = !disabled.has(pluginEnablementKey(manifest.id));
|
|
209
209
|
const entries = [manifest.app, manifest.server].filter((entry): entry is string => Boolean(entry));
|
|
210
210
|
let missingEntry: string | undefined;
|
|
211
211
|
for (const entry of entries) {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { claimHeadQueueItem, hasQueuedItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
|
|
24
|
-
import {
|
|
24
|
+
import { applyModelSnapshot, promptCommand } from '@/server/lib/queue/payload';
|
|
25
|
+
import { requestWaitingSweep } from '@/server/lib/waiting/sweep-trigger';
|
|
25
26
|
import type { QueuedMessage } from '@/shared/types/chat';
|
|
26
27
|
|
|
27
28
|
/** Settling time between agent_end and the next prompt dispatch. Small on
|
|
@@ -94,45 +95,15 @@ function clearPendingTimer(sessionId: string): void {
|
|
|
94
95
|
|
|
95
96
|
/** Send one queued item to the session as a normal prompt. The model snapshot
|
|
96
97
|
* rides first (set_model / set_thinking_level) exactly like the composer's
|
|
97
|
-
* queued delivery did. Returns false when the dispatch failed.
|
|
98
|
+
* queued delivery did. Returns false when the dispatch failed.
|
|
99
|
+
*
|
|
100
|
+
* The command itself is built by `queue/payload.ts`, shared with the waiting
|
|
101
|
+
* prompt's dispatcher: both send a prompt nobody typed in this tab, from the
|
|
102
|
+
* same persisted shape. */
|
|
98
103
|
async function dispatchQueuedItem(session: QueueDeliveryHost, item: QueuedMessage): Promise<boolean> {
|
|
99
104
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (item.model.thinkingLevel !== 'auto') {
|
|
103
|
-
await session.send({ type: 'set_thinking_level', level: item.model.thinkingLevel });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// Attachments reach this point as the persisted display fields — the live
|
|
107
|
-
// `File` never survived the queue's JSON round trip. The base64 payload and
|
|
108
|
-
// the MIME type are therefore read from the fields that DID persist:
|
|
109
|
-
// `dataBase64` for the bytes, `type` for the media type. (Matching on a
|
|
110
|
-
// `data:` preview used to yield zero images, because the composer's preview
|
|
111
|
-
// is a `blob:` object URL that the server cannot read.)
|
|
112
|
-
const images = item.attachments
|
|
113
|
-
.filter((a) => typeof a?.dataBase64 === 'string' && a.dataBase64.length > 0)
|
|
114
|
-
.map((a) => ({
|
|
115
|
-
type: 'image' as const,
|
|
116
|
-
data: a.dataBase64 as string,
|
|
117
|
-
mimeType: typeof a.type === 'string' && a.type.startsWith('image/') ? a.type : 'image/png',
|
|
118
|
-
}));
|
|
119
|
-
// Text attachments are inlined into the prompt, exactly as the composer
|
|
120
|
-
// does at send time — the item's own `text` is the raw composer input, so
|
|
121
|
-
// without this a queued file would deliver as a bare filename mention.
|
|
122
|
-
const textFiles = item.attachments
|
|
123
|
-
.filter((a) => typeof a?.content === 'string' && a.content.length > 0)
|
|
124
|
-
.map((a) => ({
|
|
125
|
-
name: typeof a.name === 'string' && a.name ? a.name : 'attachment',
|
|
126
|
-
mimeType: typeof a.type === 'string' ? a.type : 'text/plain',
|
|
127
|
-
content: a.content as string,
|
|
128
|
-
size: typeof a.size === 'number' ? a.size : (a.content as string).length,
|
|
129
|
-
}));
|
|
130
|
-
await session.send({
|
|
131
|
-
type: 'prompt',
|
|
132
|
-
message: composeMessageWithTextAttachments(item.text, textFiles),
|
|
133
|
-
...(images.length ? { images } : {}),
|
|
134
|
-
...(item.model ? { accessMode: item.model.accessMode } : {}),
|
|
135
|
-
});
|
|
105
|
+
await applyModelSnapshot(session.send, item.model);
|
|
106
|
+
await session.send(promptCommand({ text: item.text, attachments: item.attachments, model: item.model }));
|
|
136
107
|
return true;
|
|
137
108
|
} catch (error) {
|
|
138
109
|
console.error(`[queue] delivery failed for session ${session.sessionId}:`, error);
|
|
@@ -231,7 +202,13 @@ async function deliverNext(session: QueueDeliveryHost): Promise<boolean> {
|
|
|
231
202
|
return false;
|
|
232
203
|
}
|
|
233
204
|
const item = await claimHeadQueueItem(sessionId);
|
|
234
|
-
if (!item)
|
|
205
|
+
if (!item) {
|
|
206
|
+
// The queue just drained: a prompt held until this session finished may now
|
|
207
|
+
// be free to go. The sweep re-checks the queue itself, so requesting one
|
|
208
|
+
// here cannot release a prompt ahead of an item that is still waiting.
|
|
209
|
+
requestWaitingSweep();
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
235
212
|
const ok = await dispatchQueuedItem(session, item);
|
|
236
213
|
if (!ok) {
|
|
237
214
|
await requeueHeadQueueItem(sessionId, item);
|
|
@@ -240,9 +217,24 @@ async function deliverNext(session: QueueDeliveryHost): Promise<boolean> {
|
|
|
240
217
|
await armRetryIfQueued(session);
|
|
241
218
|
return false;
|
|
242
219
|
}
|
|
220
|
+
// This delivery may have been the last one: ask for a sweep so a dependent
|
|
221
|
+
// prompt does not sit until the next tick after the queue emptied.
|
|
222
|
+
requestWaitingSweep();
|
|
243
223
|
return true;
|
|
244
224
|
}
|
|
245
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Whether this process already holds a delivery slot for the session.
|
|
228
|
+
*
|
|
229
|
+
* Read by the waiting-prompt sweep: a run end arms a delivery 500 ms out, so a
|
|
230
|
+
* dependency that looks settled for a moment may still have its queued
|
|
231
|
+
* follow-up about to go out. Waiting for the slot to clear is what keeps a
|
|
232
|
+
* dependent prompt from starting a second turn in the gap.
|
|
233
|
+
*/
|
|
234
|
+
export function hasPendingDelivery(sessionId: string): boolean {
|
|
235
|
+
return getPendingTimers().has(sessionId);
|
|
236
|
+
}
|
|
237
|
+
|
|
246
238
|
/**
|
|
247
239
|
* A user Stop: return anything already claimed for this session's pending
|
|
248
240
|
* delivery to the queue and cancel the timer. The item keeps its head
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one place a stored prompt becomes an omp command.
|
|
8
|
+
*
|
|
9
|
+
* Two dispatchers send prompts nobody typed in this tab: the follow-up queue
|
|
10
|
+
* (`queue/delivery.server.ts`) and a prompt held until another session finishes
|
|
11
|
+
* (`waiting/runtime.server.ts`). They carry the SAME persisted shape — a text,
|
|
12
|
+
* an attachment list whose live `File` did not survive the JSON round trip, and
|
|
13
|
+
* an optional model snapshot — so a second copy of this assembly is how the two
|
|
14
|
+
* drift (one inlining a text file, the other sending a bare filename).
|
|
15
|
+
*
|
|
16
|
+
* The attachment rules are read off the fields that DID persist:
|
|
17
|
+
*
|
|
18
|
+
* - `dataBase64` holds an image's bytes; `type` its media type. Matching on a
|
|
19
|
+
* `data:` preview yields zero images, because the composer's preview is a
|
|
20
|
+
* `blob:` object URL the server cannot read.
|
|
21
|
+
* - `content` is an inlined text file. Without it a queued attachment
|
|
22
|
+
* delivers as a filename mention instead of the file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Attachment, QueuedMessageModel } from '@/shared/types/chat';
|
|
26
|
+
import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
|
|
27
|
+
|
|
28
|
+
export interface StoredPrompt {
|
|
29
|
+
text: string;
|
|
30
|
+
attachments: Attachment[];
|
|
31
|
+
model: QueuedMessageModel | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `prompt` command (with any images) a stored prompt dispatches. */
|
|
35
|
+
export function promptCommand(prompt: StoredPrompt): Record<string, unknown> {
|
|
36
|
+
const images = prompt.attachments
|
|
37
|
+
.filter((attachment) => typeof attachment?.dataBase64 === 'string' && attachment.dataBase64.length > 0)
|
|
38
|
+
.map((attachment) => ({
|
|
39
|
+
type: 'image' as const,
|
|
40
|
+
data: attachment.dataBase64 as string,
|
|
41
|
+
mimeType: typeof attachment.type === 'string' && attachment.type.startsWith('image/')
|
|
42
|
+
? attachment.type
|
|
43
|
+
: 'image/png',
|
|
44
|
+
}));
|
|
45
|
+
const textFiles = prompt.attachments
|
|
46
|
+
.filter((attachment) => typeof attachment?.content === 'string' && attachment.content.length > 0)
|
|
47
|
+
.map((attachment) => ({
|
|
48
|
+
name: typeof attachment.name === 'string' && attachment.name ? attachment.name : 'attachment',
|
|
49
|
+
mimeType: typeof attachment.type === 'string' ? attachment.type : 'text/plain',
|
|
50
|
+
content: attachment.content as string,
|
|
51
|
+
size: typeof attachment.size === 'number' ? attachment.size : (attachment.content as string).length,
|
|
52
|
+
}));
|
|
53
|
+
return {
|
|
54
|
+
type: 'prompt',
|
|
55
|
+
message: composeMessageWithTextAttachments(prompt.text, textFiles),
|
|
56
|
+
...(images.length ? { images } : {}),
|
|
57
|
+
...(prompt.model ? { accessMode: prompt.model.accessMode } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Apply a stored model snapshot to a live child before its prompt. */
|
|
62
|
+
export async function applyModelSnapshot(
|
|
63
|
+
send: (command: Record<string, unknown>) => Promise<unknown>,
|
|
64
|
+
model: QueuedMessageModel | null,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
if (!model) return;
|
|
67
|
+
await send({ type: 'set_model', provider: model.provider, modelId: model.modelId });
|
|
68
|
+
// 'auto' thinking leaves omp's level alone: the snapshot records "no pick",
|
|
69
|
+
// not a level named auto.
|
|
70
|
+
if (model.thinkingLevel !== 'auto') {
|
|
71
|
+
await send({ type: 'set_thinking_level', level: model.thinkingLevel });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -56,7 +56,16 @@ export type RealtimeSignal =
|
|
|
56
56
|
* snapshot and then never moved — the panels rendered whatever existed when
|
|
57
57
|
* they subscribed and only a reload showed the new state.
|
|
58
58
|
*/
|
|
59
|
-
| 'session-data-dirty'
|
|
59
|
+
| 'session-data-dirty'
|
|
60
|
+
/**
|
|
61
|
+
* A waiting prompt was created, cancelled, dispatched or failed.
|
|
62
|
+
*
|
|
63
|
+
* Both halves read it: the server republishes the `waiting` topic, and the
|
|
64
|
+
* client's dispatch watcher adopts the session a prompt became. Nothing else
|
|
65
|
+
* reports a dispatch — no user action causes it, and the spawned session's own
|
|
66
|
+
* transcript is seconds away.
|
|
67
|
+
*/
|
|
68
|
+
| 'waiting-changed';
|
|
60
69
|
|
|
61
70
|
export interface RealtimeSignalEvent {
|
|
62
71
|
signal: RealtimeSignal;
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
TOPIC_SIDEBAR,
|
|
23
23
|
TOPIC_SIDEBAR_STATUS,
|
|
24
24
|
TOPIC_USAGE,
|
|
25
|
+
TOPIC_WAITING,
|
|
25
26
|
sessionPlanTopic,
|
|
26
27
|
sessionQueueTopic,
|
|
27
28
|
sessionTelemetryTopic,
|
|
@@ -67,6 +68,7 @@ export const EXACT_TOPICS = new Map<string, TopicDescriptor>([
|
|
|
67
68
|
[TOPIC_USAGE, { resolve: () => globalDescriptor(TOPIC_USAGE).resolve(TOPIC_USAGE) }],
|
|
68
69
|
[TOPIC_PANELS, { resolve: () => globalDescriptor(TOPIC_PANELS).resolve(TOPIC_PANELS) }],
|
|
69
70
|
[TOPIC_MODELS, { resolve: () => globalDescriptor(TOPIC_MODELS).resolve(TOPIC_MODELS) }],
|
|
71
|
+
[TOPIC_WAITING, { resolve: () => globalDescriptor(TOPIC_WAITING).resolve(TOPIC_WAITING) }],
|
|
70
72
|
]);
|
|
71
73
|
|
|
72
74
|
/** Suffixes a `session:<id>:<suffix>` topic may carry. */
|