ompchamber 1.2.1 → 2.0.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 (223) hide show
  1. package/README.md +131 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.74ff36419f.js +2 -0
  14. package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
  15. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  16. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  17. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  18. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  19. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  20. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  21. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  22. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  23. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  24. package/dist/client/static/js/async/q.33d6779f98.js +21 -0
  25. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  26. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  27. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  28. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  29. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  31. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  32. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  33. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  34. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  35. package/dist/client/static/js/index.c05535dbde.js +38 -0
  36. package/package.json +3 -3
  37. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  38. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  39. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  40. package/src/client/components/settings/Modal.tsx +11 -2
  41. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  42. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  43. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  45. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  46. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  47. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  48. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  49. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  50. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  51. package/src/client/components/workspace/btw-panel/index.tsx +124 -45
  52. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  53. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  54. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  59. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  63. package/src/client/components/workspace/editor/index.tsx +11 -2
  64. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  65. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  66. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  67. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  68. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  69. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  70. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  71. package/src/client/data/theme/terminal.ts +50 -52
  72. package/src/client/hooks/chat/btw/index.ts +139 -18
  73. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  74. package/src/client/hooks/chat/btw/mode.ts +111 -16
  75. package/src/client/hooks/chat/composer/index.ts +10 -4
  76. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  77. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  78. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  79. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  80. package/src/client/hooks/chat/timeline/index.ts +8 -20
  81. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  82. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  83. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  84. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  85. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  86. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  87. package/src/client/hooks/settings/providers.ts +34 -3
  88. package/src/client/hooks/ui/open-file.ts +7 -1
  89. package/src/client/hooks/ui/theme.ts +82 -57
  90. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  91. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  92. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  93. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  94. package/src/client/hooks/workspace/search-stream.ts +24 -5
  95. package/src/client/main.tsx +6 -0
  96. package/src/client/tailwind.css +19 -56
  97. package/src/server/lib/btw/child.server.ts +41 -0
  98. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  99. package/src/server/lib/btw/prompt.ts +13 -5
  100. package/src/server/lib/btw/registry.server.ts +13 -4
  101. package/src/server/lib/btw/runtime.server.ts +149 -156
  102. package/src/server/lib/btw/schema.server.ts +27 -0
  103. package/src/server/lib/btw/service.server.ts +106 -5
  104. package/src/server/lib/btw/store.server.ts +57 -6
  105. package/src/server/lib/db/schema.ts +4 -0
  106. package/src/server/lib/db/settings-store.test.ts +65 -0
  107. package/src/server/lib/db/settings-store.ts +25 -0
  108. package/src/server/lib/fs/atomic-write.ts +42 -4
  109. package/src/server/lib/fs/config-lock.ts +74 -0
  110. package/src/server/lib/fs/git-ignore.ts +55 -5
  111. package/src/server/lib/fs/ripgrep.ts +119 -0
  112. package/src/server/lib/lifecycle/identity.ts +10 -64
  113. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  114. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  115. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  116. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  117. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  118. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  119. package/src/server/lib/models/provider-registry.server.ts +2 -2
  120. package/src/server/lib/models/registry.server.ts +1 -1
  121. package/src/server/lib/omp/config/behavior.ts +17 -17
  122. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  123. package/src/server/lib/omp/config/document.ts +128 -0
  124. package/src/server/lib/omp/config/extensions.ts +11 -10
  125. package/src/server/lib/omp/config/mcp.ts +4 -57
  126. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  127. package/src/server/lib/omp/config/models-config.ts +92 -0
  128. package/src/server/lib/omp/config/models-validation.ts +159 -0
  129. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  130. package/src/server/lib/omp/config/providers.ts +241 -211
  131. package/src/server/lib/omp/config/roles.ts +6 -14
  132. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  133. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  134. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  135. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  136. package/src/server/lib/omp/rpc/manager.ts +21 -21
  137. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  138. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  139. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  140. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  141. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  142. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  143. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  144. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  145. package/src/server/lib/queue/delivery-harness.ts +88 -0
  146. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  147. package/src/server/lib/queue/delivery.server.ts +105 -13
  148. package/src/server/lib/queue/store.server.ts +30 -5
  149. package/src/server/lib/terminal/foreground.test.ts +164 -0
  150. package/src/server/lib/terminal/foreground.ts +34 -6
  151. package/src/server/lib/terminal/history.ts +8 -7
  152. package/src/server/plugins/compress.ts +33 -5
  153. package/src/server/plugins/ssr.ts +14 -7
  154. package/src/server/routes/btw/topics.ts +89 -2
  155. package/src/server/routes/fs/git.ts +5 -6
  156. package/src/server/routes/fs/search.ts +17 -26
  157. package/src/server/routes/sessions/queue.ts +17 -4
  158. package/src/server/routes/settings/index.ts +2 -0
  159. package/src/server/routes/settings/model-override.ts +69 -0
  160. package/src/server/routes/settings/provider-models.ts +35 -12
  161. package/src/server/routes/settings/providers.ts +31 -1
  162. package/src/server/routes/settings/root.ts +8 -3
  163. package/src/shared/lib/chat/btw/client.ts +34 -0
  164. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  165. package/src/shared/lib/code/shiki-themes.ts +9 -5
  166. package/src/shared/lib/fs/file-tree.ts +17 -12
  167. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  168. package/src/shared/lib/markdown/autolink.ts +50 -0
  169. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  170. package/src/shared/lib/markdown/document-urls.ts +118 -0
  171. package/src/shared/lib/markdown/marked.ts +27 -15
  172. package/src/shared/lib/markdown/mermaid.ts +2 -2
  173. package/src/shared/lib/models/catalog.ts +1 -1
  174. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  175. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  176. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  177. package/src/shared/lib/settings/client.ts +10 -4
  178. package/src/shared/lib/settings/diff.test.ts +31 -0
  179. package/src/shared/lib/settings/diff.ts +37 -0
  180. package/src/shared/lib/theme/catalog.test.ts +168 -0
  181. package/src/shared/lib/theme/catalog.ts +98 -0
  182. package/src/shared/lib/theme/color.ts +88 -0
  183. package/src/shared/lib/theme/css.ts +70 -0
  184. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  185. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  186. package/src/shared/lib/theme/palettes/light.ts +313 -0
  187. package/src/shared/types/btw.ts +83 -4
  188. package/src/shared/types/index.ts +1 -0
  189. package/src/shared/types/settings/provider.ts +8 -1
  190. package/src/shared/types/settings/state.ts +10 -4
  191. package/src/shared/types/theme.ts +48 -0
  192. package/dist/client/static/css/index.b938f4a693.css +0 -2
  193. package/dist/client/static/js/77.8022a800b5.js +0 -139
  194. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  195. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  196. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  197. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  198. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  199. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  200. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  201. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  202. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  203. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  204. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  205. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  206. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  207. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  208. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  209. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  210. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  211. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  212. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  213. package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
  215. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  216. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  217. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  218. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  219. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  220. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  221. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
  222. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -8,17 +8,19 @@
