@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.
Files changed (135) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -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
  })();
@@ -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
- const child = Bun.spawn(['sh', '-c', `{ ${inner} >/dev/null 2>&1 & } ; echo $!`], {
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
- /** Wait until a process other than this one actually holds the lease. */
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
- for (let attempt = 0; attempt < 300; attempt++) {
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 { pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
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(pluginPanelKey(manifest.id));
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 { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
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
- if (item.model) {
101
- await session.send({ type: 'set_model', provider: item.model.provider, modelId: item.model.modelId });
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) return false;
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. */