ompchamber 1.2.1 → 2.0.1

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 (237) hide show
  1. package/README.md +143 -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.cb81980498.js +2 -0
  14. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  15. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  16. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  17. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  18. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  19. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  20. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  21. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  22. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  23. package/dist/client/static/js/async/q.22588845a9.js +21 -0
  24. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  25. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  26. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  27. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  28. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  29. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  31. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  32. package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
  33. package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
  34. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  35. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  36. package/dist/client/static/js/index.1fd9e1c543.js +38 -0
  37. package/package.json +3 -3
  38. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  39. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  40. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  41. package/src/client/components/settings/Modal.tsx +11 -2
  42. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  43. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  45. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  46. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  47. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  48. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  49. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  50. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  51. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  52. package/src/client/components/workspace/btw-panel/index.tsx +124 -45
  53. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  54. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  55. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  56. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  57. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  58. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  60. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  64. package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
  65. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
  66. package/src/client/components/workspace/diff-panel/index.tsx +8 -1
  67. package/src/client/components/workspace/editor/index.tsx +18 -2
  68. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  69. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  70. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  71. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  72. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  73. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  74. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  75. package/src/client/data/theme/terminal.ts +50 -52
  76. package/src/client/hooks/chat/btw/index.ts +139 -18
  77. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  78. package/src/client/hooks/chat/btw/mode.ts +111 -16
  79. package/src/client/hooks/chat/composer/index.ts +10 -4
  80. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  81. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  82. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  83. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  84. package/src/client/hooks/chat/timeline/index.ts +8 -20
  85. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  86. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  87. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  88. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  89. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  90. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  91. package/src/client/hooks/editor/save-file.ts +62 -0
  92. package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
  93. package/src/client/hooks/editor/use-file-editor.ts +38 -29
  94. package/src/client/hooks/editor/use-file-transfer.ts +5 -2
  95. package/src/client/hooks/settings/providers.ts +34 -3
  96. package/src/client/hooks/ui/open-file.ts +7 -1
  97. package/src/client/hooks/ui/theme.ts +82 -57
  98. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  99. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  100. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  101. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  102. package/src/client/hooks/workspace/search-stream.ts +24 -5
  103. package/src/client/main.tsx +6 -0
  104. package/src/client/tailwind.css +19 -56
  105. package/src/server/lib/btw/child.server.ts +41 -0
  106. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  107. package/src/server/lib/btw/prompt.ts +13 -5
  108. package/src/server/lib/btw/registry.server.ts +13 -4
  109. package/src/server/lib/btw/runtime.server.ts +149 -156
  110. package/src/server/lib/btw/schema.server.ts +27 -0
  111. package/src/server/lib/btw/service.server.ts +106 -5
  112. package/src/server/lib/btw/store.server.ts +57 -6
  113. package/src/server/lib/db/schema.ts +4 -0
  114. package/src/server/lib/db/settings-store.test.ts +65 -0
  115. package/src/server/lib/db/settings-store.ts +25 -0
  116. package/src/server/lib/fs/atomic-write.ts +42 -4
  117. package/src/server/lib/fs/config-lock.ts +74 -0
  118. package/src/server/lib/fs/git-ignore.ts +55 -5
  119. package/src/server/lib/fs/ripgrep.ts +119 -0
  120. package/src/server/lib/lifecycle/identity.ts +10 -64
  121. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  122. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  123. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  124. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  125. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  126. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  127. package/src/server/lib/models/provider-registry.server.ts +2 -2
  128. package/src/server/lib/models/registry.server.ts +1 -1
  129. package/src/server/lib/omp/config/behavior.ts +17 -17
  130. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  131. package/src/server/lib/omp/config/document.ts +128 -0
  132. package/src/server/lib/omp/config/extensions.ts +11 -10
  133. package/src/server/lib/omp/config/mcp.ts +4 -57
  134. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  135. package/src/server/lib/omp/config/models-config.ts +92 -0
  136. package/src/server/lib/omp/config/models-validation.ts +159 -0
  137. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  138. package/src/server/lib/omp/config/providers.ts +241 -211
  139. package/src/server/lib/omp/config/roles.ts +6 -14
  140. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  141. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  143. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  144. package/src/server/lib/omp/rpc/manager.ts +21 -21
  145. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  146. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  147. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  148. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  149. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  150. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  151. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  152. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  153. package/src/server/lib/queue/delivery-harness.ts +88 -0
  154. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  155. package/src/server/lib/queue/delivery.server.ts +105 -13
  156. package/src/server/lib/queue/store.server.ts +30 -5
  157. package/src/server/lib/terminal/foreground.test.ts +164 -0
  158. package/src/server/lib/terminal/foreground.ts +34 -6
  159. package/src/server/lib/terminal/history.ts +8 -7
  160. package/src/server/plugins/compress.ts +33 -5
  161. package/src/server/plugins/ssr.ts +14 -7
  162. package/src/server/routes/btw/topics.ts +89 -2
  163. package/src/server/routes/fs/action.test.ts +64 -0
  164. package/src/server/routes/fs/action.ts +10 -1
  165. package/src/server/routes/fs/git.ts +5 -6
  166. package/src/server/routes/fs/search.ts +17 -26
  167. package/src/server/routes/sessions/queue.ts +17 -4
  168. package/src/server/routes/settings/index.ts +2 -0
  169. package/src/server/routes/settings/model-override.ts +69 -0
  170. package/src/server/routes/settings/provider-models.ts +35 -12
  171. package/src/server/routes/settings/providers.ts +31 -1
  172. package/src/server/routes/settings/root.ts +8 -3
  173. package/src/shared/lib/chat/btw/client.ts +34 -0
  174. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  175. package/src/shared/lib/code/line-endings.test.ts +38 -0
  176. package/src/shared/lib/code/line-endings.ts +56 -0
  177. package/src/shared/lib/code/shiki-themes.ts +9 -5
  178. package/src/shared/lib/fs/file-tree.ts +17 -12
  179. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  180. package/src/shared/lib/markdown/autolink.ts +50 -0
  181. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  182. package/src/shared/lib/markdown/document-urls.ts +118 -0
  183. package/src/shared/lib/markdown/marked.ts +27 -15
  184. package/src/shared/lib/markdown/mermaid.ts +2 -2
  185. package/src/shared/lib/models/catalog.ts +1 -1
  186. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  187. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  188. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  189. package/src/shared/lib/settings/client.ts +10 -4
  190. package/src/shared/lib/settings/diff.test.ts +31 -0
  191. package/src/shared/lib/settings/diff.ts +37 -0
  192. package/src/shared/lib/theme/catalog.test.ts +168 -0
  193. package/src/shared/lib/theme/catalog.ts +98 -0
  194. package/src/shared/lib/theme/color.ts +88 -0
  195. package/src/shared/lib/theme/css.ts +70 -0
  196. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  197. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  198. package/src/shared/lib/theme/palettes/light.ts +313 -0
  199. package/src/shared/types/btw.ts +83 -4
  200. package/src/shared/types/index.ts +1 -0
  201. package/src/shared/types/settings/provider.ts +8 -1
  202. package/src/shared/types/settings/state.ts +10 -4
  203. package/src/shared/types/theme.ts +48 -0
  204. package/dist/client/static/css/index.b938f4a693.css +0 -2
  205. package/dist/client/static/js/77.8022a800b5.js +0 -139
  206. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  207. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  208. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  209. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  210. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  211. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  212. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  213. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  214. package/dist/client/static/js/async/m.2e1ea3db9e.js.LICENSE.txt +0 -6
  215. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  216. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  217. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  218. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  219. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  220. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  221. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  222. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  223. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  224. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  225. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  226. package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
  227. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
  228. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  229. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  234. /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
  235. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
  236. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  237. /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.1fd9e1c543.js.LICENSE.txt} +0 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `detectBusyShells` decides the terminal cap's budget, so it has to be right
