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
@@ -18,7 +18,8 @@
18
18
  */
19
19
 
20
20
  import { getDb } from '@/server/db.server';
21
- import type { BtwModel, BtwTopic, BtwTurn, BtwTurnStatus } from '@/shared/types';
21
+ import { isApprovalMode, type ApprovalMode } from '@/shared/lib/omp/config/access-mode';
22
+ import type { BtwModel, BtwTopic, BtwTurn, BtwTurnStatus, ChatMessageData } from '@/shared/types';
22
23
 
23
24
  interface TopicRow {
24
25
  id: string;
@@ -27,6 +28,8 @@ interface TopicRow {
27
28
  provider: string | null;
28
29
  model_id: string | null;
29
30
  model_name: string | null;
31
+ thinking_level: string | null;
32
+ approval_mode: string | null;
30
33
  leaf_id: string | null;
31
34
  promoted_session_id: string | null;
32
35
  created_at: number;
@@ -38,6 +41,7 @@ interface TurnRow {
38
41
  turn_index: number;
39
42
  question: string;
40
43
  answer: string;
44
+ messages: string;
41
45
  status: string;
42
46
  created_at: number;
43
47
  updated_at: number;
@@ -67,6 +71,8 @@ function rowToTopic(row: TopicRow): BtwTopic {
67
71
  createdAt: row.created_at,
68
72
  updatedAt: row.updated_at,
69
73
  ...(model ? { model } : {}),
74
+ ...(row.thinking_level ? { thinkingLevel: row.thinking_level } : {}),
75
+ ...(isApprovalMode(row.approval_mode) ? { approvalMode: row.approval_mode } : {}),
70
76
  ...(row.promoted_session_id ? { promotedSessionId: row.promoted_session_id } : {}),
71
77
  turns: [],
72
78
  };
@@ -77,12 +83,25 @@ function rowToTurn(row: TurnRow): BtwTurn {
77
83
  index: row.turn_index,
78
84
  question: row.question,
79
85
  answer: row.answer,
86
+ messages: parseMessages(row.messages),
80
87
  status: isBtwTurnStatus(row.status) ? row.status : 'interrupted',
81
88
  createdAt: row.created_at,
82
89
  updatedAt: row.updated_at,
83
90
  };
84
91
  }
85
92
 
93
+ /** A turn's stored conversation. A row written before the column existed (or a
94
+ * corrupted one) reads as an empty conversation rather than throwing. */
95
+ function parseMessages(raw: string | null | undefined): ChatMessageData[] {
96
+ if (!raw) return [];
97
+ try {
98
+ const value: unknown = JSON.parse(raw);
99
+ return Array.isArray(value) ? (value as ChatMessageData[]) : [];
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
86
105
  /** Every topic of a session, oldest first, each with its turns in order. */
87
106
  export async function listBtwTopics(sessionId: string): Promise<BtwTopic[]> {
88
107
  const db = await getDb();
@@ -119,6 +138,8 @@ export interface NewBtwTopicInput {
119
138
  sessionId: string;
120
139
  title: string;
121
140
  model?: BtwModel;
141
+ thinkingLevel?: string;
142
+ approvalMode?: ApprovalMode;
122
143
  /** Parent transcript leaf when the topic was created (promotion guard). */
123
144
  leafId?: string | null;
124
145
  }
@@ -133,6 +154,8 @@ export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic>
133
154
  provider: input.model?.provider ?? null,
134
155
  model_id: input.model?.id ?? null,
135
156
  model_name: input.model?.name ?? null,
157
+ thinking_level: input.thinkingLevel ?? null,
158
+ approval_mode: input.approvalMode ?? null,
136
159
  leaf_id: input.leafId ?? null,
137
160
  promoted_session_id: null,
138
161
  created_at: now,
@@ -140,9 +163,21 @@ export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic>
140
163
  };
141
164
  await db.run(
142
165
  `INSERT INTO btw_topics
143
- (id, session_id, title, provider, model_id, model_name, leaf_id, promoted_session_id, created_at, updated_at)
144
- VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
145
- [row.id, row.session_id, row.title, row.provider, row.model_id, row.model_name, row.leaf_id, now, now],
166
+ (id, session_id, title, provider, model_id, model_name, thinking_level, approval_mode, leaf_id, promoted_session_id, created_at, updated_at)
167
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
168
+ [
169
+ row.id,
170
+ row.session_id,
171
+ row.title,
172
+ row.provider,
173
+ row.model_id,
174
+ row.model_name,
175
+ row.thinking_level,
176
+ row.approval_mode,
177
+ row.leaf_id,
178
+ now,
179
+ now,
180
+ ],
146
181
  );
147
182
  return rowToTopic(row);
148
183
  }
@@ -165,11 +200,11 @@ export async function appendBtwTurn(topicId: string, question: string): Promise<
165
200
  return turnIndex;
166
201
  }
167
202
 
168
- /** Patch one turn's answer and/or status. */
203
+ /** Patch one turn's answer, conversation and/or status. */
169
204
  export async function updateBtwTurn(
170
205
  topicId: string,
171
206
  turnIndex: number,
172
- patch: { answer?: string; status?: BtwTurnStatus },
207
+ patch: { answer?: string; status?: BtwTurnStatus; messages?: ChatMessageData[] },
173
208
  ): Promise<void> {
174
209
  const db = await getDb();
175
210
  const sets: string[] = ['updated_at = ?'];
@@ -182,6 +217,10 @@ export async function updateBtwTurn(
182
217
  sets.push('status = ?');
183
218
  values.push(patch.status);
184
219
  }
220
+ if (patch.messages !== undefined) {
221
+ sets.push('messages = ?');
222
+ values.push(JSON.stringify(patch.messages));
223
+ }
185
224
  await db.run(`UPDATE btw_turns SET ${sets.join(', ')} WHERE topic_id = ? AND turn_index = ?`, [
186
225
  ...values,
187
226
  topicId,
@@ -223,6 +262,18 @@ export async function setBtwTopicModel(topicId: string, model: BtwModel): Promis
223
262
  ]);
224
263
  }
225
264
 
265
+ /** Record the thinking level the side child runs at (`null` clears it). */
266
+ export async function setBtwTopicThinkingLevel(topicId: string, level: string | null): Promise<void> {
267
+ const db = await getDb();
268
+ await db.run('UPDATE btw_topics SET thinking_level = ?, updated_at = ? WHERE id = ?', [level, Date.now(), topicId]);
269
+ }
270
+
271
+ /** Record the approval mode the side child was spawned with. */
272
+ export async function setBtwTopicApprovalMode(topicId: string, mode: ApprovalMode): Promise<void> {
273
+ const db = await getDb();
274
+ await db.run('UPDATE btw_topics SET approval_mode = ?, updated_at = ? WHERE id = ?', [mode, Date.now(), topicId]);
275
+ }
276
+
226
277
  export async function setBtwTopicPromoted(topicId: string, promotedSessionId: string): Promise<void> {
227
278
  const db = await getDb();
228
279
  await db.run('UPDATE btw_topics SET promoted_session_id = ?, updated_at = ? WHERE id = ?', [
@@ -15,6 +15,7 @@ import type { DbClient } from '@/server/lib/db/client';
15
15
  import { migrateQueueTableFk } from '@/server/lib/queue/schema-migration.server';
16
16
  import { ensureBtwSchema } from '@/server/lib/btw/schema.server';
17
17
  import { migrateWorkspaceFolderColumns } from '@/shared/lib/workspace/schema-migrations';
18
+ import { migrateSessionStreamStateColumns } from '@/shared/lib/omp/session/schema-migrations';
18
19
 
19
20
  export async function initSchema(db: DbClient): Promise<void> {
20
21
  await db.exec(`
@@ -84,6 +85,7 @@ export async function initSchema(db: DbClient): Promise<void> {
84
85
  CREATE TABLE IF NOT EXISTS session_stream_state (
85
86
  session_id TEXT PRIMARY KEY,
86
87
  status TEXT NOT NULL,
88
+ owner_pid INTEGER,
87
89
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
88
90
  );
89
91
  `);
@@ -92,6 +94,8 @@ export async function initSchema(db: DbClient): Promise<void> {
92
94
  // Installs older `queued_messages` schemas carry an invalid FK (see the
93
95
  // migration module); rebuild once so real-session queue inserts work.
94
96
  await migrateQueueTableFk(db);
97
+ // `owner_pid` arrived after `session_stream_state` shipped; see the migration.
98
+ await migrateSessionStreamStateColumns(db);
95
99
  // Side-question tables live in their own module (see schema.server.ts).
96
100
  await ensureBtwSchema(db);
97
101
 
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { DbClient } from '@/server/lib/db/client';
4
+ import { isPlainObject, mergeSettingsJson, readSettingsJson } from '@/server/lib/db/settings-store';
5
+
6
+ /** Minimal in-memory stand-in for the `app_settings` rows the store talks to. */
7
+ function fakeDb(rows: Record<string, string> = {}) {
8
+ const store = new Map(Object.entries(rows));
9
+ const db = {
10
+ async get(_sql: string, params: unknown[] = []) {
11
+ const value = store.get(String(params[0]));
12
+ return value === undefined ? undefined : { value };
13
+ },
14
+ async run(_sql: string, params: unknown[] = []) {
15
+ store.set(String(params[0]), String(params[1]));
16
+ return { changes: 1, lastID: 0 };
17
+ },
18
+ };
19
+ // Only `get`/`run` are reachable from the store; the rest of `DbClient` exists
20
+ // for callers this test never exercises.
21
+ return { db: db as unknown as DbClient, read: (key: string) => store.get(key) };
22
+ }
23
+
24
+ const KEY = 'omp_chamber_settings';
25
+
26
+ describe('mergeSettingsJson', () => {
27
+ test('applies the patch and keeps the keys it did not name', async () => {
28
+ const { db, read } = fakeDb({ [KEY]: JSON.stringify({ theme: 'paper', soundAlerts: true }) });
29
+ await mergeSettingsJson(db, KEY, { theme: 'nord-dark' });
30
+ expect(JSON.parse(read(KEY)!)).toEqual({ theme: 'nord-dark', soundAlerts: true });
31
+ });
32
+
33
+ test('creates the row when it is absent or not an object', async () => {
34
+ const absent = fakeDb();
35
+ await mergeSettingsJson(absent.db, KEY, { theme: 'nord-dark' });
36
+ expect(JSON.parse(absent.read(KEY)!)).toEqual({ theme: 'nord-dark' });
37
+
38
+ const array = fakeDb({ [KEY]: JSON.stringify(['stale']) });
39
+ await mergeSettingsJson(array.db, KEY, { theme: 'nord-dark' });
40
+ expect(JSON.parse(array.read(KEY)!)).toEqual({ theme: 'nord-dark' });
41
+
42
+ const malformed = fakeDb({ [KEY]: 'not json' });
43
+ await mergeSettingsJson(malformed.db, KEY, { theme: 'nord-dark' });
44
+ expect(JSON.parse(malformed.read(KEY)!)).toEqual({ theme: 'nord-dark' });
45
+ });
46
+
47
+ test('a patch cannot resurrect a value from a stale copy of its siblings', async () => {
48
+ // Two writers, one blob: only the key each of them edited is stored.
49
+ const { db, read } = fakeDb({ [KEY]: JSON.stringify({ theme: 'paper' }) });
50
+ await mergeSettingsJson(db, KEY, { theme: 'nord-dark' });
51
+ await mergeSettingsJson(db, KEY, { chatCompletionSound: false });
52
+ expect(JSON.parse(read(KEY)!)).toEqual({ theme: 'nord-dark', chatCompletionSound: false });
53
+ expect(await readSettingsJson(db, KEY, {})).toEqual({ theme: 'nord-dark', chatCompletionSound: false });
54
+ });
55
+ });
56
+
57
+ describe('isPlainObject', () => {
58
+ test('accepts objects and rejects the shapes that must replace instead of merge', () => {
59
+ expect(isPlainObject({ a: 1 })).toBe(true);
60
+ expect(isPlainObject(['a'])).toBe(false);
61
+ expect(isPlainObject(null)).toBe(false);
62
+ expect(isPlainObject('a')).toBe(false);
63
+ expect(isPlainObject(7)).toBe(false);
64
+ });
65
+ });
@@ -35,6 +35,31 @@ export async function writeSettingsRaw(db: DbClient, key: string, value: string)
35
35
  await db.run(UPSERT_VALUE_SQL, [key, value]);
36
36
  }
37
37
 
38
+ /** A JSON object that is not an array — the only shape a key-wise merge applies to. */
39
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
40
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ }
42
+
43
+ /**
44
+ * Shallow-merge a plain-object patch into the object already stored for `key`.
45
+ *
46
+ * A patch is the only thing the settings client can send without republishing
47
+ * values it merely read: the `omp_chamber_settings` blob is written by two
48
+ * writers (the settings modal, the theme picker) and read by many, so a writer
49
+ * that echoed its whole copy would discard whatever another writer stored in
50
+ * between. Merging is what makes a partial write safe; a stored value that is
51
+ * absent or not an object is replaced outright, and arrays still replace
52
+ * because a list has no per-key identity to merge on.
53
+ */
54
+ export async function mergeSettingsJson(
55
+ db: DbClient,
56
+ key: string,
57
+ patch: Record<string, unknown>,
58
+ ): Promise<void> {
59
+ const stored = await readSettingsJson<unknown>(db, key, null);
60
+ await writeSettingsJson(db, key, { ...(isPlainObject(stored) ? stored : {}), ...patch });
61
+ }
62
+
38
63
  /** Read every settings row, parsing JSON where possible and keeping raw text otherwise. */
39
64
  export async function readAllSettingsJson(db: DbClient): Promise<Record<string, unknown>> {
40
65
  const rows = await db.all<{ key: string; value: string }>(SELECT_ALL_SQL);
@@ -8,18 +8,56 @@
8
8
  * the target. rename(2) is atomic on POSIX, so a crash mid-write leaves either
9
9
  * the old file or the new one — never a truncated file. The temp file is
10
10
  * removed (best-effort) when the write or rename fails.
11
+ *
12
+ * Two properties the plain rename does NOT give you, and both are required for
13
+ * omp's config files:
14
+ *
15
+ * - **Mode.** `Bun.write` creates the temp file with the process umask
16
+ * (0644/0664), so overwriting a `0600` file — omp opens its own config temp
17
+ * with `wx, 0o600` — silently loosened the mode of a file holding plaintext
18
+ * API keys. The existing target's mode is carried onto the replacement; only
19
+ * a brand-new file uses `createMode`.
20
+ * - **Symlinks.** Renaming onto a symlinked path replaces the symlink with a
21
+ * regular file. omp resolves the symlink chain and writes to the real target
22
+ * (`Settings.#resolveYamlWritePath`), so a user who links `config.yml` into a
23
+ * dotfiles repo keeps the link. The chain is resolved before the temp file is
24
+ * placed, so the temp lands beside the real target on the same filesystem.
11
25
  */
12
26
 
13
27
  import fs from 'fs';
14
28
  import { dirname } from 'path';
15
29
 
30
+ export interface AtomicWriteOptions {
31
+ /**
32
+ * Mode for a file that does not exist yet. Existing files always keep their
33
+ * own mode. Defaults to `0o644`.
34
+ */
35
+ createMode?: number;
36
+ }
37
+
16
38
  /** Writes `data` to `targetPath` via a sibling temp file then rename (atomic on POSIX). */
17
- export async function writeFileAtomic(targetPath: string, data: string): Promise<void> {
18
- await fs.promises.mkdir(dirname(targetPath), { recursive: true });
19
- const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
39
+ export async function writeFileAtomic(
40
+ targetPath: string,
41
+ data: string,
42
+ options: AtomicWriteOptions = {},
43
+ ): Promise<void> {
44
+ // Missing file (first write) or a dangling link throws — the parent is real.
45
+ let resolved = targetPath;
46
+ try {
47
+ resolved = fs.realpathSync.native(targetPath);
48
+ } catch {}
49
+ let mode = options.createMode ?? 0o644;
50
+ try {
51
+ mode = fs.statSync(resolved).mode & 0o777;
52
+ } catch {}
53
+ await fs.promises.mkdir(dirname(resolved), { recursive: true });
54
+ const tempPath = `${resolved}.tmp-${process.pid}-${crypto.randomUUID()}`;
20
55
  try {
21
56
  await Bun.write(tempPath, data);
22
- await fs.promises.rename(tempPath, targetPath);
57
+ // Bun.write ignores `mode` when creating, so the mode is applied explicitly
58
+ // before the rename — a chmod after would leave a window at the default.
59
+ await fs.promises.chmod(tempPath, mode);
60
+ await fs.promises.rename(tempPath, resolved);
23
61
  } catch (error) {
24
62
  await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
25
63
  throw error;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Cross-process lock for read-modify-write cycles on shared config files.
8
+ *
9
+ * Two writers (the dev server, the installed app, or two provider mutations
10
+ * landing together) editing the same file would otherwise silently lose the
11
+ * earlier mutation when the later rename wins. A lockfile with exclusive
12
+ * create (`wx`) is atomic on every platform; the holder writes its PID and
13
+ * deletes the file on completion. Stale locks (writer crashed) are broken
14
+ * after a grace period.
15
+ *
16
+ * This is deliberately NOT omp's own lock: omp takes an OS-level `flock(2)` on
17
+ * `${path}.lock` (Linux uses abstract Unix sockets, Windows named mutexes), so
18
+ * the two mechanisms do not interop. The window this leaves — a chamber write
19
+ * landing between omp's read and its rename — is bounded by keeping every
20
+ * critical section to a single read-modify-write, and omp re-reads inside its
21
+ * own lock before applying its pending changes.
22
+ */
23
+
24
+ import fs from 'fs';
25
+ import { dirname } from 'path';
26
+
27
+ const LOCK_TIMEOUT_MS = 3_000;
28
+ const LOCK_STALE_MS = 10_000;
29
+ const LOCK_RETRY_MS = 25;
30
+
31
+ /** Run `fn` while holding an exclusive `${configPath}.lock` lease. */
32
+ export async function withConfigLock<T>(configPath: string, fn: () => T | Promise<T>): Promise<T> {
33
+ const lockPath = `${configPath}.lock`;
34
+ // The config file may not exist yet (first write) — the lockfile needs its
35
+ // parent dir to exist before exclusive-create can succeed.
36
+ await fs.promises.mkdir(dirname(lockPath), { recursive: true });
37
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
38
+ for (;;) {
39
+ let handle: fs.promises.FileHandle | null = null;
40
+ try {
41
+ handle = await fs.promises.open(lockPath, 'wx');
42
+ } catch (error) {
43
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
44
+ // Held by another process — break it if stale, otherwise wait and retry.
45
+ try {
46
+ if (Date.now() - (await fs.promises.stat(lockPath)).mtimeMs > LOCK_STALE_MS) {
47
+ await fs.promises.unlink(lockPath);
48
+ continue;
49
+ }
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (Date.now() >= deadline) {
54
+ throw new Error(`Timed out waiting for ${lockPath} (another process holds the config lock)`);
55
+ }
56
+ await Bun.sleep(LOCK_RETRY_MS);
57
+ continue;
58
+ }
59
+ try {
60
+ await handle.writeFile(String(process.pid));
61
+ } finally {
62
+ await handle.close();
63
+ }
64
+ try {
65
+ return await fn();
66
+ } finally {
67
+ try {
68
+ await fs.promises.unlink(lockPath);
69
+ } catch {
70
+ // Already removed (e.g. by cleanup) — the critical section is done.
71
+ }
72
+ }
73
+ }
74
+ }
@@ -15,10 +15,46 @@
15
15
  *
16
16
  * Paths must be relative to `dir` and must exist on disk: git resolves
17
17
  * directory-only patterns (`build/`) by stat-ing the path.
18
+ *
19
+ * Answers are memoized per path: the panel asks again every time a folder is
20
+ * re-listed or re-expanded, and each question costs a `git` subprocess
21
+ * (measured 5.6 ms). The TTL is short because a `.gitignore` the user just
22
+ * edited should take effect without a reload; a path whose answer is still
23
+ * cached is simply not asked about, so a re-listing of a known folder spawns
24
+ * nothing at all.
18
25
  */
26
+
27
+ const CACHE_TTL_MS = 5_000;
28
+ /** Bound on remembered paths; the oldest answer is evicted past this. */
29
+ const CACHE_MAX_ENTRIES = 20_000;
30
+
31
+ interface IgnoreAnswer {
32
+ ignored: boolean;
33
+ at: number;
34
+ }
35
+
36
+ const cache = new Map<string, IgnoreAnswer>();
37
+
38
+ function cacheKey(dir: string, rel: string): string {
39
+ return `${dir}\0${rel}`;
40
+ }
41
+
19
42
  export async function collectIgnoredPaths(dir: string, relPaths: string[]): Promise<Set<string>> {
20
43
  if (relPaths.length === 0) return new Set();
21
44
 
45
+ const now = Date.now();
46
+ const ignored = new Set<string>();
47
+ const unknown: string[] = [];
48
+ for (const rel of relPaths) {
49
+ const answer = cache.get(cacheKey(dir, rel));
50
+ if (answer && now - answer.at < CACHE_TTL_MS) {
51
+ if (answer.ignored) ignored.add(rel);
52
+ continue;
53
+ }
54
+ unknown.push(rel);
55
+ }
56
+ if (unknown.length === 0) return ignored;
57
+
22
58
  try {
23
59
  const proc = Bun.spawn({
24
60
  cmd: ['git', 'check-ignore', '--stdin', '-z'],
@@ -32,18 +68,32 @@ export async function collectIgnoredPaths(dir: string, relPaths: string[]): Prom
32
68
  // Drain stdout while the listing is written: a directory big enough to
33
69
  // fill both pipe buffers would otherwise deadlock child against parent.
34
70
  const stdout = new Response(proc.stdout).text();
35
- proc.stdin.write(relPaths.map(rel => `${rel}\0`).join(''));
71
+ proc.stdin.write(unknown.map(rel => `${rel}\0`).join(''));
36
72
  // FileSink.end() types as `number | Promise<number>` — EPIPE (child gone)
37
73
  // must not surface as an unhandled rejection.
38
74
  void Promise.resolve(proc.stdin.end()).catch(() => {});
39
75
 
40
76
  const [text, exitCode] = await Promise.all([stdout, proc.exited]);
41
77
  // 0 = at least one path matched, 1 = none, 128 = no repository. Only 0 can
42
- // carry a match list.
43
- if (exitCode !== 0) return new Set();
78
+ // carry a match list, but every path still gets a definite answer: an
79
+ // unlisted one is not ignored.
80
+ const matched = exitCode === 0 ? new Set(text.split('\0').filter(Boolean)) : new Set<string>();
81
+
82
+ // Only 0 and 1 mean git actually read the paths; 128 (no repository) is
83
+ // not an answer, so nothing is cached from it and the next listing retries.
84
+ const answered = exitCode === 0 || exitCode === 1;
85
+ const stamped = Date.now();
86
+ for (const rel of unknown) {
87
+ const isIgnored = matched.has(rel);
88
+ if (isIgnored) ignored.add(rel);
89
+ if (answered) {
90
+ if (cache.size >= CACHE_MAX_ENTRIES) cache.clear();
91
+ cache.set(cacheKey(dir, rel), { ignored: isIgnored, at: stamped });
92
+ }
93
+ }
44
94
 
45
- return new Set(text.split('\0').filter(Boolean));
95
+ return ignored;
46
96
  } catch {
47
- return new Set(); // git missing / spawn failed — nothing is dimmed
97
+ return ignored; // git missing / spawn failed — nothing is dimmed
48
98
  }
49
99
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `rg` behind the Search panel — a native one when the machine has it.
8
+ *
9
+ * The `ripgrep` package is a WASI build (732 KiB of wasm) whose guest sees a
10
+ * single preopened directory. That costs two things. Speed: measured 86–117 ms
11
+ * against 37 ms for the same query in this repo. And correctness: the guest
12
+ * cannot reach the user's global gitignore, so a path git refuses to track —
13
+ * `~/.gitignore` excludes `AGENTS.md` on this machine — was listed as a search
14
+ * hit while the Files and Git panels both hid it.
15
+ *
16
+ * A native `rg` on PATH answers both, but `Bun.which` alone is not enough to
17
+ * find one: the first `rg` on PATH in this repo is `node_modules/.bin/rg`, the
18
+ * WASI shim itself (`#!/usr/bin/env node`). Resolving by hand lets a script be
19
+ * skipped and the next PATH entry considered. When no native binary exists the
20
+ * WASI build still runs, because the panel has to work on a machine that never
21
+ * installed ripgrep.
22
+ */
23
+
24
+ import { join } from 'path';
25
+ import { ripgrep } from 'ripgrep';
26
+
27
+ /** Bytes that open a script rather than an executable (`#!`). */
28
+ const SHEBANG_MAGIC = [0x23, 0x21] as const;
29
+
30
+ export interface RipgrepSink {
31
+ /** Raw stdout, chunk by chunk, as rg flushes it. */
32
+ onStdout: (chunk: Uint8Array) => void;
33
+ /** Raw stderr, chunk by chunk. */
34
+ onStderr: (chunk: Uint8Array) => void;
35
+ }
36
+
37
+ let resolvedBinary: string | null | undefined;
38
+
39
+ /**
40
+ * Path of a native `rg`, or null when PATH holds only scripts.
41
+ *
42
+ * Cached for the process: PATH does not move under a running server, and the
43
+ * answer costs one 2-byte read per candidate. A binary is recognised by *not*
44
+ * starting with `#!` — that covers ELF, Mach-O (thin and fat) and PE without
45
+ * enumerating magics, and a wrong guess only costs a failed spawn, which falls
46
+ * back to the WASI build.
47
+ */
48
+ export async function nativeRipgrepPath(): Promise<string | null> {
49
+ if (resolvedBinary !== undefined) return resolvedBinary;
50
+ resolvedBinary = null;
51
+
52
+ const executable = process.platform === 'win32' ? 'rg.exe' : 'rg';
53
+ for (const dir of (Bun.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':')) {
54
+ if (!dir) continue;
55
+ const candidate = join(dir, executable);
56
+ try {
57
+ const head = await Bun.file(candidate).slice(0, 2).bytes();
58
+ if (head.length < 2) continue;
59
+ if (head[0] === SHEBANG_MAGIC[0] && head[1] === SHEBANG_MAGIC[1]) continue;
60
+ resolvedBinary = candidate;
61
+ break;
62
+ } catch {
63
+ // Not there, or not readable — try the next PATH entry.
64
+ }
65
+ }
66
+ return resolvedBinary;
67
+ }
68
+
69
+ /**
70
+ * Run `rg` with `args` in `cwd`, streaming both pipes into `sink`.
71
+ *
72
+ * Returns rg's exit code (0 = matches, 1 = none, 2 = error). A native binary
73
+ * that disappears between resolution and spawn falls back to WASI rather than
74
+ * failing the search.
75
+ */
76
+ export async function runRipgrep(args: string[], cwd: string, sink: RipgrepSink): Promise<number> {
77
+ const binary = await nativeRipgrepPath();
78
+ if (binary) {
79
+ try {
80
+ const proc = Bun.spawn({
81
+ cmd: [binary, ...args],
82
+ cwd,
83
+ stdout: 'pipe',
84
+ stderr: 'pipe',
85
+ });
86
+ // `pipeTo`, not `for await`: the repo's lib set has no
87
+ // `ReadableStream[Symbol.asyncIterator]`, and this is the same shape
88
+ // `lib/omp/rpc/lines.ts` uses to drain a Bun child.
89
+ const pump = (stream: ReadableStream<Uint8Array>, write: (chunk: Uint8Array) => void) =>
90
+ stream.pipeTo(
91
+ new WritableStream({
92
+ write(chunk) {
93
+ write(chunk);
94
+ },
95
+ }),
96
+ ).then(
97
+ () => undefined,
98
+ () => undefined,
99
+ );
100
+ // Drained together: rg filling one pipe while the other is unread would
101
+ // otherwise block it against a full buffer.
102
+ await Promise.all([pump(proc.stdout, sink.onStdout), pump(proc.stderr, sink.onStderr)]);
103
+ return await proc.exited;
104
+ } catch {
105
+ // Spawn refused (deleted, not executable) — the WASI build still answers.
106
+ }
107
+ }
108
+
109
+ // WASI preopens map the guest "." onto the real target directory.
110
+ const preopens = { '.': cwd };
111
+ const { code } = await ripgrep(args, {
112
+ preopens,
113
+ // ripgrep accepts any `{ write(chunk) }` sink — feed it the raw stream so
114
+ // matches flush incrementally.
115
+ stdout: { write: (chunk: Uint8Array) => sink.onStdout(chunk) },
116
+ stderr: { write: (chunk: Uint8Array) => sink.onStderr(chunk) },
117
+ });
118
+ return code;
119
+ }