@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
@@ -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. */
@@ -20,9 +20,11 @@ import {
20
20
  TOPIC_PANELS,
21
21
  TOPIC_SCHEDULE,
22
22
  TOPIC_USAGE,
23
+ TOPIC_WAITING,
23
24
  } from '@/shared/lib/realtime/protocol';
24
25
  import { type TopicDescriptor } from '@/server/lib/realtime/hub.server';
25
26
  import { listScheduledTasks } from '@/server/lib/schedule/store.server';
27
+ import { listWaitingPrompts } from '@/server/lib/waiting/store.server';
26
28
  import { buildUsageReport } from '@/server/lib/usage/report.server';
27
29
  import { discoverPanelPlugins } from '@/server/lib/panels/registry.server';
28
30
  import { loadModelsWithCache } from '@/server/lib/models/registry.server';
@@ -34,6 +36,9 @@ import { loadModelsWithCache } from '@/server/lib/models/registry.server';
34
36
  */
35
37
  const RESOLVERS: Record<string, () => Promise<unknown>> = {
36
38
  [TOPIC_SCHEDULE]: () => listScheduledTasks(),
39
+ // The waiting list is the same read the runtime sweeps over, so a client and
40
+ // the dispatcher can never disagree about which prompt is still held.
41
+ [TOPIC_WAITING]: () => listWaitingPrompts(),
37
42
  // The SAME builder `GET /api/settings/usage` answers with: the panel renders
38
43
  // whichever path arrived first, so the two payloads must not diverge.
39
44
  [TOPIC_USAGE]: () => buildUsageReport(),
@@ -20,6 +20,7 @@ import {
20
20
  TOPIC_SIDEBAR,
21
21
  TOPIC_SIDEBAR_STATUS,
22
22
  TOPIC_USAGE,
23
+ TOPIC_WAITING,
23
24
  isValidTopic,
24
25
  reposTopic,
25
26
  sessionTopic,
@@ -93,6 +94,7 @@ export function initRealtimeTopics(): void {
93
94
  else if (signal === 'workspace-dirty') republishWatchedWorkspaceTopics();
94
95
  else if (signal === 'session-data-dirty' && sessionId) republishSessionDataTopics(sessionId);
95
96
  else if (signal === 'repos-scanned' && root) publishTopic(reposTopic(root));
97
+ else if (signal === 'waiting-changed') publishTopic(TOPIC_WAITING);
96
98
  });
97
99
  }