8
+ * about the two states that matter: a shell at its prompt (not counted) and a
9
+ * shell running a command (counted). Both are observed against a real PTY,
10
+ * because the whole question is what the *kernel* says the terminal's
11
+ * foreground group is — a mocked probe would only test the mock.
12
+ *
13
+ * The probe's answer is cross-checked against `ps -o tpgid=`, which is the
14
+ * definition of the field being read: an offset that drifted makes the probe
15
+ * return null (its own pid guard rejects a mismatched struct) and fail these
16
+ * assertions rather than silently report a garbage group.
17
+ *
18
+ * Waiting is driven by the PTY stream, not by sleeps: a shell's prompt (or the
19
+ * marker a command prints before it parks) is the real signal that the
20
+ * foreground group has moved, so the assertions never race a guessed delay.
21
+ */
22
+
23
+ import { afterAll, describe, expect, test } from 'bun:test';
24
+
25
+ import { processProbe } from '@/server/lib/lifecycle/proc';
26
+ import { shellLaunch } from '@/server/lib/terminal/shell';
27
+ import { detectBusyShells, type ShellProcess } from '@/server/lib/terminal/foreground';
28
+
29
+ const describePosix = process.platform === 'win32' ? describe.skip : describe;
30
+
31
+ /** The shell the test drives, and the prompt it announces itself with. */
32
+ const SHELL = '/bin/sh';
33
+ /**
34
+ * Spelled `CHAM''BER` on the command line below: the PTY echoes what is typed,
35
+ * so a literal needle would be satisfied by the echo before the shell ever
36
+ * parsed the line — the same trap the busy marker avoids.
37
+ */
38
+ const PROMPT = 'CHAMBER> ';
39
+
40
+ /** The value `ps` prints for the same field the probe reads. */
41
+ async function psTpgid(pid: number): Promise<number> {
42
+ const proc = Bun.spawn(['ps', '-o', 'tpgid=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' });
43
+ const text = await new Response(proc.stdout).text();
44
+ await proc.exited;
45
+ return Number(text.trim());
46
+ }
47
+
48
+ interface PtyShell {
49
+ id: string;
50
+ pid: number;
51
+ term: Bun.Terminal;
52
+ proc: Bun.Subprocess;
53
+ /** Resolves the next time the PTY emits `needle`; output seen so far counts. */
54
+ until: (needle: string) => Promise<void>;
55
+ }
56
+
57
+ const spawned: PtyShell[] = [];
58
+
59
+ async function spawnShell(id: string): Promise<PtyShell> {
60
+ let seen = '';
61
+ let pending: { needle: string; resolve: () => void } | null = null;
62
+ const term = new Bun.Terminal({
63
+ cols: 80,
64
+ rows: 24,
65
+ name: 'xterm-256color',
66
+ data(_term, bytes) {
67
+ seen += new TextDecoder().decode(bytes);
68
+ if (pending && seen.includes(pending.needle)) {
69
+ const { resolve } = pending;
70
+ pending = null;
71
+ resolve();
72
+ }
73
+ },
74
+ });
75
+ // The production launch argv, not a bare `/bin/sh`. The runtime must spawn
76
+ // `detached: true`, and `setsid()` drops the controlling terminal — a shell
77
+ // that never re-opens the PTY reports `tpgid` as -1 (verified on Linux), which
78
+ // reads as "a command owns the terminal" and is what made the prompt
79
+ // assertion below fail there. `shellLaunch` is the shim that re-acquires it,
80
+ // so this is the shell shape the probe is actually asked about.
81
+ const proc = Bun.spawn(shellLaunch(SHELL), { terminal: term, detached: true, cwd: '/tmp' });
82
+ const shell: PtyShell = {
83
+ id,
84
+ pid: proc.pid,
85
+ term,
86
+ proc,
87
+ until(needle) {
88
+ if (seen.includes(needle)) return Promise.resolve();
89
+ return new Promise<void>((resolve) => {
90
+ pending = { needle, resolve };
91
+ });
92
+ },
93
+ };
94
+ spawned.push(shell);
95
+ return shell;
96
+ }
97
+
98
+ afterAll(async () => {
99
+ for (const shell of spawned) {
100
+ try {
101
+ shell.term.write('\u0003exit\n');
102
+ } catch {
103
+ // Already closed.
104
+ }
105
+ }
106
+ await Promise.all(spawned.map((shell) => shell.proc.exited.catch(() => undefined)));
107
+ });
108
+
109
+ describePosix('processProbe.foregroundGroup', () => {
110
+ test('reports the same group ps does for a live pid', async () => {
111
+ const fromProbe = processProbe.foregroundGroup(process.pid);
112
+ expect(fromProbe).not.toBeNull();
113
+ expect(fromProbe).toBe(await psTpgid(process.pid));
114
+ });
115
+
116
+ test('returns null for a pid that no longer exists', async () => {
117
+ const corpse = Bun.spawn(['/bin/sh', '-c', 'exit 0']);
118
+ const deadPid = corpse.pid;
119
+ await corpse.exited;
120
+ expect(processProbe.foregroundGroup(deadPid)).toBeNull();
121
+ });
122
+ });
123
+
124
+ describePosix('detectBusyShells', () => {
125
+ test('a shell at its prompt is not busy, and one running a command is', async () => {
126
+ const shell = await spawnShell('probe');
127
+ const shells: ShellProcess[] = [{ id: shell.id, pid: shell.pid }];
128
+
129
+ // The prompt is the shell printing from its own foreground group. It is
130
+ // named by us rather than assumed: the default is `$ ` for a user and `# `
131
+ // for root, and the assertion is about the foreground group, not the glyph.
132
+ shell.term.write("PS1='CHAM''BER> '\n");
133
+ await shell.until(PROMPT);
134
+ expect((await detectBusyShells(shells)).has(shell.id)).toBe(false);
135
+
136
+ // The marker is printed by a child that already owns the terminal, and the
137
+ // typed text spells it `REA''DY` — a literal needle would be matched by the
138
+ // PTY's own echo of the input, before the shell had even parsed the line.
139
+ shell.term.write("sh -c 'echo REA''DY; sleep 30'\n");
140
+ await shell.until('READY\r\n');
141
+ expect((await detectBusyShells(shells)).has(shell.id)).toBe(true);
142
+ });
143
+
144
+ test('falls back to ps when one pid cannot be probed, and still answers', async () => {
145
+ const shell = await spawnShell('fallback');
146
+ const corpse = Bun.spawn(['/bin/sh', '-c', 'exit 0']);
147
+ const ghostPid = corpse.pid;
148
+ await corpse.exited;
149
+
150
+ shell.term.write("sh -c 'echo REA''DY; sleep 30'\n");
151
+ await shell.until('READY\r\n');
152
+
153
+ // A dead pid is one the probe declines, which routes the whole set through
154
+ // `ps` — the path the BSDs take for every question.
155
+ const shells: ShellProcess[] = [
156
+ { id: shell.id, pid: shell.pid },
157
+ { id: 'ghost', pid: ghostPid },
158
+ ];
159
+ const busy = await detectBusyShells(shells);
160
+ expect(busy.has(shell.id)).toBe(true);
161
+ expect(busy.has('ghost')).toBe(false);
162
+ expect(busy.size).toBe(1);
163
+ });
164
+ });
@@ -19,11 +19,19 @@
19
19
  * switched a few times pins the budget with shells that are all idle at a
