@kabmo-labs/kabmo 0.3.1 → 0.5.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 (155) 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-cs3b557t.js} +792 -792
  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/TopNavbar.tsx +5 -3
  14. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  15. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  16. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  17. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  18. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  19. package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
  20. package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
  21. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  22. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  23. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  24. package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
  25. package/src/client/components/mobile/RightSidebar.tsx +30 -36
  26. package/src/client/components/mobile/SessionSidebar.tsx +124 -101
  27. package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
  28. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
  29. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
  30. package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
  31. package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
  32. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  34. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  35. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +47 -14
  37. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  38. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
  39. package/src/client/components/mobile/panel-chips.test.tsx +155 -0
  40. package/src/client/components/mobile/panel-chips.tsx +74 -0
  41. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  42. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  43. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  44. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  45. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  46. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  47. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  49. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  50. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  51. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  52. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  53. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  54. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  55. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +15 -10
  58. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  60. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +162 -0
  61. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  62. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  63. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  64. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  65. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  66. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  67. package/src/client/components/workspace/editor/index.tsx +1 -0
  68. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  69. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  70. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  71. package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
  72. package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
  73. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  74. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  75. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  76. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  77. package/src/client/hooks/chat/composer/compact.ts +119 -0
  78. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  79. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  80. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  81. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  82. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  83. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  84. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  85. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  86. package/src/client/hooks/chat/timeline/index.ts +15 -19
  87. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  88. package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
  89. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  90. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  91. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  92. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  93. package/src/client/hooks/chat/waiting/held.ts +42 -0
  94. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  95. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  96. package/src/client/hooks/chat/waiting/store.ts +100 -0
  97. package/src/client/hooks/ui/toasts.ts +12 -1
  98. package/src/client/hooks/workspace/left-dock.ts +132 -0
  99. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  100. package/src/client/tailwind.css +22 -0
  101. package/src/server/db.server.test.ts +52 -1
  102. package/src/server/db.server.ts +49 -1
  103. package/src/server/index.ts +9 -0
  104. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  105. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  106. package/src/server/lib/db/schema.test.ts +1 -0
  107. package/src/server/lib/db/schema.ts +3 -0
  108. package/src/server/lib/fs/fs-path.test.ts +1 -1
  109. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  110. package/src/server/lib/omp/session/delete.server.ts +8 -0
  111. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  112. package/src/server/lib/panels/registry.server.ts +2 -2
  113. package/src/server/lib/queue/delivery.server.ts +31 -39
  114. package/src/server/lib/queue/payload.ts +73 -0
  115. package/src/server/lib/realtime/signals.server.ts +10 -1
  116. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  117. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  118. package/src/server/lib/realtime/topics.server.ts +2 -0
  119. package/src/server/lib/util/with-timeout.ts +29 -0
  120. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  121. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  122. package/src/server/lib/waiting/runtime.server.ts +313 -0
  123. package/src/server/lib/waiting/schema.server.ts +46 -0
  124. package/src/server/lib/waiting/settle.test.ts +84 -0
  125. package/src/server/lib/waiting/settle.ts +77 -0
  126. package/src/server/lib/waiting/store.server.test.ts +167 -0
  127. package/src/server/lib/waiting/store.server.ts +268 -0
  128. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  129. package/src/server/plugins/compress.ts +18 -6
  130. package/src/server/plugins/ssr.ts +16 -8
  131. package/src/server/plugins/static.test.ts +59 -0
  132. package/src/server/plugins/static.ts +56 -14
  133. package/src/server/routes/index.ts +2 -0
  134. package/src/server/routes/waiting/bindings.ts +14 -0
  135. package/src/server/routes/waiting/index.ts +123 -0
  136. package/src/server/routes/waiting/routes.test.ts +121 -0
  137. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  138. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  139. package/src/shared/lib/realtime/protocol.ts +10 -0
  140. package/src/shared/lib/ui/titlebar.test.ts +124 -0
  141. package/src/shared/lib/ui/titlebar.ts +54 -0
  142. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  143. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  144. package/src/shared/lib/workspace/left-dock.ts +197 -0
  145. package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
  146. package/src/shared/lib/workspace/mobile-dock.ts +55 -0
  147. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  148. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  149. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  150. package/src/shared/types/index.ts +1 -0
  151. package/src/shared/types/waiting.ts +54 -0
  152. package/src/shared/types/workspace.ts +8 -0
  153. package/src/test-support/isolated-db.ts +3 -4
  154. package/src/test-support/silent-socket.ts +69 -0
  155. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The left dock's state, as the desktop layout and its bar read it.