8
8
  *
9
9
  * Stack (run in order, all server/client safe — parsed synchronously):
10
10
  * 1. remend — heal incomplete markdown while streaming (before parse)
11
- * 2. marked — GFM true, breaks true, autolink via marked-linkify-it (CJK
12
- * punctuation is not swallowed into URLs), plus a KaTeX extension for
13
- * \(...\) inline, \[...\] block, and $$...$$ display math
11
+ * 2. marked — GFM true, breaks true, autolink via linkify-it (CJK
12
+ * punctuation is not swallowed into URLs, and a match carrying a backtick
13
+ * is left to the code-span rule — see autolink.ts), plus a KaTeX
14
+ * extension for \(...\) inline, \[...\] block, and $$...$$ display math
14
15
  *
15
16
  * The resulting HTML is NOT sanitized here — run the output through
16
- * DOMPurify (see sanitize.ts) before injecting, so raw HTML from the
17
- * assistant is escaped rather than executed.
17
+ * DOMPurify (see sanitize.ts) before injecting. What the source's own HTML
18
+ * means is the caller's call: `renderMarkdown` escapes it (assistant output,
19
+ * where markup is data) unless `allowHtml` says the source is a document.
18
20
  */
19
21
 
20
22
  import { Marked } from 'marked';
21
- import markedLinkifyIt from 'marked-linkify-it';
23
+ import { codeSafeAutolink } from '@/shared/lib/markdown/autolink';
22
24
  import remend from 'remend';
23
25
  import { highlightCode } from '@/shared/lib/code/syntax-highlight';
24
26
  import { MATH_PENDING_CLASS } from '@/shared/lib/markdown/katex';
@@ -64,7 +66,7 @@ const marked = new Marked({
64
66
  breaks: true,
65
67
  });
66
68
 
67
- marked.use(markedLinkifyIt());
69
+ marked.use(codeSafeAutolink());
68
70
 