20
20
  * prompt, and the next attach is rejected with nothing actually running.
21
21
  *
22
- * Cost is one `ps` call for the whole registry, so this is safe to run on every
23
- * attach. It is advisory: any failure resolves to "not busy", because refusing
24
- * a shell to save a slot is worse than letting one extra through.
22
+ * The value comes from the platform's `ProcessProbe` — `libproc` on macOS,
23
+ * `/proc` on Linux — which is a synchronous read of a field the kernel already
24
+ * has, and one `ps` subprocess answered it before. Measured on 8 live shells:
25
+ * 30 ms for the spawn against 0.07 ms for the probe. `ps` stays as the fallback
26
+ * for the platforms with no probe (the BSDs) and for the case where a probe
27
+ * declines to answer, so the answer never depends on the fast path working.
28
+ *
29
+ * Advisory either way: any failure resolves to "not busy", because refusing a
30
+ * shell to save a slot is worse than letting one extra through.
25
31
  */
26
32
 
33
+ import { processProbe } from '@/server/lib/lifecycle/proc';
34
+
27
35
  const PS_TIMEOUT_MS = 1500;
28
36
 
29
37
  /** `pid` is the shell's process (it is the PTY session leader, so pgid === pid). */
@@ -32,10 +40,15 @@ export interface ShellProcess {
32
40
  pid: number;
33
41
  }
