ompchamber 1.0.0 → 1.2.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 (233) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Assembly of the chat model registry that `GET /api/models` serves: the omp
8
+ * registry (auth providers + models.yml) narrowed by the provider/model hides
9
+ * the operator set, plus the live session's default model.
10
+ *
11
+ * Extracted from `routes/models/root.ts` so the route keeps only its loader and
12
+ * action, and so the preference store can share the same model list.
13
+ */
14
+
15
+ import { getDb } from '@/server/db.server';
16
+ import { PROVIDERS_SETTINGS_KEY } from '@/server/lib/models/provider-registry.server';
17
+ import { readSettingsJson } from '@/server/lib/db/settings-store';
18
+ import { readDisabledProviders } from '@/server/lib/omp/config/roles';
19
+ import { runUtilityCommand, type OmpModel } from '@/server/lib/omp/rpc/utility';
20
+ import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
21
+ import type { ModelsData, ProviderItem } from '@/shared/types';
22
+
23
+ const MODELS_CACHE_TTL_MS = 60_000;
24
+ export const SAFE_MODEL_LOAD_FAILURE_MESSAGE = 'Model list is temporarily unavailable. Check your configuration and try again.';
25
+
26
+ const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
27
+
28
+ function compareModelEntries(a: { id: string; name: string; provider: string }, b: { id: string; name: string; provider: string }): number {
29
+ return modelNameCollator.compare(a.name || a.id, b.name || b.id)
30
+ || modelNameCollator.compare(a.provider, b.provider)
31
+ || modelNameCollator.compare(a.id, b.id);
32
+ }
33
+
34
+ // "off" is always a valid selector; the concrete efforts come from the model's
35
+ // baked thinking metadata (omp: getSupportedEfforts = reasoning ? efforts : []).
36
+ function thinkingLevelsFor(model: OmpModel): string[] {
37
+ if (!model.reasoning) return ['off'];
38
+ return ['off', ...(model.thinking?.efforts ?? [])];
39
+ }
40
+
41
+ function supportsFastMode(model: OmpModel): boolean {
42
+ return model.provider === 'anthropic' || model.provider === 'openai' || model.provider === 'google';
43
+ }
44
+
45
+ /**
46
+ * Disabled providers recorded in the chamber's own provider overlay. Used only
47
+ * by the MOCK path of `/api/models`, which has no config.yml `disabledProviders`
48
+ * to read — real mode gets the same answer natively.
49
+ */
50
+ function isDisabledStoredProvider(value: unknown): value is ProviderItem {
51
+ if (typeof value !== 'object' || value === null) return false;
52
+ const candidate = value as ProviderItem;
53
+ return typeof candidate.slug === 'string' && candidate.disabled === true;
54
+ }
55
+
56
+ export async function readStoredProvidersForModels(): Promise<ProviderItem[]> {
57
+ try {
58
+ const db = await getDb();
59
+ const parsed = await readSettingsJson<unknown>(db, PROVIDERS_SETTINGS_KEY, null);
60
+ if (!Array.isArray(parsed)) return [];
61
+ return parsed.filter(isDisabledStoredProvider);
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ function hiddenModelKey(provider: string, modelId: string): string {
68
+ return `${provider.trim().toLowerCase()}:${modelId}`;
69
+ }
70
+
71
+ async function readHiddenModelKeys(): Promise<Set<string>> {
72
+ try {
73
+ const db = await getDb();
74
+ const parsed = await readSettingsJson<unknown>(db, PROVIDERS_SETTINGS_KEY, null);
75
+ if (!Array.isArray(parsed)) return new Set();
76
+ const hidden = new Set<string>();
77
+ for (const provider of parsed) {
78
+ if (typeof provider !== 'object' || provider === null) continue;
79
+ const { slug, models } = provider as { slug?: unknown; models?: unknown };
80
+ if (typeof slug !== 'string' || !Array.isArray(models)) continue;
81
+ for (const model of models) {
82
+ if (typeof model !== 'object' || model === null) continue;
83
+ const { id, isVisible } = model as { id?: unknown; isVisible?: unknown };
84
+ if (typeof id === 'string' && isVisible === false) hidden.add(hiddenModelKey(slug, id));
85
+ }
86
+ }
87
+ return hidden;
88
+ } catch {
89
+ return new Set();
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A model is offered only when its provider is neither disabled in omp's
95
+ * config.yml (`disabledProviders`) nor carrying a per-model hide in the
96
+ * chamber overlay. Disabling the provider is the coarse switch — Settings →
97
+ * Providers flips it so the whole provider leaves the chat picker.
98
+ */
99
+ function filterSelectableModels(
100
+ available: OmpModel[],
101
+ disabledProviders: Set<string>,
102
+ hiddenModelKeys: Set<string>,
103
+ ): OmpModel[] {
104
+ return available.filter((model) => (
105
+ !disabledProviders.has(model.provider)
106
+ && !hiddenModelKeys.has(hiddenModelKey(model.provider, model.id))
107
+ ));
108
+ }
109
+
110
+ function isOmpModelEntry(value: unknown): value is OmpModel {
111
+ if (typeof value !== 'object' || value === null) return false;
112
+ const candidate = value as OmpModel;
113
+ return typeof candidate.id === 'string' && typeof candidate.provider === 'string';
114
+ }
115
+
116
+ function isLoginProvider(value: unknown): value is { id: string; name: string; authenticated: boolean } {
117
+ if (typeof value !== 'object' || value === null) return false;
118
+ const candidate = value as { id?: unknown; name?: unknown; authenticated?: unknown };
119
+ return typeof candidate.id === 'string'
120
+ && typeof candidate.name === 'string'
121
+ && typeof candidate.authenticated === 'boolean';
122
+ }
123
+
124
+ async function loadModels(): Promise<ModelsData> {
125
+ const availableResponse = await runUtilityCommand<{ models?: unknown }>(
126
+ { type: 'get_available_models' },
127
+ 120_000,
128
+ );
129
+ const available = Array.isArray(availableResponse.models)
130
+ ? availableResponse.models
131
+ .filter(isOmpModelEntry)
132
+ .map((model) => ({
133
+ ...model,
134
+ name: typeof model.name === 'string' && model.name.trim().length > 0 ? model.name : model.id,
135
+ }))
136
+ : [];
137
+
138
+ const disabledProviders = await readDisabledProviders();
139
+ const hiddenModelKeys = await readHiddenModelKeys();
140
+ const nameMap: Record<string, string> = {};
141
+ const thinkingLevels: Record<string, string[]> = {};
142
+ const modelList = filterSelectableModels(available, disabledProviders, hiddenModelKeys)
143
+ .map((m) => ({
144
+ id: m.id,
145
+ name: m.name,
146
+ provider: m.provider,
147
+ thinkingLevels: thinkingLevelsFor(m),
148
+ supportsFastMode: supportsFastMode(m),
149
+ contextWindow: m.contextWindow ?? undefined,
150
+ maxTokens: m.maxTokens ?? undefined,
151
+ cost: m.cost,
152
+ }))
153
+ .sort(compareModelEntries);
154
+
155
+ const loginResponse = await runUtilityCommand<{ providers?: unknown }>(
156
+ { type: 'get_login_providers' },
157
+ 30_000,
158
+ );
159
+ const loginProviders = Array.isArray(loginResponse.providers)
160
+ ? loginResponse.providers.filter(isLoginProvider)
161
+ : [];
162
+ const connectedProviders = loginProviders
163
+ .filter((provider) => provider.authenticated)
164
+ .map((provider) => ({ id: provider.id, name: provider.name, disabled: disabledProviders.has(provider.id) }));
165
+ for (const m of available) {
166
+ const key = `${m.provider}:${m.id}`;
167
+ nameMap[key] = m.name;
168
+ thinkingLevels[key] = thinkingLevelsFor(m);
169
+ }
170
+
171
+ let defaultModel: { provider: string; modelId: string } | null = null;
172
+ try {
173
+ const state = await runUtilityCommand<{ model?: { provider?: string; id?: string } }>(
174
+ { type: 'get_state' },
175
+ 30_000,
176
+ );
177
+ const provider = state.model?.provider;
178
+ const modelId = state.model?.id;
179
+ if (provider && modelId && available.some((m) => m.provider === provider && m.id === modelId)) {
180
+ defaultModel = { provider, modelId };
181
+ }
182
+ } catch {
183
+ // Default model is cosmetic — the models list is still useful without it.
184
+ }
185
+
186
+ return { models: nameMap, modelList, defaultModel, thinkingLevels, connectedProviders };
187
+ }
188
+
189
+ export const EMPTY_MODELS: ModelsData = {
190
+ models: {},
191
+ modelList: [],
192
+ defaultModel: null,
193
+ thinkingLevels: {},
194
+ };
195
+
196
+ export async function loadModelsWithCache(): Promise<ModelsData> {
197
+ const cached = globalThis.__ompChamberModelsCache;
198
+ if (cached && cached.expiresAt > Date.now()) return cached.data;
199
+ const data = await loadModels();
200
+ globalThis.__ompChamberModelsCache = { data, expiresAt: Date.now() + MODELS_CACHE_TTL_MS };
201
+ return data;
202
+ }
203
+
204
+ export function invalidateModelsCache(): void {
205
+ invalidateModelsCaches();
206
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Folds one omp RPC frame into the session wrapper's runtime state, and runs
8
+ * the settle-time side effects a terminal run triggers.
9
+ *
10
+ * Mirrors the client's `foldAgentEvent` (shared/lib/chat/omp/agent-events.ts):
11
+ * the same shape of problem — one frame, many state flags — solved the same way
12
+ * so both ends of the transport read alike. Split out of rpc-manager.ts so the
13
+ * wrapper owns only process lifecycle.
14
+ *
15
+ * The state machine is the load-bearing part. `turn_end` fires for EVERY turn
16
+ * in a multi-turn run, `agent_end` is the only frame that knows whether the run
17
+ * is terminal, and a `prompt` response that failed must clear the same flags a
18
+ * successful settle does — a missed clear strands the session as "running" and
19
+ * the idle reclaim can never take it back.
20
+ */
21
+
22
+ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
23
+ import {
24
+ consumeAutoTitleOutput,
25
+ triggerAutoSessionTitle,
26
+ type AutoTitleHost,
27
+ } from '@/server/lib/omp/session/auto-title.server';
28
+ import { scheduleQueueDelivery, type QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
29
+ import { loadStreamStatuses, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
30
+ import { NON_TERMINAL_CONTINUATION_GRACE_MS, type AgentEvent } from '@/server/lib/omp/rpc/constants';
31
+
32
+ /**
33
+ * Wrapper surface the fold reads and writes. Every field is owned by the
34
+ * wrapper; the fold mutates them in place exactly as the inline switch did.
35
+ * Composes the two host contracts the settle path hands the wrapper to
36
+ * (auto-title, queue delivery) so the wrapper itself satisfies this whole type.
37
+ */
38
+ export interface SessionFrameHost extends AutoTitleHost, QueueDeliveryHost {
39
+ promptRunning: boolean;
40
+ streaming: boolean;
41
+ compacting: boolean;
42
+ awaitingAgentStart: boolean;
43
+ awaitingAgentStartDeadline: number;
44
+ continuationGraceUntil: number;
45
+ emit(event: AgentEvent): void;
46
+ /** Remember a blocking ask/approval dialog so a reattaching client can answer it. */
47
+ trackUiDialog(frame: AgentEvent): void;
48
+ /** Fold a subagent frame into the liveness roster. */
49
+ observeSubagent(frame: AgentEvent, now: number): void;
50
+ }
51
+
52
+ /** What the caller must do after the fold. */
53
+ export interface FrameFoldResult {
54
+ /** The session list is stale and needs a rebuild. */
55
+ refreshSessionList: boolean;
56
+ /** The fold already handled this frame's user-visible effect and the caller
57
+ * must NOT forward it: a failed `prompt` response has emitted its own
58
+ * `prompt_error`, and a `command_output` belonging to the chamber's own
59
+ * background rename must stay out of the timeline. */
60
+ suppressForward: boolean;
61
+ }
62
+
63
+ /** True when the ending turn was a user abort rather than a natural stop. */
64
+ function endedAborted(messages: unknown): boolean {
65
+ return (
66
+ Array.isArray(messages) &&
67
+ messages.some((entry) => {
68
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
69
+ return (entry as Record<string, unknown>).stopReason === 'aborted';
70
+ })
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Write the terminal stream badge from the ending turn's own stopReason.
76
+ *
77
+ * The `abort` command already wrote `abort` at dispatch time, but the
78
+ * `agent_end` frame arrives later and previously flattened it to `finish`.
79
+ * Re-check the current row and only upgrade `stream` rows, so a `finish`
80
+ * written here can never clobber a newer run's live `stream`/`abort`.
81
+ */
82
+ async function markEndStatus(sessionId: string, messages: unknown): Promise<void> {
83
+ if (!sessionId) return;
84
+ const current = await loadStreamStatuses();
85
+ if (current[sessionId] !== 'stream') return;
86
+ await markStreamStatus(sessionId, endedAborted(messages) ? 'abort' : 'finish');
87
+ }
88
+
89
+ /** Apply one frame to the wrapper's runtime state. */
90
+ export function foldSessionFrame(host: SessionFrameHost, event: AgentEvent): FrameFoldResult {
91
+ const result: FrameFoldResult = { refreshSessionList: false, suppressForward: false };
92
+
93
+ switch (event.type) {
94
+ case 'agent_start':
95
+ host.promptRunning = true;
96
+ host.streaming = true;
97
+ host.awaitingAgentStart = false;
98
+ host.awaitingAgentStartDeadline = 0;
99
+ host.continuationGraceUntil = 0;
100
+ clearSessionFileCaches();
101
+ result.refreshSessionList = true;
102
+ if (host.sessionId) void markStreamStatus(host.sessionId, 'stream');
103
+ break;
104
+ case 'turn_start':
105
+ // Redundant with agent_start in the happy path, but the run-level
106
+ // counterpart can be missed (e.g. the state was cleared by a command
107
+ // between frames). The turn is proof enough the session is live.
108
+ if (host.sessionId) void markStreamStatus(host.sessionId, 'stream');
109
+ break;
110
+ case 'message_start':
111
+ // Same recovery as turn_start: a message frame is only emitted inside a
112
+ // live run, so its arrival re-arms the `stream` row no matter what stale
113
+ // status sits there (upsert overwrites any terminal badge).
114
+ if (host.sessionId) void markStreamStatus(host.sessionId, 'stream');
115
+ break;
116
+ case 'turn_end': {
117
+ // A multi-turn run emits turn_end for EVERY turn — intermediates end with
118
+ // `toolUse`/`stop` while the run keeps going (verified against omp
119
+ // 18.2.8: agent_start → turn_start → turn_end('toolUse') → turn_start →
120
+ // turn_end('stop') → agent_end, and the frame carries no isTerminal
121
+ // field). Only `aborted` is unambiguous here: the run is over, so the
122
+ // abort badge can be written before agent_end arrives. Everything else
123
+ // waits for agent_end, which knows isTerminal.
124
+ if (!host.sessionId) break;
125
+ const turn = event.message as Record<string, unknown> | undefined;
126
+ if (turn?.stopReason === 'aborted') void markStreamStatus(host.sessionId, 'abort');
127
+ break;
128
+ }
129
+ case 'agent_end':
130
+ if (event.isTerminal !== false) {
131
+ host.streaming = false;
132
+ host.promptRunning = false;
133
+ host.awaitingAgentStart = false;
134
+ host.awaitingAgentStartDeadline = 0;
135
+ host.continuationGraceUntil = 0;
136
+ clearSessionFileCaches();
137
+ const aborted = endedAborted(event.messages);
138
+ if (host.sessionId) void markEndStatus(host.sessionId, event.messages);
139
+ // A settled run is the moment a session becomes nameable: omp's own
140
+ // first-message titling is suppressed under `--mode rpc-ui`
141
+ // (PI_NO_TITLE), so the chamber asks for the same title itself. The
142
+ // trigger is a no-op once the session carries a name — see
143
+ // triggerAutoSessionTitle for why that check is the safety property.
144
+ if (!aborted) void triggerAutoSessionTitle(host);
145
+ // The run truly ended — the server, not the browser, decides whether a
146
+ // queued follow-up goes out next. A user-aborted run holds the queue
147
+ // (stop-all semantics): the next run end or an explicit send picks it up.
148
+ if (!aborted) scheduleQueueDelivery(host);
149
+ } else {
150
+ host.continuationGraceUntil = Date.now() + NON_TERMINAL_CONTINUATION_GRACE_MS;
151
+ }
152
+ break;
153
+ case 'prompt_result':
154
+ host.promptRunning = false;
155
+ host.awaitingAgentStart = false;
156
+ host.awaitingAgentStartDeadline = 0;
157
+ break;
158
+ case 'auto_compaction_start':
159
+ host.compacting = true;
160
+ break;
161
+ case 'auto_compaction_end':
162
+ host.compacting = false;
163
+ clearSessionFileCaches();
164
+ break;
165
+ case 'command_output':
166
+ // A `command_output` for a command the OPERATOR typed renders as a notice
167
+ // row. But the chamber fires its own `/rename` after a settled run, and
168
+ // omp answers it on the same frame — "Session renamed to …", or "Could not
169
+ // generate a session title" for a low-signal first message. Neither was
170
+ // asked for, and the second would be pure noise. The frame carries no
171
+ // correlation id, so attribution is by the request window.
172
+ result.suppressForward = consumeAutoTitleOutput(host);
173
+ break;
174
+ case 'session_info_update':
175
+ // A rename rewrote the fixed-width title slot in place; the scan cache is
176
+ // keyed on file mtime, which that write cannot move, so the list must be
177
+ // rebuilt explicitly.
178
+ clearSessionFileCaches();
179
+ result.refreshSessionList = true;
180
+ break;
181
+ case 'extension_ui_request':
182
+ host.trackUiDialog(event);
183
+ break;
184
+ // Subagent frames carry no turn state, but they are the only proof that
185
+ // work is still running once the parent turn has ended.
186
+ case 'subagent_lifecycle':
187
+ case 'subagent_progress':
188
+ case 'subagent_event':
189
+ host.observeSubagent(event, Date.now());
190
+ break;
191
+ case 'response': {
192
+ if (event.success === false && event.command === 'prompt') {
193
+ host.promptRunning = false;
194
+ host.awaitingAgentStart = false;
195
+ host.awaitingAgentStartDeadline = 0;
196
+ host.emit({ type: 'prompt_error', errorMessage: (event.error as string) ?? 'Prompt failed' });
197
+ // The caller's trailing notify covers this: promptRunning just flipped,
198
+ // so the running snapshot changes and the listeners fire. Suppress only
199
+ // the forward, or the error would land twice.
200
+ result.suppressForward = true;
201
+ }
202
+ break;
203
+ }
204
+ }
205
+
206
+ return result;
207
+ }
@@ -17,13 +17,12 @@
17
17
 
18
18
  import { RpcProcess, type RpcFrame } from '@/server/lib/omp/rpc/process';
19
19
  import { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
20
- import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
20
+ import { foldSessionFrame } from '@/server/lib/omp/rpc/frame-fold';
21
21
  import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
22
22
  import { dispatchSessionCommand } from '@/server/lib/omp/rpc/session-commands';
23
- import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
24
23
  import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
25
- import { loadStreamStatuses, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
26
- import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, NON_TERMINAL_CONTINUATION_GRACE_MS, READY_TIMEOUT_MS, SUBAGENT_STALE_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
24
+ import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
25
+ import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, READY_TIMEOUT_MS, SUBAGENT_STALE_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
27
26
 
28
27
  export type {
29
28
  AgentEvent,
@@ -68,6 +67,13 @@ export class AgentSessionWrapper {
68
67
  // set in initialize(). No other flag on this wrapper can see them, and an
69
68
  // idle reclaim or a spawn-mode reconcile must never kill a running subagent.
70
69
  private readonly subagents = new SubagentLiveness();
70
+ // One background `/rename` at a time. A settled run may be followed by
71
+ // another (queue delivery, a second prompt), and two overlapping generations
72
+ // would race to write the same title slot.
73
+ autoTitleInFlight = false;
74
+ // Epoch ms until which `command_output` frames belong to our own background
75
+ // rename and must not reach the timeline. 0 when nothing is outstanding.
76
+ autoTitleWindowUntil = 0;
71
77
  readonly idleDestroyMs: number;
72
78
  proc: RpcProcess;
73
79
  readonly cwd: string;
@@ -173,130 +179,28 @@ export class AgentSessionWrapper {
173
179
  this.destroy();
174
180
  }
175
181
 
176
- /** Write the terminal stream badge from the ending turn's own stopReason.
177
- * The `abort` command already wrote `abort` at dispatch time, but the
178
- * agent_end frame arrives later and previously flattened it to `finish`.
179
- * Re-check the current row and only upgrade `stream` rows, so a `finish`
180
- * written here can never clobber a newer run's live `stream`/`abort`. */
181
- private async markEndStatus(messages: unknown): Promise<void> {
182
- if (!this.sessionId) return;
183
- const aborted = Array.isArray(messages) && messages.some((entry) => {
184
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
185
- return (entry as Record<string, unknown>).stopReason === 'aborted';
186
- });
187
- const current = await loadStreamStatuses();
188
- if (current[this.sessionId] !== 'stream') return;
189
- await markStreamStatus(this.sessionId, aborted ? 'abort' : 'finish');
182
+ /** SessionFrameHost adapter: remember a blocking dialog for reattaching clients. */
183
+ trackUiDialog(frame: AgentEvent): void {
184
+ this.pendingUiDialogs.track(frame);
185
+ }
186
+
187
+ /** SessionFrameHost adapter: fold a subagent frame into the liveness roster. */
188
+ observeSubagent(frame: AgentEvent, now: number): void {
189
+ this.subagents.observe(frame, now);
190
190
  }
191
191
 
192
192
  private handleFrame(frame: RpcFrame): void {
193
193
  this.resetIdleTimer();
194
194
  const event = frame as AgentEvent;
195
- let refreshSessionList = false;
196
-
197
- switch (event.type) {
198
- case 'agent_start':
199
- this.promptRunning = true;
200
- this.streaming = true;
201
- this.awaitingAgentStart = false;
202
- this.awaitingAgentStartDeadline = 0;
203
- this.continuationGraceUntil = 0;
204
- clearSessionFileCaches();
205
- refreshSessionList = true;
206
- if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
207
- break;
208
- case 'turn_start':
209
- // Redundant with agent_start in the happy path, but the run-level
210
- // counterpart can be missed (e.g. the state was cleared by a command
211
- // between frames). The turn is proof enough the session is live.
212
- if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
213
- break;
214
- case 'message_start':
215
- // Same recovery as turn_start: a message frame is only emitted inside
216
- // a live run, so its arrival re-arms the `stream` row no matter what
217
- // stale status sits there (upsert overwrites any terminal badge).
218
- if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
219
- break;
220
- case 'turn_end': {
221
- // A multi-turn run emits turn_end for EVERY turn — intermediates end
222
- // with `toolUse`/`stop` while the run keeps going (verified against
223
- // omp 18.2.8: agent_start → turn_start → turn_end('toolUse') →
224
- // turn_start → turn_end('stop') → agent_end, and the frame carries no
225
- // isTerminal field). Only `aborted` is unambiguous here: the run is
226
- // over, so the abort badge can be written before agent_end arrives.
227
- // Everything else waits for agent_end, which knows isTerminal.
228
- if (!this.sessionId) break;
229
- const turn = event.message as Record<string, unknown> | undefined;
230
- if (turn?.stopReason === 'aborted') void markStreamStatus(this.sessionId, 'abort');
231
- break;
232
- }
233
- case 'agent_end':
234
- if (event.isTerminal !== false) {
235
- this.streaming = false;
236
- this.promptRunning = false;
237
- this.awaitingAgentStart = false;
238
- this.awaitingAgentStartDeadline = 0;
239
- this.continuationGraceUntil = 0;
240
- clearSessionFileCaches();
241
- // The turn's own stopReason is the ground truth for how it ended:
242
- // `aborted` = user stop, anything else (`stop`, `toolUse`, and the
243
- // ambiguous `error` that tool failures also produce) completes as
244
- // `finish`. This must not overwrite a fresh `abort` row from a NEWER
245
- // run — guard against downgrades by checking the current row first.
246
- const aborted = Array.isArray(event.messages) && event.messages.some((entry) => {
247
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
248
- return (entry as Record<string, unknown>).stopReason === 'aborted';
249
- });
250
- if (this.sessionId) void this.markEndStatus(event.messages);
251
- // The run truly ended — the server, not the browser, decides whether
252
- // a queued follow-up goes out next. A user-aborted run holds the
253
- // queue (stop-all semantics): the next run end or an explicit send
254
- // picks it up.
255
- if (!aborted) scheduleQueueDelivery(this);
256
- } else {
257
- this.continuationGraceUntil = Date.now() + NON_TERMINAL_CONTINUATION_GRACE_MS;
258
- }
259
- break;
260
- case 'prompt_result':
261
- this.promptRunning = false;
262
- this.awaitingAgentStart = false;
263
- this.awaitingAgentStartDeadline = 0;
264
- break;
265
- case 'auto_compaction_start':
266
- this.compacting = true;
267
- break;
268
- case 'auto_compaction_end':
269
- this.compacting = false;
270
- clearSessionFileCaches();
271
- break;
272
- case 'session_info_update':
273
- clearSessionFileCaches();
274
- refreshSessionList = true;
275
- break;
276
- case 'extension_ui_request':
277
- this.pendingUiDialogs.track(event);
278
- break;
279
- // Subagent frames carry no turn state, but they are the only proof that
280
- // work is still running once the parent turn has ended.
281
- case 'subagent_lifecycle':
282
- case 'subagent_progress':
283
- case 'subagent_event':
284
- this.subagents.observe(event, Date.now());
285
- break;
286
- case 'response': {
287
- if (event.success === false && event.command === 'prompt') {
288
- this.promptRunning = false;
289
- this.awaitingAgentStart = false;
290
- this.awaitingAgentStartDeadline = 0;
291
- this.emit({ type: 'prompt_error', errorMessage: (event.error as string) ?? 'Prompt failed' });
292
- notifyRunningChange();
293
- return;
294
- }
295
- break;
296
- }
297
- }
298
-
299
- this.emit(event);
195
+ // The state machine and its settle-time side effects live in frame-fold.ts;
196
+ // this method owns only the wrapper's own bookkeeping around it.
197
+ const { refreshSessionList, suppressForward } = foldSessionFrame(this, event);
198
+ // `suppressForward` withholds only the FRAME — a failed prompt response
199
+ // already emitted its own `prompt_error`, and the chamber's own background
200
+ // rename must not surface its diagnostics. The running-state notification
201
+ // still fires: the fold just cleared `promptRunning`, and that flip is what
202
+ // releases the sidebar's spinner.
203
+ if (!suppressForward) this.emit(event);
300
204
  notifyRunningChange({ refreshSessionList });
301
205
  }
302
206
 
@@ -22,7 +22,13 @@ import { RpcProcess } from '@/server/lib/omp/rpc/process';
22
22
 
23
23
  // Extensions stay ENABLED: they can register models and login providers, and
24
24
  // omitting them made the web UI's model/provider lists disagree with the CLI's.
25
- const UTILITY_EXTRA_ARGS = ['--no-session', '--no-skills', '--no-lsp'];
25
+ //
26
+ // Skills stay ENABLED too. `--no-skills` made `get_available_commands` omit
27
+ // every `skill:<name>` entry (measured: 45 commands without, 52 with), so the
28
+ // composer's `/` popup could not offer the skills omp happily executes — and
29
+ // the chamber's own disk scan missed provider roots omp reads (`~/.agents/skills`).
30
+ // Cost is nil: the ready frame lands at the same ~420ms either way.
31
+ const UTILITY_EXTRA_ARGS = ['--no-session', '--no-lsp'];
26
32
  const READY_TIMEOUT_MS = 60_000;
27
33
  // Longer than the 60s models-cache TTL on purpose: with idle-kill == TTL every
28
34
  // pause past a minute paid a cold multi-second respawn on top of the stale