69
71
  interface CodeToken {
70
72
  type: string;
@@ -217,19 +219,29 @@ function escapeHtmlOutsideCode(source: string): string {
217
219
  * parsing so the placeholder URL never leaks into the DOM as an href. */
218
220
  const INCOMPLETE_LINK_RE = /\[([^\]]*)\]\(streamdown:incomplete-link\)/g;
219
221
 
222
+ export interface RenderMarkdownOptions {
223
+ /**
224
+ * Leave the source's own HTML markup intact so marked parses it as markup.
225
+ *
226
+ * OFF (default) is the agent-output contract: assistant HTML is data, never
227
+ * markup, so `<div align="center">` and `<br>` arrive as visible text and
228
+ * DOMPurify is only the second line of defence. ON is the file-preview
229
+ * contract, where the source IS the document — a README's `<p align="center">`
230
+ * block, badge `<img>` and inline `<br>` are the layout its author wrote, and
231
+ * escaping them shows the tags instead of the page. DOMPurify still sanitizes
232
+ * the result, so scripts and event handlers never survive either way.
233
+ */
234
+ allowHtml?: boolean;
235
+ }
236
+
220
237
  /**
221
238
  * Heal streaming markdown (remend) then parse to HTML via marked.
222
239
  * Synchronous and side-effect free — safe in both server loaders and the
223
240
  * browser. The output must be sanitized with DOMPurify before injection.
224
- *
225
- * Raw HTML in the source is escaped (instruksi: assistant HTML is escaped,
226
- * never rendered) — `<script>`/`<img onerror>` come through as literal text
227
- * while backtick code is preserved. DOMPurify remains the second line of
228
- * defence.
229
241
  */
230
- export function renderMarkdown(markdown: string): string {
231
- const escaped = escapeHtmlOutsideCode(markdown);
232
- const healed = remend(escaped, { katex: true, inlineKatex: true })
242
+ export function renderMarkdown(markdown: string, options: RenderMarkdownOptions = {}): string {
243
+ const source = options.allowHtml ? markdown : escapeHtmlOutsideCode(markdown);
244
+ const healed = remend(source, { katex: true, inlineKatex: true })
233
245
  .replace(INCOMPLETE_LINK_RE, (_, text: string) => `<span class="md-incomplete-link">${text}</span>`);
234
246
  return (marked.parse(healed) as string).trim();
235
247
  }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { sanitizeMermaidSvg } from '@/shared/lib/markdown/sanitize';
21
+ import { isDarkTheme } from '@/shared/lib/theme/catalog';
21
22
 
22
23
  export interface MermaidRenderResult {
23
24
  rendered: number;
@@ -34,8 +35,7 @@ let mermaidPromise: Promise<MermaidModule['default']> | null = null;
34
35
  let lastThemeKey: ThemeKey | null = null;
35
36
 
36
37
  function resolveThemeKey(): ThemeKey {
37
- const theme = document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme');
38
- return theme === 'one-dark-pro-soft' || theme === 'noir' || theme === 'dark' ? 'dark' : 'default';
38
+ return isDarkTheme(document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme')) ? 'dark' : 'default';
39
39
  }
40
40
 
41
41
  function cacheKey(themeKey: ThemeKey, source: string): string {
@@ -17,7 +17,7 @@ export interface CatalogModelInfo {
17
17
  reasoning?: boolean;
18
18
  tool_call?: boolean;
19
19
  limit?: { context?: number; output?: number };
20
- cost?: { input?: number; output?: number; cache_read?: number };
20
+ cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number };
21
21
  }
22
22
 
23
23
  type ModelsDevCatalog = Record<string, { models?: Record<string, CatalogModelInfo> }>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Additive migrations for `session_stream_state`. `CREATE TABLE IF NOT EXISTS`
8
+ * leaves an existing table alone, so a database created before `owner_pid`
9
+ * existed would miss the column and every status read would fail on it.
10
+ *
11
+ * Split out of `@/server/lib/db/schema` for the same reason
12
+ * `@/shared/lib/workspace/schema-migrations` is: the DDL block there owns table
13
+ * creation, and each later column arrives in the module that explains it.
14
+ */
15
+
16
+ import type { DbClient } from '@/server/lib/db/client';
17
+
18
+ async function addColumn(db: DbClient, statement: string): Promise<void> {
19
+ try {
20
+ await db.exec(statement);
21
+ } catch {
22
+ // Already present — a fresh install gets the column from the DDL.
23
+ }
24
+ }
25
+
26
+ export async function migrateSessionStreamStateColumns(db: DbClient): Promise<void> {
27
+ // Which chamber process is running the session, so any instance reading this
28
+ // table can judge a `stream` row it does not own (see stream-state.server.ts).
29
+ await addColumn(db, 'ALTER TABLE session_stream_state ADD COLUMN owner_pid INTEGER;');
30
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The staleness rule behind the sidebar's self-heal. Getting it wrong is
8
+ * user-visible in both directions: too eager clears the spinner of a run that
9
+ * is still working, too lazy leaves a spinner turning forever for a run whose
10
+ * process is gone.
11
+ *
12
+ * The rule is deliberately a pure function of the ROW plus OS liveness, with no
13
+ * caller context. That is what makes several chamber instances agree: they all
14
+ * read the same row and ask the same question about the same pid.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+
19
+ import { isStaleStreamRow } from '@/shared/lib/omp/session/stream-state.server';
20
+
21
+ const OWNER = 4242;
22
+ const otherInstance = 9999;
23
+ /** OWNER and the other instance are alive; anything else, including 999999, is not. */
24
+ const alive = (pid: number) => pid === OWNER || pid === otherInstance;
25
+
26
+ describe('isStaleStreamRow', () => {
27
+ test('a row whose owner is alive is live, whoever is asking', () => {
28
+ expect(isStaleStreamRow({ session_id: 's1', owner_pid: OWNER }, alive)).toBe(false);
29
+ });
30
+
31
+ test('a row whose owner is gone is stale', () => {
32
+ expect(isStaleStreamRow({ session_id: 's1', owner_pid: 999999 }, alive)).toBe(true);
33
+ });
34
+
35
+ test('the verdict does not depend on which instance is asking', () => {
36
+ // The regression this shape exists for: several instances read ONE row and
37
+ // must report the same status. Judging by the reader's own runtime registry
38
+ // made a second instance call another instance's live run stale — the row
39
+ // is live here, and every asker must say so.
40
+ const row = { session_id: 's1', owner_pid: otherInstance };
41
+ const verdicts = [otherInstance, OWNER, 12345].map(() => isStaleStreamRow(row, alive));
42
+ expect(verdicts).toEqual([false, false, false]);
43
+ });
44
+
45
+ test('an ownerless row is stale, because nothing can vouch for its run', () => {
46
+ expect(isStaleStreamRow({ session_id: 's1', owner_pid: null }, alive)).toBe(true);
47
+ });
48
+
49
+ test('a row owned by the reader is judged by liveness like any other', () => {
50
+ // Self-ownership is not special-cased: the reader's own pid is alive while
51
+ // it runs, so its live rows survive.
52
+ expect(isStaleStreamRow({ session_id: 's1', owner_pid: OWNER }, (pid) => pid === OWNER)).toBe(false);
53
+ });
54
+ });
@@ -5,35 +5,49 @@
5
5
 
6
6
  /**
7
7
  * Server-authoritative per-session stream status, persisted in SQLite so the
8
- * sidebar can render spinner/check from the SAME loader that already refresh
8
+ * sidebar can render spinner/check from the SAME loader that already refreshes
9
9
  * on stream events (`omp:session-updated` → revalidator). Statuses:
10
10
  *
11
- * stream — a run is in flight (prompt dispatch → agent_start)
11
+ * stream — a run is in flight (prompt dispatch → agent_end)
12
12
  * finish — the run ended normally (agent_end), not yet seen
13
13
  * abort — the user stopped it, not yet seen
14
14
  *
15
15
  * A terminal status is a one-shot badge: opening the session acknowledges it
16
16
  * (`markStreamSeen` deletes the row), which is exactly the "check appears
17
- * once, disappears when the session is opened" contract. Rows marked
18
- * `stream` whose session is no longer running when the sidebar loads are
19
- * stale (chamber restart mid-run) and self-heal to `finish`.
17
+ * once, disappears when the session is opened" contract.
18
+ *
19
+ * `stream` rows record the pid of the chamber process running that session, so
20
+ * a `stream` row whose owner is gone self-heals to `finish` — see
21
+ * `healStaleStreamStatuses`. Ownership travels in the row because the database
22
+ * is SHARED by every chamber instance on the machine (~/.ompchamber/db.sqlite)
23
+ * while the runtime session registry is per process: without the column, a
24
+ * second instance opening its sidebar judged the first instance's live run by
25
+ * its own empty registry and cleared a spinner that was still working.
20
26
  *
21
27
  * All writes are fire-and-forget: a status write must never fail a prompt,
22
28
  * and the sidebar re-reads on the next revalidate anyway.
23
29
  */
24
30
 
25
31
  import { getDb } from '@/server/db.server';
32
+ import { isProcessAlive } from '@/server/lib/lifecycle/identity';
26
33
 
27
34
  export type SessionStreamStatus = 'stream' | 'finish' | 'abort';
28
35
 
29
36
  export async function markStreamStatus(sessionId: string, status: SessionStreamStatus): Promise<void> {
30
37
  try {
31
38
  const db = await getDb();
39
+ // The owner describes the process running a live row; a terminal badge
40
+ // belongs to no process, and leaving a pid on it would invite a later read
41
+ // to judge the badge by the liveness of a process it never concerned.
42
+ const ownerPid = status === 'stream' ? process.pid : null;
32
43
  await db.run(
33
- `INSERT INTO session_stream_state (session_id, status, updated_at)
34
- VALUES (?, ?, CURRENT_TIMESTAMP)
35
- ON CONFLICT(session_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at`,
36
- [sessionId, status],
44
+ `INSERT INTO session_stream_state (session_id, status, owner_pid, updated_at)
45
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
46
+ ON CONFLICT(session_id) DO UPDATE SET
47
+ status = excluded.status,
48
+ owner_pid = excluded.owner_pid,
49
+ updated_at = excluded.updated_at`,
50
+ [sessionId, status, ownerPid],
37
51
  );
38
52
  } catch {
39
53
  // Status tracking is best-effort by design.
@@ -97,23 +111,58 @@ export async function loadStreamStatuses(): Promise<Record<string, SessionStream
97
111
  }
98
112
 
99
113
  /**
100
- * Self-heal: `stream` rows belong to a live chamber process. After a restart
101
- * mid-run nothing will ever write the terminal status, so at read time a
102
- * `stream` row with no running session flips to `finish`.
114
+ * Whether a `stream` row has no live run behind it and must heal to `finish`.
115
+ *
116
+ * The owner pid is the only input: a row is live exactly while the process that
117
+ * wrote it is still running. EVERY chamber instance therefore reaches the same
118
+ * verdict from the same row — which is the point. Judging a row by the reader's
119
+ * own runtime registry instead made instances disagree about one run: a second
120
+ * instance opening its sidebar saw an empty registry, called the first
121
+ * instance's working run stale, and cleared a spinner that was still turning.
122
+ *
123
+ * A row with no owner predates the column and heals: new code always records a
124
+ * pid for `stream`, so an ownerless `stream` row can only come from an older
125
+ * process, whose run nothing can vouch for.
126
+ */
127
+ export function isStaleStreamRow(
128
+ row: { session_id: string; owner_pid: number | null },
129
+ isOwnerAlive: (ownerPid: number) => boolean,
130
+ ): boolean {
131
+ if (row.owner_pid === null) return true;
132
+ return !isOwnerAlive(row.owner_pid);
133
+ }
134
+
135
+ /**
136
+ * Self-heal: a `stream` row outlives its owner when a chamber process exits
137
+ * mid-run (crash, restart, SIGKILL) — nothing will ever write the terminal
138
+ * status for it. At read time every such row flips to `finish`, which is the
139
+ * honest outcome: the run is not going to finish.
140
+ *
141
+ * Deliberately takes no caller context. Liveness is the same question for every
142
+ * instance, so it is answered once, here, from the OS — the database is shared
143
+ * by all of them and they must all read the same status out of it.
144
+ *
145
+ * Runs on the sidebar loader's path, so the authoritative status travels with
146
+ * the same fetch that refreshes the list.
103
147
  */
104
- export async function healStaleStreamStatuses(runningIds: Set<string>): Promise<void> {
148
+ export async function healStaleStreamStatuses(): Promise<void> {
105
149
  try {
106
150
  const db = await getDb();
107
- const rows = (await db.all("SELECT session_id FROM session_stream_state WHERE status = 'stream'")) as {
151
+ const rows = (await db.all("SELECT session_id, owner_pid FROM session_stream_state WHERE status = 'stream'")) as {
108
152
  session_id: string;
153
+ owner_pid: number | null;
109
154
  }[];
110
- for (const row of rows) {
111
- if (!runningIds.has(row.session_id)) {
112
- await db.run("UPDATE session_stream_state SET status = 'finish', updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", [
113
- row.session_id,
114
- ]);
115
- }
116
- }
155
+ const stale = rows.filter((row) => isStaleStreamRow(row, isProcessAlive)).map((row) => row.session_id);
156
+ if (stale.length === 0) return;
157
+ // `status = 'stream'` is re-checked in the WHERE clause: a run that started
158
+ // between the read above and this write owns its row now, and healing it
159
+ // would clear the spinner of a session that is demonstrably working.
160
+ const placeholders = stale.map(() => '?').join(', ');
161
+ await db.run(
162
+ `UPDATE session_stream_state SET status = 'finish', updated_at = CURRENT_TIMESTAMP
163
+ WHERE status = 'stream' AND session_id IN (${placeholders})`,
164
+ stale,
165
+ );
117
166
  } catch {
118
167
  // Best-effort.
119
168
  }
@@ -76,14 +76,20 @@ export function writeSetting(key: string, value: unknown): void {
76
76
  }
77
77
 
78
78
  /**
79
- * Write a patch of chamber-blob keys (`theme`, `streamTransport`, …). The blob
80
- * is kept whole so the durable copy stays one round-trippable object, while the
81
- * snapshot exposes its keys flat.
79
+ * Write a patch of chamber-blob keys (`theme`, `streamTransport`, …). The
80
+ * snapshot exposes the blob's keys flat, so an edit applies to the next read
81
+ * without a round-trip.
82
+ *
83
+ * Only the patch travels, and the server merges it into the stored blob. The
84
+ * whole local blob must not be republished: this page's copy was taken when it
85
+ * booted, so a tab left open across an external change (a second tab, a direct
86
+ * API call) would overwrite the newer keys with its own stale ones on the next
87
+ * unrelated write — which is exactly how a theme chosen elsewhere reverted.
82
88
  */
83
89
  export function writeChamberSettings(patch: Record<string, any>): void {
84
90
  chamberBlob = { ...chamberBlob, ...patch };
85
91
  snapshot = { ...current(), ...patch };
86
- sendSettings({ [CHAMBER_SETTINGS_KEY]: chamberBlob });
92
+ sendSettings({ [CHAMBER_SETTINGS_KEY]: patch });
87
93
  }
88
94
 
89
95
  /** POST a settings patch to SQLite. Fire-and-forget; failures are non-fatal. */
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { diffSettings } from '@/shared/lib/settings/diff';
4
+
5
+ describe('diffSettings', () => {
6
+ test('an object patch persists only the keys it actually changed', () => {
7
+ const prev = { theme: 'paper', soundAlerts: true, binaryPath: '/bin/omp' };
8
+ const next = { ...prev, theme: 'nord-dark' };
9
+ expect(diffSettings(prev, next, { theme: 'nord-dark' })).toEqual({ theme: 'nord-dark' });
10
+ });
11
+
12
+ test('a no-op patch persists nothing', () => {
13
+ const prev = { theme: 'paper', soundAlerts: true };
14
+ expect(diffSettings(prev, { ...prev }, { theme: 'paper' })).toEqual({});
15
+ });
16
+
17
+ test('an updater function persists every key whose value it moved', () => {
18
+ const prev = { keybindingSend: 'Enter', keybindingNewLine: 'Shift + Enter' };
19
+ const next = { keybindingSend: 'Ctrl / Cmd + Enter', keybindingNewLine: 'Enter' };
20
+ expect(diffSettings(prev, next, (state) => ({ ...state, ...next }))).toEqual(next);
21
+ });
22
+
23
+ test('an unrelated update never republishes the theme it read', () => {
24
+ // The regression this exists for: a tab that booted on `paper` toggling an
25
+ // unrelated switch in a settings modal whose snapshot predates an external
26
+ // theme change must not write `theme` back.
27
+ const prev = { theme: 'paper', chatCompletionSound: true };
28
+ const patch = { chatCompletionSound: false };
29
+ expect(diffSettings(prev, { ...prev, ...patch }, patch)).toEqual({ chatCompletionSound: false });
30
+ });
31
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The subset of a settings update that should be persisted.
8
+ *
9
+ * The settings modal keeps a full `SettingsState` snapshot from the moment it
10
+ * opened and hands every edit to one writer. Writing the whole object back
11
+ * republishes values the user never touched, from a snapshot that may be older
12
+ * than what another writer (a second tab, an API call) has since stored — the
13
+ * stale copy wins purely because it was written last.
14
+ *
15
+ * A key is persisted only when the update changed it:
16
+ *
17
+ * - **Object patch** — the keys it names, but only those whose value actually
18
+ * differs, so a no-op re-render does not touch the row.
19
+ * - **Updater function** — every key whose value differs between `prev` and
20
+ * `next`; the function's own intent is not inspectable, so the diff is.
21
+ *
22
+ * Values are compared by identity, which is what `SettingsState` holds: strings,
23
+ * numbers and booleans. A nested object would compare by reference and be
24
+ * reported as changed — acceptable here, and better than dropping a real edit.
25
+ */
26
+ export function diffSettings<T extends Record<string, any>>(
27
+ prev: T,
28
+ next: T,
29
+ update: Partial<T> | ((prev: T) => T),
30
+ ): Partial<T> {
31
+ const patch: Partial<T> = {};
32
+ const keys = typeof update === 'function' ? (Object.keys(next) as (keyof T)[]) : (Object.keys(update) as (keyof T)[]);
33
+ for (const key of keys) {
34
+ if (next[key] !== prev[key]) patch[key] = next[key];
35
+ }
36
+ return patch;
37
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The catalog is the single source of truth for what the chamber can paint, and
8
+ * every failure mode here is silent at runtime: an unknown id renders the
9
+ * default palette (a theme that "doesn't work"), a missing color renders as an
10
+ * empty declaration (an invisible swatch, a transparent panel), a wrong variant
11
+ * leaves a dark theme with light scrollbars and light-theme code blocks.
12
+ */
13
+
14
+ import { describe, expect, test } from 'bun:test';
15
+
16
+ import {
17
+ DEFAULT_THEME_ID,
18
+ THEMES,
19
+ THEME_SWATCHES,
20
+ isDarkTheme,
21
+ resolveTheme,
22
+ resolveThemeId,
23
+ } from '@/shared/lib/theme/catalog';
24
+ import { themeStyleSheet } from '@/shared/lib/theme/css';
25
+ import { mixHex, parseHexColor } from '@/shared/lib/theme/color';
26
+ import { getXtermTheme } from '@/client/data/theme/terminal';
27
+
28
+ const COLOR_KEYS = ['canvas', 'paper', 'ink', 'error', 'success', 'info', 'warning', 'meta'] as const;
29
+ const COLOR_VALUE = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8})$/i;
30
+
31
+ describe('theme catalog', () => {
32
+ test('ships the chamber palettes plus every ported variant', () => {
33
+ expect(THEMES.length).toBe(45);
34
+ // 21 ported light + paper/contrast; 21 ported dark + one-dark-pro-soft.
35
+ expect(THEMES.filter((t) => t.variant === 'light').length).toBe(23);
36
+ expect(THEMES.filter((t) => t.variant === 'dark').length).toBe(22);
37
+ });
38
+
39
+ test('ids are unique and each palette carries a paintable color for every slot', () => {
40
+ const ids = new Set<string>();
41
+ for (const theme of THEMES) {
42
+ expect(ids.has(theme.id)).toBe(false);
43
+ ids.add(theme.id);
44
+ expect(theme.name.length).toBeGreaterThan(0);
45
+ expect(theme.family.length).toBeGreaterThan(0);
46
+ for (const key of COLOR_KEYS) {
47
+ expect(theme[key]).toMatch(COLOR_VALUE);
48
+ }
49
+ }
50
+ });
51
+
52
+ test('the default is the shipped E-Ink palette', () => {
53
+ expect(DEFAULT_THEME_ID).toBe('paper');
54
+ expect(resolveTheme(DEFAULT_THEME_ID).name).toBe('E-Ink Paper');
55
+ });
56
+
57
+ test('an id the catalog does not know resolves to the default, never to nothing', () => {
58
+ // `noir` shipped until this change as a byte-identical alias of
59
+ // `one-dark-pro-soft`; a settings row naming it must still render.
60
+ for (const unknown of ['noir', 'nonsense', '', undefined, null]) {
61
+ expect(resolveTheme(unknown).id).toBe(DEFAULT_THEME_ID);
62
+ expect(resolveThemeId(unknown)).toBe(DEFAULT_THEME_ID);
63
+ }
64
+ });
65
+
66
+ test('every ported family pairs a light entry with a dark one', () => {
67
+ const byFamily = new Map<string, Set<string>>();
68
+ for (const theme of THEMES) {
69
+ const variants = byFamily.get(theme.family) ?? new Set<string>();
70
+ variants.add(theme.variant);
71
+ byFamily.set(theme.family, variants);
72
+ }
73
+ // `paper`/`contrast` (light-only) and `one-dark-pro-soft` (dark-only) are
74
+ // the chamber's own single-variant palettes; every ported family has both.
75
+ const singles = [...byFamily].filter(([, variants]) => variants.size === 1).map(([family]) => family);
76
+ expect(singles.sort()).toEqual(['contrast', 'one-dark-pro-soft', 'paper']);
77
+ });
78
+
79
+ test('swatches name every color the cards can show', () => {
80
+ expect(THEME_SWATCHES.map((s) => s.key)).toEqual([...COLOR_KEYS]);
81
+ });
82
+ });
83
+
84
+ describe('generated stylesheet', () => {
85
+ const css = themeStyleSheet();
86
+
87
+ test('defines every palette under a selector that beats :root', () => {
88
+ for (const theme of THEMES) {
89
+ // `html[...]` (0,1,1) outranks the stylesheet's `:root` (0,1,0); a bare
90
+ // `[data-theme]` would tie and lose on document order.
91
+ expect(css).toContain(`html[data-theme="${theme.id}"]`);
92
+ }
93
+ expect(css.match(/html\[data-theme="/g)?.length).toBe(THEMES.length);
94
+ });
95
+
96
+ test('carries each palette\'s colors and its color-scheme', () => {
97
+ expect(css).toContain('--theme-canvas: #1F2430;');
98
+ expect(css).toContain('color-scheme: dark;');
99
+ expect(css).toContain('color-scheme: light;');
100
+ });
101
+
102
+ test('derives the hover shades instead of pinning a hex that fits one palette', () => {
103
+ expect(css).toContain('--theme-info-hover: color-mix(in srgb, var(--theme-info) 85%, var(--theme-ink));');
104
+ });
105
+ });
106
+
107
+ describe('theme variant verdict', () => {
108
+ test('follows the palette, not a hand-kept id list', () => {
109
+ expect(isDarkTheme('paper')).toBe(false);
110
+ expect(isDarkTheme('one-dark-pro-soft')).toBe(true);
111
+ expect(isDarkTheme('nord-dark')).toBe(true);
112
+ expect(isDarkTheme('nord-light')).toBe(false);
113
+ expect(isDarkTheme('tokyonight-light')).toBe(false);
114
+ expect(isDarkTheme('tokyonight-dark')).toBe(true);
115
+ });
116
+
117
+ test('every dark palette is dark and every light palette is not', () => {
118
+ for (const theme of THEMES) {
119
+ expect(isDarkTheme(theme.id)).toBe(theme.variant === 'dark');
120
+ }
121
+ });
122
+ });
123
+
124
+ describe('color math', () => {
125
+ test('parses the notations the ported palettes actually use', () => {
126
+ expect(parseHexColor('#FFF')).toEqual({ r: 255, g: 255, b: 255, a: 1 });
127
+ expect(parseHexColor('#f4f1ea')).toEqual({ r: 244, g: 241, b: 234, a: 1 });
128
+ expect(parseHexColor('#dbd7caee')?.a).toBeCloseTo(0.933, 3);
129
+ expect(parseHexColor('rebeccapurple')).toBeNull();
130
+ });
131
+
132
+ test('mixes toward the blend color and composites its alpha', () => {
133
+ expect(mixHex('#000000', '#ffffff', 0.5)).toBe('#808080');
134
+ expect(mixHex('#000000', '#ffffff', 0)).toBe('#000000');
135
+ expect(mixHex('#000000', '#ffffff', 1)).toBe('#ffffff');
136
+ // A fully transparent blend leaves the base untouched rather than
137
+ // producing a black hole.
138
+ expect(mixHex('#123456', '#ffffff00', 1)).toBe('#123456');
139
+ expect(mixHex('nonsense', '#ffffff', 1)).toBe('nonsense');
140
+ });
141
+ });
142
+
143
+ describe('terminal palette', () => {
144
+ test('follows the active theme rather than a fixed light/dark pair', () => {
145
+ const nord = getXtermTheme('nord-dark');
146
+ const paper = getXtermTheme('paper');
147
+ expect(nord.background).toBe('#1F2430');
148
+ expect(nord.foreground).toBe('#E5E9F0');
149
+ expect(paper.background).toBe('#f4f1ea');
150
+ expect(paper.foreground).toBe('#141310');
151
+ });
152
+
153
+ test('emits literal colors — xterm cannot parse color-mix()', () => {
154
+ for (const theme of THEMES) {
155
+ for (const value of Object.values(getXtermTheme(theme.id))) {
156
+ expect(value).toMatch(COLOR_VALUE);
157
+ }
158
+ }
159
+ });
160
+
161
+ test('takes its signal colors from the palette', () => {
162
+ const dracula = getXtermTheme('dracula-dark');
163
+ expect(dracula.red).toBe('#FF5555');
164
+ expect(dracula.green).toBe('#50FA7B');
165
+ expect(dracula.yellow).toBe('#FFB86C');
166
+ expect(dracula.cursor).toBe('#BD93F9');
167
+ });
168
+ });