34
42
 
35
- export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<string>> {
43
+ /**
44
+ * The same question through `ps`, for a platform whose probe declined.
45
+ *
46
+ * Kept whole rather than merged into the probe path: it is the only mechanism
47
+ * that works everywhere a `ps` is installed, and its subprocess machinery
48
+ * (timeout, kill, reaping) is what the probe exists to avoid paying for.
49
+ */
50
+ async function busyShellsViaPs(shells: ShellProcess[]): Promise<Set<string>> {
36
51
  const busy = new Set<string>();
37
- if (shells.length === 0 || process.platform === 'win32') return busy;
38
-
39
52
  const byPid = new Map<number, string>();
40
53
  for (const shell of shells) byPid.set(shell.pid, shell.id);
41
54
 
@@ -71,3 +84,18 @@ export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<stri
71
84
  }
72
85
  return busy;
73
86
  }
87
+
88
+ export async function detectBusyShells(shells: ShellProcess[]): Promise<Set<string>> {
89
+ const busy = new Set<string>();
90
+ if (shells.length === 0 || process.platform === 'win32') return busy;
91
+
92
+ // All-or-nothing: one shell the probe cannot answer for sends the whole set
93
+ // through `ps`, which keeps a single answer for a single question instead of
94
+ // mixing two sources whose failure modes differ.
95
+ for (const shell of shells) {
96
+ const tpgid = processProbe.foregroundGroup(shell.pid);
97
+ if (tpgid === null) return busyShellsViaPs(shells);
98
+ if (tpgid !== shell.pid) busy.add(shell.id);
99
+ }
100
+ return busy;
101
+ }
@@ -93,18 +93,19 @@ export function trimUtf8ToBytes(bytes: Uint8Array, maxBytes: number): Uint8Array
93
93
  /**
94
94
  * Split `input` into the bytes that may be replayed and the trailing fragment
95
95
  * of an unfinished control sequence. Dropped sequences are queries only.
96
+ *
97
+ * The scan for the next ESC is `indexOf`, not a byte-at-a-time loop: this runs
98
+ * over the whole replay buffer, and a JS loop spends its time on the bytes
99
+ * between sequences rather than on the sequences themselves. Measured on
100
+ * 512 KiB: 0.2 ms scanning byte-by-byte against 0.0085 ms with `indexOf`.
96
101
  */