8
+ *
9
+ * Backed by the GLOBAL `app_settings.omp_left_panels` (see
10
+ * `shared/lib/workspace/left-dock.ts`), not by `layout.*` session state: the
11
+ * dock lists the same views in every chat, so a placement stored per session
12
+ * would move the user's arrangement when they picked another session.
13
+ *
14
+ * The read is synchronous from the primed settings snapshot, so the first render
15
+ * already knows whether the bar is drawn. A write notifies every reader on
16
+ * screen, because they all resolve against this one module-level cache.
17
+ *
18
+ * The snapshot is compared by IDENTITY of the stored value: `writeSetting`
19
+ * replaces it with the exact object a write handed it, so a reference comparison
20
+ * is both correct and free — a rebuilt object would make
21
+ * `useSyncExternalStore` see a change on every render and loop.
22
+ */
23
+ import { useCallback, useEffect, useState } from 'preact/hooks';
24
+ import { useSyncExternalStore } from 'preact/compat';
25
+ import { readKabmoSetting } from '@/shared/lib/settings/client';
26
+ import {
27
+ LEFT_DOCK_SETTING_KEY,
28
+ readLeftDock,
29
+ setPanelSide as persistPanelSide,
30
+ writeLeftDock,
31
+ type LeftDockState,
32
+ type PanelSide,
33
+ } from '@/shared/lib/workspace/left-dock';
34
+ import { resolveLeftDockWidth, saveLeftDockWidth } from '@/shared/lib/workspace/left-dock-width';
35
+
36
+ const listeners = new Set<() => void>();
37
+
38
+ /** The stored value the cache was built from, and the state it produced. */
39
+ let lastSource: unknown;
40
+ let cached: LeftDockState = { enabled: false, panels: [] };
41
+
42
+ function subscribe(listener: () => void): () => void {
43
+ listeners.add(listener);
44
+ return () => listeners.delete(listener);
45
+ }
46
+
47
+ function snapshot(): LeftDockState {
48
+ const source = readKabmoSetting<unknown>(LEFT_DOCK_SETTING_KEY);
49
+ if (source === lastSource) return cached;
50
+ lastSource = source;
51
+ cached = readLeftDock();
52
+ return cached;
53
+ }
54
+
55
+ function publish(): void {
56
+ lastSource = undefined;
57
+ for (const listener of listeners) listener();
58
+ }
59
+
60
+ export interface LeftDockHandle extends LeftDockState {
61
+ /** Draw the dock's activity bar. Off by default. */
62
+ setEnabled: (enabled: boolean) => void;
63
+ /**
64
+ * Place a plugin panel on the left, or send it back to the right.
65
+ *
66
+ * Placing a panel TURNS THE RAIL ON. The rail exists to reach the panels on
67
+ * it, so leaving it off after a successful placement would be a move with no
68
+ * button, no view and no way to undo it from the bar — the settings row would
69
+ * be the only control, which is exactly the state a user reads as broken.
70
+ * Moving a panel back to the right does NOT turn the rail off: that is the
71
+ * user's switch, and the dock may still be worth showing.
72
+ */
73
+ setSide: (panelId: string, side: PanelSide) => void;
74
+ /** The width this view opens at, and the writer a drag commits through. */
75
+ widthOf: (viewId: string) => number;
76
+ commitWidth: (viewId: string, px: number) => void;
77
+ }
78
+
79
+ export function useLeftDock(): LeftDockHandle {
80
+ const state = useSyncExternalStore(subscribe, snapshot);
81
+
82
+ const setEnabled = useCallback((enabled: boolean) => {
83
+ writeLeftDock({ enabled });
84
+ publish();
85
+ }, []);
86
+
87
+ const setSide = useCallback((panelId: string, side: PanelSide) => {
88
+ // The coupling — placing a panel also shows the rail — lives in the store,
89
+ // so a second caller cannot produce a placement nobody can reach.
90
+ persistPanelSide(panelId, side);
91
+ publish();
92
+ }, []);
93
+
94
+ const commitWidth = useCallback((viewId: string, px: number) => {
95
+ saveLeftDockWidth(viewId, px);
96
+ publish();
97
+ }, []);
98
+
99
+ return {
100
+ ...state,
101
+ setEnabled,
102
+ setSide,
103
+ widthOf: resolveLeftDockWidth,
104
+ commitWidth,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * The dock's width for ONE view.
110
+ *
111
+ * Per view because a session list reads at 280px and a plugin's table does not:
112
+ * a column that snapped to the list's width the moment the user switched views
113
+ * would be a column with one width that is wrong for every second view.
114
+ */
115
+ export function useLeftDockWidth(viewId: string): [number, (px: number) => void] {
116
+ const dock = useLeftDock();
117
+ const [width, setWidth] = useState(() => dock.widthOf(viewId));
118
+
119
+ useEffect(() => {
120
+ setWidth(dock.widthOf(viewId));
121
+ }, [viewId, dock]);
122
+
123
+ const commitWidth = useCallback(
124
+ (px: number) => {
125
+ dock.commitWidth(viewId, px);
126
+ setWidth(dock.widthOf(viewId));
127
+ },
128
+ [dock, viewId],
129
+ );
130
+
131
+ return [width, commitWidth];
132
+ }
@@ -123,6 +123,19 @@ export function seedPanelRegistry(payload: PanelRegistryPayload): void {
123
123
  adoptPanelRegistry(payload);
124
124
  }
125
125
 
126
+ /**
127
+ * Forget the adopted payload — a test seam, and nothing else.
128
+ *
129
+ * The store is anchored on `globalThis` (a `bun --hot` reload must not lose it),
130
+ * which is also why it survives between test files in one `bun test` process: a
131
+ * suite that adopts a payload with a plugin in it leaves every later suite
132
+ * drawing that plugin's button. A file that adopts one has to put the store
133
+ * back.
134
+ */
135
+ export function resetPanelRegistry(): void {
136
+ globalThis.__kabmoPanelRegistry = undefined;
137
+ }
138
+
126
139
  /**
127
140
  * Re-read the marketplace from disk, bypassing the server's scan cache.
128
141
  *
@@ -182,6 +182,28 @@
182
182
  overflow-y: auto;
183
183
  }
184
184
 
185
+ /* The composer reads at the transcript's own size — 13px — EXCEPT on iOS.
186
+ *
187
+ * Every message row in the timeline is `text-[13px] leading-relaxed`, and a
188
+ * draft written at 16px looked like a different document pasted under the
189
+ * conversation. The size itself is a utility on the textarea; this rule exists
190
+ * for the one platform where 13px is not allowed to apply.
191
+ *
192
+ * iOS Safari ZOOMS the viewport when a text control below 16px takes focus,
193
+ * and it does not zoom back out — the page is left wider than the screen with
194
+ * the composer half off it. So iOS keeps 16px and every other platform reads
195
+ * at the transcript's size. `-webkit-touch-callout` is the standard
196
+ * iOS-only probe (it is a Safari-only property), and it cannot be expressed as
197
+ * a Tailwind variant, which is why this is a class rather than a utility.
198
+ *
199
+ * The PLACEHOLDER needs no rule of its own: `::placeholder` inherits
200
+ * `font-size` from its textarea, so both move together. */
201
+ @supports (-webkit-touch-callout: none) {
202
+ textarea.composer-type {
203
+ font-size: 16px;
204
+ }
205
+ }
206
+
185
207
  /* Reveal-on-hover controls are written `opacity-0 group-hover:opacity-100`
186
208
  * (or `hidden group-hover:flex`), and Tailwind v4 emits every one of those
187
209
  * variants inside `@media (hover: hover)` — verified in the emitted
@@ -33,7 +33,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
33
33
  import { homedir, tmpdir } from 'node:os';
34
34
  import * as path from 'node:path';
35
35
 
36
- import { getDatabasePath, getDb } from '@/server/db.server';
36
+ import { closeDb, getDatabasePath, getDb } from '@/server/db.server';
37
37
  import { removeDir } from '@/test-support/remove-dir';
38
38
 
39
39
  const saved = {
@@ -151,3 +151,54 @@ describe('a failed open is not cached', () => {
151
151
  }
152
152
  });
153
153
  });
154
+
155
+ describe('closeDb', () => {
156
+ test('retires an open that is still in flight, and closes its handle', async () => {
157
+ // The bug this pins, measured on the Release gate: `releaseDb` cleared the
158
+ // slot while `getDb()` was still opening, so the continuation assigned
159
+ // `resolved` on the DROPPED slot object. Nothing could reach that handle
160
+ // again, and on Windows its temp directory stayed locked — `EBUSY … a
161
+ // handle outlived its test`, reported against the two PRECEDING cases.
162
+ const savedSlot = globalThis.__kabmoDb;
163
+ try {
164
+ Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'in-flight', 'db.sqlite');
165
+ globalThis.__kabmoDb = undefined;
166
+
167
+ // Start the open and retire the slot in the SAME tick, so the async
168
+ // continuation is still pending when `closeDb` runs.
169
+ const pending = getDb();
170
+ const retired = globalThis.__kabmoDb as { disposed?: boolean } | undefined;
171
+ closeDb();
172
+ expect(retired?.disposed).toBe(true);
173
+
174
+ // The open settles as a refusal rather than handing back a handle whose
175
+ // owner is gone, and the slot stays clear for the next caller.
176
+ await expect(pending).rejects.toThrow(/retired/);
177
+ expect(globalThis.__kabmoDb ?? null).toBeNull();
178
+
179
+ // A later open under a NEW path works, which is what a test isolating
180
+ // itself between cases needs.
181
+ Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'after', 'db.sqlite');
182
+ const db = await getDb();
183
+ expect(db).toBeDefined();
184
+ closeDb();
185
+ } finally {
186
+ globalThis.__kabmoDb = savedSlot;
187
+ }
188
+ });
189
+
190
+ test('closes a resolved handle and clears the slot', async () => {
191
+ const savedSlot = globalThis.__kabmoDb;
192
+ try {
193
+ Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'resolved', 'db.sqlite');
194
+ globalThis.__kabmoDb = undefined;
195
+ const db = await getDb();
196
+ closeDb();
197
+ expect(globalThis.__kabmoDb ?? null).toBeNull();
198
+ // A closed handle refuses further work rather than silently succeeding.
199
+ expect(() => db.raw.exec('SELECT 1')).toThrow();
200
+ } finally {
201
+ globalThis.__kabmoDb = savedSlot;
202
+ }
203
+ });
204
+ });
@@ -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
  }