98
100
 
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Reject a promise that has not settled within `ms`.
8
+ *
9
+ * Both server-side dispatchers (a scheduled task, a prompt held until another
10
+ * session finishes) wait on a spawn and then on a prompt ack, and neither has a
11
+ * timeout of its own: an omp child that boots but never answers would leave the
12
+ * dispatch in flight for the life of the process. The caller owns the message,
13
+ * because it is the only place that knows what was being waited for.
14
+ */
15
+ export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
16
+ const { promise: guarded, resolve, reject } = Promise.withResolvers<T>();
17
+ const timer = setTimeout(() => reject(new Error(message)), ms);
18
+ promise.then(
19
+ (value) => {
20
+ clearTimeout(timer);
21
+ resolve(value);
22
+ },
23
+ (error) => {
24
+ clearTimeout(timer);
25
+ reject(error);
26
+ },
27
+ );
28
+ return guarded;
29
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * When was a session's stream row last written?
8
+ *
9
+ * The dependency check needs this and the SIDEBAR does not, which is why it is
10
+ * a query of its own rather than a field on `SessionStreamState`: that payload
11
+ * is the `sidebar:status` wire contract, and widening it for one caller would
12
+ * put a field on every frame that no consumer reads.
13
+ *
14
+ * The conversion happens in SQLite. `updated_at` is written by
15
+ * `CURRENT_TIMESTAMP` — a UTC string with no zone marker — so parsing it in JS
16
+ * would silently read it as local time. `strftime('%s', …)` yields epoch
17
+ * seconds, which is the resolution the row is written at.
18
+ */
19
+
20
+ import { getDb } from '@/server/db.server';
21
+
22
+ /**
23
+ * Epoch ms of the session's last stream-status write, or null when the session
24
+ * has no row (or the clock cannot be read).
25
+ */
26
+ export async function streamRowWrittenAt(sessionId: string): Promise<number | null> {
27
+ try {
28
+ const db = await getDb();
29
+ const row = (await db.get(
30
+ "SELECT CAST(strftime('%s', updated_at) AS INTEGER) AS epoch FROM session_stream_state WHERE session_id = ?",
31
+ [sessionId],
32
+ )) as { epoch?: number | null } | undefined;
33
+ return typeof row?.epoch === 'number' ? row.epoch * 1000 : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The waiting-prompt sweep: what it dispatches, what it holds, and what happens
8
+ * when the dispatch fails.
9
+ *
10
+ * Every outside dependency is INJECTED (`WaitingDeps`), never mocked through
11
+ * `mock.module` — that swaps a registry entry for the whole `bun test` run and
12
+ * broke six unrelated suites the last time it was tried here. Injection keeps
13
+ * the blast radius inside this file, and the rules worth pinning are all about
14
+ * ORDER rather than about omp:
15
+ *
16
+ * - a prompt is dispatched exactly once, even for two sweeps that race;
17
+ * - a dependency with a queue still waiting holds the prompt;
18
+ * - a dispatch that throws is retried, then reported, and never silently
19
+ * dropped;
20
+ * - the prompt carries its attachments and its model snapshot.
21
+ */
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { sweepWaitingPrompts, MAX_DISPATCH_ATTEMPTS, type StreamRowMap, type WaitingDeps } from '@/server/lib/waiting/runtime.server';
25
+ import type { Attachment } from '@/shared/types/chat';
26
+ import type { WaitingPrompt } from '@/shared/types/waiting';
27
+
28
+ const NOW = 1_800_000_000_000;
29
+ const OLD = NOW - 60_000;
30
+
31
+ function prompt(overrides: Partial<WaitingPrompt> = {}): WaitingPrompt {
32
+ return {
33
+ id: 'wait-1',
34
+ pendingId: 'new-1',
35
+ dependsOn: 'dep-a',
36
+ dependsOnTitle: 'Session A',
37
+ prompt: 'do the thing',
38
+ attachments: [],
39
+ model: null,
40
+ modes: null,
41
+ cwd: '/tmp/project',
42
+ folderId: 1,
43
+ status: 'waiting',
44
+ sessionId: null,
45
+ error: null,
46
+ createdAt: OLD,
47
+ updatedAt: OLD,
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ interface Harness {
53
+ deps: WaitingDeps;
54
+ /** Commands sent to each spawned session, in order. */
55
+ sent: Record<string, Record<string, unknown>[]>;
56
+ /** Session ids spawned, in order. */
57
+ spawned: string[];
58
+ /** Calls that changed a row, as `verb id`. */
59
+ writes: string[];
60
+ setStreams(rows: StreamRowMap): void;
61
+ setRowWrittenAt(at: number | null): void;
62
+ setQueued(queued: boolean): void;
63
+ setDelivering(delivering: boolean): void;
64
+ failSpawnWith(error: Error | null): void;
65
+ failSendWith(error: Error | null): void;
66
+ }
67
+
68
+ function harness(initial: WaitingPrompt[] = [prompt()]): Harness {
69
+ // The attempt counter is the store's own column, not part of the client-facing
70
+ // shape: the harness keeps it beside the rows rather than widening `WaitingPrompt`.
71
+ const attempts = new Map<string, number>();
72
+ let rows = initial.map((row) => ({ ...row }));
73
+ let streams: StreamRowMap = { 'dep-a': { status: 'finish' } };
74
+ let rowWrittenAt: number | null = OLD;
75
+ let queued = false;
76
+ let delivering = false;
77
+ let spawnError: Error | null = null;
78
+ let sendError: Error | null = null;
79
+ const sent: Harness['sent'] = {};
80
+ const spawned: string[] = [];
81
+ const writes: string[] = [];
82
+ let spawnCount = 0;
83
+
84
+ const deps: WaitingDeps = {
85
+ async startNewRpcSession() {
86
+ if (spawnError) throw spawnError;
87
+ spawnCount += 1;
88
+ const id = `spawned-${spawnCount}`;
89
+ spawned.push(id);
90
+ sent[id] = [];
91
+ return {
92
+ realSessionId: id,
93
+ session: {
94
+ async send(command) {
95
+ if (sendError) throw sendError;
96
+ sent[id].push(command);
97
+ return null;
98
+ },
99
+ },
100
+ };
101
+ },
102
+ getSession: () => undefined,
103
+ loadStreamStates: async () => streams,
104
+ streamRowWrittenAt: async () => rowWrittenAt,
105
+ hasQueuedItem: async () => queued,
106
+ hasPendingDelivery: () => delivering,
107
+ loadPersistedAccessMode: async () => 'always-ask',
108
+ listWaitingPrompts: async () => rows.map((row) => ({ ...row })),
109
+ claimWaitingPrompt: async (id) => {
110
+ const row = rows.find((entry) => entry.id === id);
111
+ if (!row || row.status !== 'waiting') return false;
112
+ row.status = 'dispatched';
113
+ writes.push(`claim ${id}`);
114
+ return true;
115
+ },
116
+ countWaitingAttempt: async (id) => {
117
+ const next = (attempts.get(id) ?? 0) + 1;
118
+ attempts.set(id, next);
119
+ return next;
120
+ },
121
+ markWaitingDispatched: async (id, sessionId) => {
122
+ const row = rows.find((entry) => entry.id === id);
123
+ if (row) {
124
+ row.status = 'dispatched';
125
+ row.sessionId = sessionId;
126
+ }
127
+ writes.push(`dispatched ${id} ${sessionId}`);
128
+ },
129
+ requeueWaitingPrompt: async (id) => {
130
+ const row = rows.find((entry) => entry.id === id);
131
+ if (row) row.status = 'waiting';
132
+ writes.push(`requeue ${id}`);
133
+ },
134
+ failWaitingPrompt: async (id, error) => {
135
+ const row = rows.find((entry) => entry.id === id);
136
+ if (row) {
137
+ row.status = 'error';
138
+ row.error = error;
139
+ }
140
+ writes.push(`fail ${id}`);
141
+ },
142
+ pruneWaitingPrompts: async () => 0,
143
+ };
144
+
145
+ return {
146
+ deps,
147
+ sent,
148
+ spawned,
149
+ writes,
150
+ setStreams: (next) => {
151
+ streams = next;
152
+ },
153
+ setRowWrittenAt: (next) => {
154
+ rowWrittenAt = next;
155
+ },
156
+ setQueued: (next) => {
157
+ queued = next;
158
+ },
159
+ setDelivering: (next) => {
160
+ delivering = next;
161
+ },
162
+ failSpawnWith: (error) => {
163
+ spawnError = error;
164
+ },
165
+ failSendWith: (error) => {
166
+ sendError = error;
167
+ },
168
+ };
169
+ }
170
+
171
+ describe('sweepWaitingPrompts', () => {
172
+ test('dispatches a prompt once its dependency has finished', async () => {
173
+ const h = harness();
174
+ expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(1);
175
+ expect(h.spawned).toEqual(['spawned-1']);
176
+ expect(h.sent['spawned-1']).toEqual([{ type: 'prompt', message: 'do the thing' }]);
177
+ expect(h.writes).toContain('dispatched wait-1 spawned-1');
178
+ });
179
+
180
+ test('holds the prompt while the dependency still has a queued follow-up', async () => {
181
+ const h = harness();
182
+ h.setQueued(true);
183
+ expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
184
+ expect(h.spawned).toEqual([]);
185
+ });
186
+
187
+ test('holds the prompt while the dependency is still streaming', async () => {
188
+ const h = harness();
189
+ h.setStreams({ 'dep-a': { status: 'stream' } });
190
+ expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
191
+ });
192
+
193
+ // The row's clock is what closes the gap between a run end and the queue it
194
+ // arms 500 ms later, so a freshly written row must not release anything yet.
195
+ test('holds the prompt until the status row has aged past the grace', async () => {
196
+ const h = harness();
197
+ h.setRowWrittenAt(NOW - 100);
198
+ expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
199
+ expect(h.spawned).toEqual([]);
200
+ });
201
+
202
+ test('holds the prompt while a delivery slot is armed', async () => {
203
+ const h = harness();
204
+ h.setDelivering(true);
205
+ expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
206
+ });
207
+
208
+ // The claim is the concurrency answer: the second sweep must not spawn again.
209
+ test('two sweeps racing spawn exactly one session', async () => {
210
+ const h = harness();
211
+ const [first, second] = await Promise.all([
212
+ sweepWaitingPrompts({ now: NOW, deps: h.deps }),
213
+ sweepWaitingPrompts({ now: NOW, deps: h.deps }),
214
+ ]);
215
+ expect(first + second).toBe(1);
216
+ expect(h.spawned).toHaveLength(1);
217
+ });
218
+
219
+ test('a failed spawn returns the prompt to waiting instead of losing it', async () => {
220
+ const h = harness();
221
+ h.failSpawnWith(new Error('omp did not start'));
222
+ expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
223
+ expect(h.writes).toContain('requeue wait-1');
224
+ expect(h.writes).not.toContain('fail wait-1');
225
+ });
226
+
227
+ test('a prompt whose dispatch keeps failing is reported, not retried forever', async () => {
228
+ const h = harness();
229
+ h.failSpawnWith(new Error('omp did not start'));
230
+ for (let attempt = 0; attempt < MAX_DISPATCH_ATTEMPTS; attempt += 1) {
231
+ await sweepWaitingPrompts({ now: NOW, deps: h.deps });
232
+ }
233
+ expect(h.writes.filter((write) => write.startsWith('fail '))).toHaveLength(1);
234
+ });
235
+
236
+ // The wrapper's `send` reads its own runtime state through `this`, so a
237
+ // dispatch that passes the method detached runs against `undefined` — the
238
+ // real failure was `undefined is not an object (evaluating 'host.restarting')`
239
+ // on every prompt. The harness's send is an object METHOD for that reason,
240
+ // and this asserts the dispatcher never loses its receiver.
241
+ test('sends through the session, never a detached method', async () => {
242
+ const h = harness();
243
+ let receiver: unknown = null;
244
+ h.deps.startNewRpcSession = async () => {
245
+ const session = {
246
+ async send(this: unknown) {
247
+ receiver = this;
248
+ return null;
249
+ },
250
+ };
251
+ return { realSessionId: 'spawned-x', session };
252
+ };
253
+ await sweepWaitingPrompts({ now: NOW, deps: h.deps });
254
+ expect(receiver).not.toBeUndefined();
255
+ });
256
+
257
+ test('carries the attachment bytes and the model snapshot', async () => {
258
+ const attachment: Attachment = { id: 'a1', preview: '', name: 'shot.png', type: 'image/png', dataBase64: 'AAA' };
259
+ const h = harness([
260
+ prompt({
261
+ attachments: [attachment],
262
+ model: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'high', accessMode: 'yolo' },
263
+ }),
264
+ ]);
265
+ await sweepWaitingPrompts({ now: NOW, deps: h.deps });
266
+ const commands = h.sent['spawned-1'];
267
+ expect(commands[0]).toEqual({ type: 'set_model', provider: 'anthropic', modelId: 'claude' });
268
+ expect(commands[1]).toEqual({ type: 'set_thinking_level', level: 'high' });
269
+ expect(commands[2]).toMatchObject({ type: 'prompt', accessMode: 'yolo' });
270
+ expect((commands[2].images as { data: string }[])[0]?.data).toBe('AAA');
271
+ });
272
+
273
+ test('nothing is read or spawned when no prompt is waiting', async () => {
274
+ const h = harness([prompt({ status: 'dispatched', sessionId: 'done' })]);
275
+ expect(await sweepWaitingPrompts({ now: NOW, deps: h.deps })).toBe(0);
276
+ expect(h.spawned).toEqual([]);
277
+ });
278
+ });