97
102
  export function sanitizeReplayChunk(input: Uint8Array): { kept: Uint8Array; rest: Uint8Array } {
98
103
  const parts: Uint8Array[] = [];
99
104
  let from = 0;
100
- let i = 0;
101
105
  let dropped = false;
102
106
 
103
- while (i < input.length) {
104
- if (input[i] !== ESC) {
105
- i += 1;
106
- continue;
107
- }
107
+ let i = input.indexOf(ESC);
108
+ while (i !== -1) {
108
109
  const scan = scanEscape(input, i);
109
110
  if (scan === null) {
110
111
  if (i > from) parts.push(input.subarray(from, i));
@@ -122,7 +123,7 @@ export function sanitizeReplayChunk(input: Uint8Array): { kept: Uint8Array; rest
122
123
  from = scan.end;
123
124
  dropped = true;
124
125
  }
125
- i = scan.end;
126
+ i = input.indexOf(ESC, scan.end);
126
127
  }
127
128
 
128
129
  if (!dropped) return { kept: input, rest: EMPTY };
@@ -11,15 +11,43 @@ import { brotliCompressSync, constants } from 'node:zlib';
11
11
  * The incoming body is buffered and a fresh `Response` is returned, because a
12
12
  * `Response` body can only be read once — reading it here would otherwise
13
13
  * leave Elysia nothing to send.
14
+ *
15
+ * Encodings are offered best-first: zstd, brotli, gzip. On a 905 KiB JSON body
16
+ * the three measured 0.48 ms / 8 KiB, 1.05 ms / 7 KiB and 2.22 ms / 19 KiB —
17
+ * zstd is both the fastest and, within 14% of brotli's ratio, effectively as
18
+ * small, and it stays ahead at every payload size measured (0.004 ms against
19
+ * 0.007 ms for gzip at 2 KiB). Brotli remains for a client that accepts `br`
20
+ * but not `zstd`, and gzip is the floor every client has.
14
21
  */
15
22
  const MIN_BYTES = 1024;
16
23
 
17
24
  const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|wasm|svg))/;
18
25
 
19
- function compress(body: Uint8Array<ArrayBuffer>, encoding: 'br' | 'gzip'): Uint8Array {
20
- return encoding === 'br'
21
- ? brotliCompressSync(body, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } })
22
- : Bun.gzipSync(body, { level: 6 });
26
+ /** Compression encodings this server can produce, best first. */
27
+ const ENCODINGS = ['zstd', 'br', 'gzip'] as const;
28
+
29
+ type Encoding = (typeof ENCODINGS)[number];
30
+
31
+ /**
32
+ * The best encoding the client accepts, or null.
33
+ *
34
+ * Plain substring matching, matching the previous `includes('br')` behavior: a
35
+ * browser's `Accept-Encoding` is an explicit set of alternatives, not a
36
+ * preference ordering this server has to weigh.
37
+ */
38
+ function negotiateEncoding(acceptEncoding: string): Encoding | null {
39
+ for (const encoding of ENCODINGS) {
40
+ if (acceptEncoding.includes(encoding)) return encoding;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function compress(body: Uint8Array<ArrayBuffer>, encoding: Encoding): Uint8Array {
46
+ if (encoding === 'zstd') return Bun.zstdCompressSync(body, { level: 3 });
47
+ if (encoding === 'br') {
48
+ return brotliCompressSync(body, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } });
49
+ }
50
+ return Bun.gzipSync(body, { level: 6 });
23
51
  }
24
52
 
25
53
  export async function maybeCompress(response: Response, acceptEncoding: string | null): Promise<Response> {
@@ -29,7 +57,7 @@ export async function maybeCompress(response: Response, acceptEncoding: string |
29
57
  if (!COMPRESSIBLE.test(contentType)) return response;
30
58
  if (response.status === 204 || response.status === 304) return response;
31
59
 
32
- const encoding = acceptEncoding.includes('br') ? 'br' : acceptEncoding.includes('gzip') ? 'gzip' : null;
60
+ const encoding = negotiateEncoding(acceptEncoding);
33
61
  if (!encoding) return response;
34
62
 
35
63
  const buffer = new Uint8Array(await response.arrayBuffer()) as Uint8Array<ArrayBuffer>;
@@ -3,6 +3,8 @@ import { join } from 'path';
3
3
  import { getDb } from '@/server/db.server';
4
4
  import { tryServeStatic } from '@/server/plugins/static';
5
5
  import { tryProxyDevAsset } from '@/server/plugins/dev-assets';
6
+ import { resolveTheme } from '@/shared/lib/theme/catalog';
7
+ import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
6
8
 
7
9
  const CLIENT_INDEX = join(process.cwd(), 'dist/client/index.html');
8
10
 
@@ -58,10 +60,6 @@ async function readSettings(): Promise<Record<string, unknown>> {
58
60
  }
59
61
  }
60
62
 
61
- function themeColor(theme: string): string {
62
- return theme === 'one-dark-pro-soft' ? '#282c34' : '#faf8f3';
63
- }
64
-
65
63
  const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
66
64
 
67
65
  /**
@@ -111,16 +109,25 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
111
109
 
112
110
  const settings = await readSettings();
113
111
  const chamberSettings = (settings.omp_chamber_settings ?? {}) as { theme?: string };
114
- const theme = chamberSettings.theme ?? 'paper';
112
+ // Resolved through the catalog: an id this build no longer ships (a
113
+ // downgrade, the retired `noir` alias) renders the default palette instead of
114
+ // an unstyled page.
115
+ const theme = resolveTheme(chamberSettings.theme);
115
116
  const initialIsMobile = MOBILE_UA.test(request.headers.get('user-agent') ?? '');
116
117
 
117
118
  const template = await readTemplate();
118
119
  const bootstrap = JSON.stringify({ initialIsMobile, appSettings: settings }).replace(/</g, '\\u003c');
120
+ // The template's own `<meta name="theme-color">` is REWRITTEN, not joined by
121
+ // a second tag: with both present Chrome reads the first one, so an appended
122
+ // tag would leave a dark theme painting light browser chrome.
123
+ const themeStyle = `<style id="${THEME_STYLE_ELEMENT_ID}">${themeStyleSheet()}</style>`;
119
124
 
120
125
  return new Response(
121
126
  template
122
- .replace('data-theme="paper"', `data-theme="${theme}"`)
123
- .replace('<!--app-head-->', `<meta name="theme-color" content="${themeColor(theme)}">`)
127
+ .replace('data-theme="paper"', `data-theme="${theme.id}"`)
128
+ .replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
129
+ .replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${theme.canvas}" />`)
130
+ .replace('<!--app-head-->', themeStyle)
124
131
  .replace('<!--app-bootstrap-->', `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
125
132
  { headers: HTML_HEADERS },
126
133
  );
@@ -14,8 +14,20 @@
14
14
 
15
15
  import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
16
16
  import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
17
+ import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
18
+ import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
17
19
  import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
18
- import { abortBtw, askBtw, getBtwState, promoteBtw, removeBtw } from '@/server/lib/btw/service.server';
20
+ import {
21
+ abortBtw,
22
+ askBtw,
23
+ getBtwState,
24
+ promoteBtw,
25
+ removeBtw,
26
+ respondBtwDialog,
27
+ setBtwApprovalMode,
28
+ setBtwModel,
29
+ setBtwThinkingLevel,
30
+ } from '@/server/lib/btw/service.server';
19
31
 
20
32
  /** Actions that can reject for a reason the client should show as-is. */
21
33
  const STATUS_BY_CODE: Record<string, number> = {
@@ -39,6 +51,40 @@ function readImages(raw: unknown): { images?: BtwImages } | { error: string } {
39
51
  return images ? { images } : {};
40
52
  }
41
53
 
54
+ /**
55
+ * Text files the client inlined for this question. Validated for shape only:
56
+ * the payload is a string the composer produced from bytes it already read, and
57
+ * the prompt builder escapes it into a fenced block.
58
+ */
59
+ function readTextFiles(raw: unknown): AttachedTextFileData[] {
60
+ if (!Array.isArray(raw)) return [];
61
+ return raw.flatMap((entry) => {
62
+ if (!entry || typeof entry !== 'object') return [];
63
+ const file = entry as Partial<AttachedTextFileData>;
64
+ if (typeof file.name !== 'string' || typeof file.content !== 'string') return [];
65
+ return [{
66
+ name: file.name,
67
+ content: file.content,
68
+ mimeType: typeof file.mimeType === 'string' ? file.mimeType : 'text/plain',
69
+ size: typeof file.size === 'number' ? file.size : file.content.length,
70
+ }];
71
+ });
72
+ }
73
+
74
+ /**
75
+ * The response body for one dialog, in the shape omp's RPC accepts: an answer
76
+ * value, a confirmation, or a cancellation. Anything else is refused rather
77
+ * than forwarded — a malformed response would leave the child blocked.
78
+ */
79
+ function readDialogResponse(raw: unknown): Record<string, unknown> | null {
80
+ if (!raw || typeof raw !== 'object') return null;
81
+ const response = raw as { value?: unknown; confirmed?: unknown; cancelled?: unknown };
82
+ if (response.cancelled === true) return { cancelled: true };
83
+ if (typeof response.confirmed === 'boolean') return { confirmed: response.confirmed };
84
+ if (typeof response.value === 'string') return { value: response.value };
85
+ return null;
86
+ }
87
+
42
88
  export async function action({ params, request }: ActionFunctionArgs) {
43
89
  const { sessionId } = params;
44
90
  if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
@@ -54,12 +100,53 @@ export async function action({ params, request }: ActionFunctionArgs) {
54
100
  const question = typeof body?.question === 'string' ? body.question : '';
55
101
  const parsed = readImages(body?.images);
56
102
  if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
57
- return json({ success: true, data: await askBtw(sessionId, { topicId, question, images: parsed.images }) });
103
+ const provider = typeof body?.provider === 'string' ? body.provider : '';
104
+ const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
105
+ return json({
106
+ success: true,
107
+ data: await askBtw(sessionId, {
108
+ topicId,
109
+ question,
110
+ images: parsed.images,
111
+ textFiles: readTextFiles(body?.textFiles),
112
+ ...(provider && modelId ? { model: { provider, id: modelId } } : {}),
113
+ ...(typeof body?.thinkingLevel === 'string' ? { thinkingLevel: body.thinkingLevel } : {}),
114
+ ...(isApprovalMode(body?.accessMode) ? { approvalMode: body.accessMode } : {}),
115
+ }),
116
+ });
58
117
  }
59
118
  case 'abort': {
60
119
  if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
61
120
  return json({ success: true, data: await abortBtw(sessionId, topicId) });
62
121
  }
122
+ case 'set_model': {
123
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
124
+ const provider = typeof body?.provider === 'string' ? body.provider : '';
125
+ const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
126
+ if (!provider || !modelId) return json({ error: 'provider and modelId are required', code: 'model_required' }, { status: 400 });
127
+ return json({ success: true, data: await setBtwModel(sessionId, topicId, provider, modelId) });
128
+ }
129
+ case 'set_thinking_level': {
130
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
131
+ const level = typeof body?.level === 'string' ? body.level : '';
132
+ if (!level) return json({ error: 'level is required', code: 'level_required' }, { status: 400 });
133
+ return json({ success: true, data: await setBtwThinkingLevel(sessionId, topicId, level) });
134
+ }
135
+ case 'set_access_mode': {
136
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
137
+ if (!isApprovalMode(body?.accessMode)) {
138
+ return json({ error: 'accessMode must be always-ask, write or yolo', code: 'invalid_access_mode' }, { status: 400 });
139
+ }
140
+ return json({ success: true, data: await setBtwApprovalMode(sessionId, topicId, body.accessMode) });
141
+ }
142
+ case 'dialog_response': {
143
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
144
+ const id = typeof body?.id === 'string' ? body.id : '';
145
+ if (!id) return json({ error: 'id is required', code: 'dialog_required' }, { status: 400 });
146
+ const response = readDialogResponse(body?.response);
147
+ if (!response) return json({ error: 'a response value is required', code: 'dialog_required' }, { status: 400 });
148
+ return json({ success: true, data: await respondBtwDialog(sessionId, topicId, id, response) });
149
+ }
63
150
  case 'promote': {
64
151
  if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
65
152
  return json({ success: true, data: await promoteBtw(sessionId, topicId) });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The save branch's byte contract.
8
+ *
9
+ * `multipart/form-data` normalizes every bare LF in a field value to CRLF in
10
+ * transit — the HTML serializer does it on the way out and Bun's parser agrees
11
+ * on the way in — so `content` reaches this route CRLF-normalized no matter what
12
+ * the editor buffer held. The file's own ending therefore arrives as its own
13
+ * `eol` field, and the bytes written to disk are built from it. Without that
14
+ * step every save rewrote the whole file's line endings: an LF file came back
15
+ * CRLF, which `git diff` reports as every line changed.
16
+ */
17
+
18
+ import { afterEach, describe, expect, test } from 'bun:test';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import { action } from '@/server/routes/fs/action';
22
+
23
+ /** Probe files live in the repo root — the one directory the root allow-list always accepts. */
24
+ const PROBE = path.join(process.cwd(), 'tmp-eol-probe.txt');
25
+
26
+ afterEach(() => {
27
+ fs.rmSync(PROBE, { force: true });
28
+ });
29
+
30
+ async function save(content: string, eol?: string) {
31
+ const form = new FormData();
32
+ form.append('actionType', 'save');
33
+ form.append('root', process.cwd());
34
+ form.append('path', 'tmp-eol-probe.txt');
35
+ form.append('content', content);
36
+ if (eol) form.append('eol', eol);
37
+ const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
38
+ expect(res.status).toBe(200);
39
+ return new Uint8Array(await Bun.file(PROBE).arrayBuffer());
40
+ }
41
+
42
+ describe('fs save line endings', () => {
43
+ test('an lf request writes lf, whatever multipart did in transit', async () => {
44
+ const bytes = await save('a\nb\n', 'lf');
45
+ expect([...bytes]).toEqual([0x61, 0x0a, 0x62, 0x0a]);
46
+ });
47
+
48
+ test('a crlf request writes crlf', async () => {
49
+ const bytes = await save('a\nb\n', 'crlf');
50
+ expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
51
+ });
52
+
53
+ test('a payload that already carries crlf is not doubled', async () => {
54
+ const bytes = await save('a\r\nb\r\n', 'crlf');
55
+ expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
56
+ });
57
+
58
+ // No `eol` means the route has no instruction, so the payload is written as
59
+ // multipart delivered it — which is CRLF, since the transport normalizes.
60
+ test('a payload without an eol field is written as delivered', async () => {
61
+ const bytes = await save('a\nb\n');
62
+ expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
63
+ });
64
+ });
@@ -5,6 +5,7 @@ import path from 'path';
5
5
  import { isMockMode } from '@/server/mock.server';
6
6
  import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
7
7
  import { runShell } from '@/server/lib/fs/shell';
8
+ import { toDiskText } from '@/shared/lib/code/line-endings';
8
9
  export async function action({ request }: ActionFunctionArgs) {
9
10
  const formData = await request.formData();
10
11
  const actionType = formData.get('actionType') as string;
@@ -24,7 +25,15 @@ export async function action({ request }: ActionFunctionArgs) {
24
25
 
25
26
  try {
26
27
  if (actionType === 'save') {
27
- const content = (formData.get('content') as string) ?? '';
28
+ const raw = (formData.get('content') as string) ?? '';
29
+ const eol = formData.get('eol');
30
+ // The bytes are built here rather than written verbatim: `multipart/
31
+ // form-data` normalizes every bare LF in a field value to CRLF in transit
32
+ // (the HTML serializer does it on the way out, Bun's parser agrees on the
33
+ // way in — same result either way), so an LF file would land on disk as
34
+ // CRLF and show up as a full-file diff. `eol` is the ending the client
35
+ // read the file with; without it the payload is written untouched.
36
+ const content = eol === 'crlf' || eol === 'lf' ? toDiskText(raw, eol) : raw;
28
37
  await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
29
38
  await Bun.write(fullPath, content);
30
39
  return json({ success: true });
@@ -181,14 +181,16 @@ export async function loader({ request }: LoaderFunctionArgs) {
181
181
  await remoteRefresh;
182
182
  const syncCount = syncRequested ? await gitSyncCount(targetDir) : undefined;
183
183
 
184
+ // No `repos`/`reposPending`/`activeRepo` here: the repo list travels on the
185
+ // `?reposOnly=1` branch, keyed to the root it was discovered for. Echoing a
186
+ // repo back in a status response is what let a panel adopt a repo it had
187
+ // never chosen — and adopt it under whichever workspace was active when the
188
+ // echo arrived.
184
189
  return json({
185
190
  changes,
186
191
  branch: branch || 'main',
187
192
  branches: branches.length ? branches : ['main'],
188
193
  remoteBranches,
189
- repos,
190
- reposPending: pending,
191
- activeRepo: repo,
192
194
  syncCount,
193
195
  });
194
196
  } catch (error: any) {
@@ -197,9 +199,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
197
199
  branch: 'main',
198
200
  branches: ['main'],
199
201
  remoteBranches: [],
200
- repos: repos.length ? repos : ['.'],
201
- reposPending: pending,
202
- activeRepo: repo,
203
202
  syncCount: { ahead: 0, behind: 0 },
204
203
  error: error?.message || 'Git error',
205
204
  }, { status: 200 });