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
@@ -9,27 +9,44 @@
9
9
  * The side question runs in a second `omp --mode rpc-ui` child that resumes the
10
10
  * topic's own transcript — a snapshot of the parent session taken when the
11
11
  * topic first asks. That gives the parent's context as real provider messages
12
- * (no re-prompting it as text, and the parent's prompt-cache prefix survives),
13
- * never writes the parent file, and disables the tool surface exactly like
14
- * omp's TUI `/btw` (`--no-tools`; the prompt itself says "NEVER use tools").
12
+ * (no re-prompting it as text, and the parent's prompt-cache prefix survives)
13
+ * and never writes the parent file.
14
+ *
15
+ * The child runs WITH tools, unlike omp's TUI `/btw` (`--no-tools`). The panel
16
+ * offers a real access-control dropdown, and an approval mode that governs
17
+ * nothing would be a lie in the UI — so the tool surface is whatever
18
+ * `--approval-mode` allows, and a gated call parks the turn on an
19
+ * `extension_ui_request` the panel answers (`PendingUiDialogs`).
15
20
  *
16
21
  * The child is disposable by design: history lives in SQLite plus the topic
17
22
  * transcript, so an idle reclaim costs a respawn, not the conversation — a
18
23
  * follow-up resumes the transcript and still sees every earlier turn.
24
+ *
25
+ * This class owns the process and the frame routing; the rules about what a turn
26
+ * is worth live in `lifecycle.server.ts`.
19
27
  */
20
28
 
21
29
  import { RpcProcess, type RpcFrame } from '@/server/lib/omp/rpc/process';
22
- import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, PROMPT_ACK_TIMEOUT_MS, READY_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
30
+ import { GET_STATE_TIMEOUT_MS, READY_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
31
+ import { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
23
32
  import { resolveOmpBin } from '@/server/lib/omp/core/cli';
24
- import { buildBtwPrompt } from '@/server/lib/btw/prompt';
25
- import { appendBtwTurn, setBtwTopicLeaf, setBtwTopicModel, settleRunningBtwTurns, updateBtwTurn } from '@/server/lib/btw/store.server';
33
+ import { buildBtwSpawnArgs, type BtwSpawnSettings } from '@/server/lib/btw/child.server';
34
+ import {
35
+ abortSideTurn,
36
+ askSideQuestion,
37
+ handleSideChildExit,
38
+ resetSideIdleTimer,
39
+ settleSideTurn,
40
+ teardownSideChild,
41
+ trackTurnActivity,
42
+ trackTurnMessage,
43
+ type BtwLifecycleHost,
44
+ } from '@/server/lib/btw/lifecycle.server';
45
+ import { setBtwTopicModel, setBtwTopicThinkingLevel } from '@/server/lib/btw/store.server';
26
46
  import { createBtwWorkspace, resolveBtwWorkspacePaths, type BtwWorkspace } from '@/server/lib/btw/session-copy.server';
27
- import { isRecord } from '@/shared/lib/util/guards';
28
47
  import { finalAnswer, finalStatus, isAnswerableUiMethod } from '@/server/lib/btw/frames';
29
- import type { AgentImage, BtwFrame, BtwModel, BtwTurnStatus } from '@/shared/types';
30
-
31
- /** Longest an aborted turn may stay unsettled before its child is reclaimed. */
32
- const ABORT_GRACE_MS = 10_000;
48
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
49
+ import type { AgentImage, BtwFrame, BtwLiveTurn, BtwModel, BtwTurnStatus, ChatMessageData } from '@/shared/types';
33
50
 
34
51
  /** Prompt images, validated by the route before they reach the child. */
35
52
  export type BtwImages = AgentImage[];
@@ -41,6 +58,8 @@ export interface BtwRuntimeContext {
41
58
  parentSessionFile: string;
42
59
  cwd: string;
43
60
  model?: BtwModel;
61
+ thinkingLevel?: string;
62
+ approvalMode?: ApprovalMode;
44
63
  }
45
64
 
46
65
  /** Where the runtime reports what happened. */
@@ -61,21 +80,28 @@ export class BtwError extends Error {
61
80
  }
62
81
  }
63
82
 
64
- export class BtwRuntime {
65
- private proc: RpcProcess | null = null;
83
+ export class BtwRuntime implements BtwLifecycleHost {
84
+ proc: RpcProcess | null = null;
66
85
  private starting: Promise<RpcProcess> | null = null;
67
86
  private workspace: BtwWorkspace | null = null;
68
- private idleTimer: NodeJS.Timeout | null = null;
69
- private abortTimer: NodeJS.Timeout | null = null;
70
- private lastIdleReset = 0;
71
- private turnIndex: number | null = null;
72
- private settling = false;
73
- private answer = '';
74
- private disposing: Promise<void> | null = null;
87
+ idleTimer: NodeJS.Timeout | null = null;
88
+ abortTimer: NodeJS.Timeout | null = null;
89
+ lastIdleReset = 0;
90
+ turnIndex: number | null = null;
91
+ settling = false;
92
+ /** The turn's conversation so far (chat-shaped; see `trackTurnMessage`). */
93
+ messages: ChatMessageData[] = [];
94
+ /** Last activity phrase published, so repeats are not re-published. */
95
+ activity = '';
96
+ disposing: Promise<void> | null = null;
97
+ /** Ask/approval dialogs this child is blocked on (see the module note). */
98
+ readonly dialogs = new PendingUiDialogs();
99
+ /** The approval mode the live child was actually spawned with. */
100
+ spawnedApprovalMode: ApprovalMode | undefined;
75
101
 
76
102
  constructor(
77
103
  private readonly context: BtwRuntimeContext,
78
- private readonly sink: BtwRuntimeSink,
104
+ readonly sink: BtwRuntimeSink,
79
105
  ) {}
80
106
 
81
107
  get topicId(): string {
@@ -98,49 +124,61 @@ export class BtwRuntime {
98
124
  return this.settling;
99
125
  }
100
126
 
101
- /** Ask (or follow up) — the caller has already refused concurrent turns. */
102
- async ask(question: string, images?: BtwImages): Promise<void> {
103
- if (this.running) throw new BtwError('A side question is already running here.', 'btw_busy');
104
- this.turnIndex = await appendBtwTurn(this.topicId, question);
105
- this.answer = '';
127
+ /** The in-flight turn as the server has accumulated it, for `btw_state`. */
128
+ liveTurn(): BtwLiveTurn | null {
129
+ if (this.turnIndex === null) return null;
130
+ return {
131
+ topicId: this.topicId,
132
+ turnIndex: this.turnIndex,
133
+ messages: this.messages,
134
+ activity: this.activity,
135
+ };
136
+ }
137
+
138
+ ask(question: string, images?: BtwImages): Promise<void> {
139
+ return askSideQuestion(this, question, images);
140
+ }
141
+
142
+ abort(): Promise<void> {
143
+ return abortSideTurn(this);
144
+ }
145
+
146
+ /**
147
+ * Re-target the live child's model. `set_model` applies to the RUNNING turn
148
+ * (omp resolves it immediately), which is exactly what the panel's dropdown
149
+ * promises: the answer in flight continues on the model the user just chose.
150
+ */
151
+ async setModel(provider: string, modelId: string): Promise<void> {
152
+ await setBtwTopicModel(this.topicId, { provider, id: modelId });
106
153
  this.sink.stateChanged();
107
- try {
108
- const proc = await this.start();
109
- await proc.sendCommand(
110
- { type: 'prompt', message: buildBtwPrompt(question), ...(images?.length ? { images } : {}) },
111
- PROMPT_ACK_TIMEOUT_MS,
112
- );
113
- } catch (error) {
114
- await this.settle('failed');
115
- this.sink.publish({
116
- type: 'btw_error',
117
- topicId: this.topicId,
118
- message: error instanceof Error ? error.message : String(error),
119
- });
120
- }
121
- this.resetIdleTimer(true);
154
+ const proc = this.proc;
155
+ if (!proc?.isAlive) return;
156
+ await proc.sendCommand({ type: 'set_model', provider, modelId }, GET_STATE_TIMEOUT_MS);
122
157
  }
123
158
 
124
- /** Cancel the running question, keeping whatever text already arrived. */
125
- async abort(): Promise<void> {
126
- if (!this.running) return;
159
+ /** Re-target the live child's thinking level (`auto` leaves omp alone). */
160
+ async setThinkingLevel(level: string): Promise<void> {
127
161
  const proc = this.proc;
128
- if (!proc?.isAlive) {
129
- await this.settle('cancelled');
130
- return;
131
- }
132
- try {
133
- await proc.sendCommand({ type: 'abort' }, GET_STATE_TIMEOUT_MS);
134
- } catch {
135
- // An unanswered abort is the escalate-to-cancel path below, not an error.
136
- }
137
- if (this.abortTimer) clearTimeout(this.abortTimer);
138
- const timer = setTimeout(() => {
139
- this.abortTimer = null;
140
- void this.disposeIfUnsettled();
141
- }, ABORT_GRACE_MS);
142
- timer.unref?.();
143
- this.abortTimer = timer;
162
+ if (!proc?.isAlive || level === 'auto') return;
163
+ await proc.sendCommand({ type: 'set_thinking_level', level }, GET_STATE_TIMEOUT_MS);
164
+ }
165
+
166
+ /**
167
+ * Adopt a new approval mode. omp has no RPC for it — the flag is spawn-time
168
+ * only — so an idle child is destroyed and the next question respawns it.
169
+ * A running turn is never killed for this (same rule as the chat session's
170
+ * `reconcileSpawnApprovalMode`): the mode takes effect from the next child.
171
+ */
172
+ async setApprovalMode(mode: ApprovalMode): Promise<void> {
173
+ this.context.approvalMode = mode;
174
+ if (this.spawnedApprovalMode === mode || this.running) return;
175
+ await this.teardownChild();
176
+ }
177
+
178
+ /** Answer a dialog this child is blocked on. */
179
+ respondToDialog(id: string, response: Record<string, unknown>): void {
180
+ this.proc?.sendFrame({ type: 'extension_ui_response', id, ...response });
181
+ if (this.dialogs.resolve(id)) this.sink.stateChanged();
144
182
  }
145
183
 
146
184
  /** Reclaim the child; the topic's history survives in SQLite and on disk. */
@@ -148,26 +186,14 @@ export class BtwRuntime {
148
186
  if (this.disposing) return this.disposing;
149
187
  if (this.running) {
150
188
  // A turn would die with the child — settle it rather than lose the answer.
151
- await this.settle('cancelled');
189
+ await settleSideTurn(this, 'cancelled');
152
190
  }
153
- this.disposing = this.teardown();
191
+ this.disposing = this.teardownChild();
154
192
  return this.disposing;
155
193
  }
156
194
 
157
- private async teardown(): Promise<void> {
158
- if (this.idleTimer) clearTimeout(this.idleTimer);
159
- if (this.abortTimer) clearTimeout(this.abortTimer);
160
- this.idleTimer = null;
161
- this.abortTimer = null;
162
- const proc = this.proc;
163
- this.proc = null;
164
- if (proc) await proc.dispose().catch(() => {});
165
- this.disposing = null;
166
- }
167
-
168
- private async disposeIfUnsettled(): Promise<void> {
169
- if (this.running) await this.settle('cancelled');
170
- await this.dispose();
195
+ async teardownChild(): Promise<void> {
196
+ await teardownSideChild(this);
171
197
  }
172
198
 
173
199
  private ensureWorkspace(): Promise<BtwWorkspace> {
@@ -190,11 +216,10 @@ export class BtwRuntime {
190
216
  this.workspace = workspace;
191
217
  // The snapshot's leaf is what a later promotion must still find in the
192
218
  // parent; a topic resumed from disk keeps the leaf it was created with.
193
- if (workspace.leafId) await setBtwTopicLeaf(this.context.topicId, workspace.leafId);
194
219
  return workspace;
195
220
  }
196
221
 
197
- private start(): Promise<RpcProcess> {
222
+ start(): Promise<RpcProcess> {
198
223
  if (this.starting) return this.starting;
199
224
  const started = this.spawnChild();
200
225
  this.starting = started;
@@ -210,35 +235,41 @@ export class BtwRuntime {
210
235
  const bin = resolveOmpBin();
211
236
  if (!bin) throw new BtwError('The omp binary could not be found.', 'btw_unavailable');
212
237
  const workspace = await this.ensureWorkspace();
213
- const model = this.context.model;
238
+ const settings: BtwSpawnSettings = {
239
+ model: this.context.model,
240
+ thinkingLevel: this.context.thinkingLevel,
241
+ approvalMode: this.context.approvalMode,
242
+ };
214
243
  const proc = new RpcProcess({
215
244
  cwd: this.context.cwd,
216
- extraArgs: [
217
- '--resume',
218
- workspace.sessionFile,
219
- // Side questions never touch tools, so no approval mode is needed.
220
- '--no-tools',
221
- // Auto-titling would spend a model call on a transcript nobody lists.
222
- '--no-title',
223
- ...(model ? ['--model', `${model.provider}/${model.id}`] : []),
224
- ],
245
+ extraArgs: buildBtwSpawnArgs(workspace.sessionFile, settings),
225
246
  onFrame: (frame) => this.handleFrame(frame),
226
- onExit: (info) => this.handleExit(info.stderrTail),
247
+ onExit: (info) => handleSideChildExit(this, info.stderrTail),
227
248
  });
228
249
  this.proc = proc;
250
+ this.spawnedApprovalMode = this.context.approvalMode;
229
251
  const ready = await proc.waitReady(READY_TIMEOUT_MS);
230
252
  await proc.negotiateProtocol(ready);
231
253
  await this.captureModel(proc);
232
254
  return proc;
233
255
  }
234
256
 
235
- /** Record the display name the child reports, for the panel's model chip. */
257
+ /**
258
+ * Record what the child reports about itself, for the panel's model chip and
259
+ * the run footer: its display name and the thinking level it actually runs at.
260
+ * `get_state` is the only source — the topic row holds what was REQUESTED, and
261
+ * omp may resolve it differently.
262
+ */
236
263
  private async captureModel(proc: RpcProcess): Promise<void> {
237
264
  try {
238
265
  const state = await proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
239
266
  const reported = state.model;
240
267
  if (reported?.provider && reported.id && reported.name) {
241
- await setBtwTopicModel(this.context.topicId, { provider: reported.provider, id: reported.id, name: reported.name });
268
+ await setBtwTopicModel(this.topicId, { provider: reported.provider, id: reported.id, name: reported.name });
269
+ this.sink.stateChanged();
270
+ }
271
+ if (typeof state.thinkingLevel === 'string' && state.thinkingLevel) {
272
+ await setBtwTopicThinkingLevel(this.topicId, state.thinkingLevel);
242
273
  this.sink.stateChanged();
243
274
  }
244
275
  } catch {
@@ -247,78 +278,40 @@ export class BtwRuntime {
247
278
  }
248
279
 
249
280
  private handleFrame(frame: RpcFrame): void {
250
- if (frame.type === 'message_update') {
251
- const event = frame.assistantMessageEvent;
252
- if (isRecord(event) && event.type === 'text_delta' && typeof event.delta === 'string' && this.turnIndex !== null) {
253
- this.answer += event.delta;
254
- this.sink.publish({ type: 'btw_delta', topicId: this.topicId, turnIndex: this.turnIndex, text: event.delta });
255
- }
281
+ if (frame.type === 'message_update' || frame.type === 'message_start') {
282
+ // The turn's conversation is accumulated in the chat's own shape (see
283
+ // `trackTurnMessage`): thinking, prose, tool calls and their output all
284
+ // reach the panel through the same mapper the chat timeline uses.
285
+ trackTurnMessage(this, frame);
286
+ trackTurnActivity(this, frame);
287
+ } else if (frame.type === 'message_end') {
288
+ // The completed message is the authoritative copy: it carries the turn's
289
+ // real usage, where the streaming frames report zeros. Upserting it
290
+ // replaces the streaming row in place (same message id), which is what
291
+ // puts token counts on the panel's run footer.
292
+ trackTurnMessage(this, frame);
293
+ } else if (frame.type === 'tool_execution_start') {
294
+ trackTurnActivity(this, frame);
256
295
  } else if (frame.type === 'agent_end') {
257
296
  // `isTerminal: false` means maintenance scheduled more work; the turn is
258
297
  // not done until a terminal end arrives (mirrors the session wrapper).
259
298
  if (frame.isTerminal === false) return;
260
- void this.settle(finalStatus(frame.messages), finalAnswer(frame.messages, this.answer));
299
+ void settleSideTurn(this, finalStatus(frame.messages), finalAnswer(frame.messages, ''));
261
300
  } else if (frame.type === 'extension_ui_request') {
262
- this.answerExtensionUi(frame);
301
+ this.trackDialog(frame);
263
302
  }
264
- this.resetIdleTimer();
303
+ resetSideIdleTimer(this);
265
304
  }
266
305
 
267
- /** A side session has no UI: release any dialog omp parks on, so it can
268
- * never block a turn that the panel is waiting on. */
269
- private answerExtensionUi(frame: RpcFrame): void {
270
- const id = frame.id;
271
- if (typeof id !== 'string' || !isAnswerableUiMethod(frame.method)) return;
272
- this.proc?.sendFrame({ type: 'extension_ui_response', id, cancelled: true });
273
- }
274
-
275
- private async settle(status: BtwTurnStatus, answer?: string): Promise<void> {
276
- const turnIndex = this.turnIndex;
277
- if (turnIndex === null) return;
278
- this.turnIndex = null;
279
- const text = answer ?? this.answer;
280
- this.answer = '';
281
- // `busy` holds the topic live across the write: a state read that lands in
282
- // this window must not settle the row as `interrupted` (see `btwStateFor`).
283
- this.settling = true;
284
- try {
285
- await updateBtwTurn(this.topicId, turnIndex, { answer: text, status });
286
- } finally {
287
- this.settling = false;
288
- this.sink.stateChanged();
289
- }
290
- }
291
-
292
- private handleExit(stderrTail: string): void {
293
- const wasRunning = this.running;
294
- this.proc = null;
295
- if (!wasRunning) return;
296
- void settleRunningBtwTurns(this.topicId, 'interrupted').then(() => {
297
- this.turnIndex = null;
298
- this.answer = '';
299
- this.sink.stateChanged();
300
- const detail = stderrTail.trim().split('\n').pop();
301
- this.sink.publish({
302
- type: 'btw_error',
303
- topicId: this.topicId,
304
- message: `The side session exited before answering${detail ? `: ${detail}` : '.'}`,
305
- });
306
- });
307
- }
308
-
309
- private resetIdleTimer(force = false): void {
310
- const now = Date.now();
311
- if (!force && this.idleTimer && now - this.lastIdleReset < 5_000) return;
312
- this.lastIdleReset = now;
313
- if (this.idleTimer) clearTimeout(this.idleTimer);
314
- const timer = setTimeout(() => {
315
- if (this.running) {
316
- this.resetIdleTimer(true);
317
- return;
318
- }
319
- void this.dispose();
320
- }, IDLE_DESTROY_MS);
321
- timer.unref?.();
322
- this.idleTimer = timer;
306
+ /**
307
+ * Record an ask/approval dialog so the panel can render it. Unlike the TUI
308
+ * there is no tool card to host an `ask` inline, so every answerable request
309
+ * becomes a modal — and the state republish is what puts it on screen.
310
+ */
311
+ private trackDialog(frame: RpcFrame): void {
312
+ if (typeof frame.id !== 'string' || !isAnswerableUiMethod(frame.method)) return;
313
+ if (this.dialogs.track(frame)) this.sink.stateChanged();
323
314
  }
324
315
  }
316
+
317
+ export type { BtwTurnStatus };
@@ -19,6 +19,8 @@ const BTW_SCHEMA_SQL = `
19
19
  provider TEXT,
20
20
  model_id TEXT,
21
21
  model_name TEXT,
22
+ thinking_level TEXT,
23
+ approval_mode TEXT,
22
24
  leaf_id TEXT,
23
25
  promoted_session_id TEXT,
24
26
  created_at INTEGER NOT NULL,
@@ -31,6 +33,7 @@ const BTW_SCHEMA_SQL = `
31
33
  turn_index INTEGER NOT NULL,
32
34
  question TEXT NOT NULL DEFAULT '',
33
35
  answer TEXT NOT NULL DEFAULT '',
36
+ messages TEXT NOT NULL DEFAULT '[]',
34
37
  status TEXT NOT NULL,
35
38
  created_at INTEGER NOT NULL,
36
39
  updated_at INTEGER NOT NULL,
@@ -38,7 +41,31 @@ const BTW_SCHEMA_SQL = `
38
41
  );
39
42
  `;
40
43
 
44
+ /**
45
+ * Columns added after the tables shipped. `CREATE TABLE IF NOT EXISTS` leaves an
46
+ * existing table alone, so an install created before these existed would miss
47
+ * them and every read would fail on the absent column. Additive only: a column
48
+ * is added once, and a fresh database already has it from the DDL above.
49
+ */
50
+ const BTW_ADDED_COLUMNS: { table: string; column: string; ddl: string }[] = [
51
+ { table: 'btw_topics', column: 'thinking_level', ddl: 'ALTER TABLE btw_topics ADD COLUMN thinking_level TEXT' },
52
+ { table: 'btw_topics', column: 'approval_mode', ddl: 'ALTER TABLE btw_topics ADD COLUMN approval_mode TEXT' },
53
+ // A turn's conversation used to be the plain `answer` string alone; the panel
54
+ // now renders the chat's own ChatMessageData, so the column arrives with the
55
+ // default an empty conversation has.
56
+ { table: 'btw_turns', column: 'messages', ddl: "ALTER TABLE btw_turns ADD COLUMN messages TEXT NOT NULL DEFAULT '[]'" },
57
+ ];
58
+
59
+ async function hasColumn(db: DbClient, table: string, column: string): Promise<boolean> {
60
+ const rows = (await db.all(`PRAGMA table_info(${table})`)) as { name?: string }[];
61
+ return rows.some((row) => row.name === column);
62
+ }
63
+
41
64
  /** Idempotent: side questions live beside the session they belong to. */
42
65
  export async function ensureBtwSchema(db: DbClient): Promise<void> {
43
66
  await db.exec(BTW_SCHEMA_SQL);
67
+ for (const added of BTW_ADDED_COLUMNS) {
68
+ if (await hasColumn(db, added.table, added.column)) continue;
69
+ await db.exec(added.ddl);
70
+ }
44
71
  }
@@ -21,15 +21,42 @@ import { findSessionFileById } from '@/server/lib/omp/session/locator';
21
21
  import { readRawHeaderLine } from '@/server/lib/omp/session/files';
22
22
  import { loadSessionModel } from '@/server/lib/omp/session/messages';
23
23
  import { resolveSpawnCwd } from '@/server/lib/omp/rpc/manager';
24
- import { createBtwTopic, deleteBtwTopic, getBtwTopic, readBtwTopicLeaf, setBtwTopicPromoted } from '@/server/lib/btw/store.server';
24
+ import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
25
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
26
+ import { composeMessageWithTextAttachments, type AttachedTextFileData } from '@/shared/lib/chat/attachments';
27
+ import {
28
+ createBtwTopic,
29
+ deleteBtwTopic,
30
+ getBtwTopic,
31
+ readBtwTopicLeaf,
32
+ setBtwTopicApprovalMode,
33
+ setBtwTopicModel,
34
+ setBtwTopicPromoted,
35
+ setBtwTopicThinkingLevel,
36
+ } from '@/server/lib/btw/store.server';
25
37
  import { promoteBtwTopic as materializePromotedTopic, readTranscriptLeaf, removeBtwWorkspace, resolveBtwWorkspacePaths } from '@/server/lib/btw/session-copy.server';
26
38
  import { btwStateFor, ensureBtwRuntime, findRunningBtwRuntime, forgetBtwRuntime, getBtwRuntime, publishBtwState } from '@/server/lib/btw/registry.server';
27
39
  import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
28
- import type { BtwState } from '@/shared/types';
40
+ import type { BtwModel, BtwState } from '@/shared/types';
29
41
 
30
42
  /** Longest a topic label may be, matching the sidebar's session-title length. */
31
43
  const TOPIC_TITLE_CHARS = 60;
32
44
 
45
+ /**
46
+ * Marks a session that was promoted out of a side question, in the sidebar list
47
+ * the user reads. Applied to the PROMOTED SESSION only — the topic's own label
48
+ * in the panel keeps the plain question.
49
+ *
50
+ * It lives in the session title because that is what the sidebar renders, and
51
+ * the title slot (line 1) is authoritative over the header. omp's auto-title
52
+ * cannot overwrite it: its latch is `autoTitlePending = messageCount === 0`, and
53
+ * a promoted transcript always carries the parent's history.
54
+ *
55
+ * Note the sidebar capitalizes the first character (`SessionItem`), so this
56
+ * renders as `Btw: …`.
57
+ */
58
+ const PROMOTED_TITLE_PREFIX = 'btw: ';
59
+
33
60
  function topicTitle(question: string): string {
34
61
  const firstLine = question.trim().split('\n')[0].trim();
35
62
  return firstLine.slice(0, TOPIC_TITLE_CHARS);
@@ -59,6 +86,22 @@ export interface AskBtwInput {
59
86
  topicId?: string;
60
87
  question: string;
61
88
  images?: BtwImages;
89
+ /**
90
+ * Inline-able text files, read by the client at attach time and sent with the
91
+ * question. They are appended to the prompt body exactly as a chat prompt
92
+ * carries them (`composeMessageWithTextAttachments`), so a side question can
93
+ * be asked about a file the same way a chat turn can.
94
+ */
95
+ textFiles?: AttachedTextFileData[];
96
+ /**
97
+ * Composer picks for the FIRST question of a topic, which has no row to write
98
+ * them to yet. Later questions address an existing topic and are re-targeted
99
+ * through the dedicated `set_model` / `set_thinking_level` / `set_access_mode`
100
+ * actions instead.
101
+ */
102
+ model?: BtwModel;
103
+ thinkingLevel?: string;
104
+ approvalMode?: ApprovalMode;
62
105
  }
63
106
 
64
107
  export async function askBtw(sessionId: string, input: AskBtwInput): Promise<BtwState> {
@@ -76,12 +119,15 @@ export async function askBtw(sessionId: string, input: AskBtwInput): Promise<Btw
76
119
  throw new BtwError('Unknown side question.', 'btw_topic_not_found');
77
120
  }
78
121
 
122
+ const approvalMode = input.approvalMode ?? (await loadPersistedAccessMode());
79
123
  const topic =
80
124
  existing ??
81
125
  (await createBtwTopic({
82
126
  sessionId,
83
127
  title: topicTitle(question),
84
- model: await topicModel(parent.sessionFile),
128
+ model: input.model ?? (await topicModel(parent.sessionFile)),
129
+ thinkingLevel: input.thinkingLevel,
130
+ approvalMode,
85
131
  }));
86
132
 
87
133
  const runtime = ensureBtwRuntime({
@@ -90,9 +136,64 @@ export async function askBtw(sessionId: string, input: AskBtwInput): Promise<Btw
90
136
  parentSessionFile: parent.sessionFile,
91
137
  cwd: parent.cwd,
92
138
  model: topic.model,
139
+ thinkingLevel: topic.thinkingLevel,
140
+ // The topic's own mode wins once set; a fresh topic adopts the composer's.
141
+ approvalMode: topic.approvalMode ?? approvalMode,
93
142
  });
94
143
 
95
- await runtime.ask(question, input.images);
144
+ await runtime.ask(composeMessageWithTextAttachments(question, input.textFiles ?? []), input.images);
145
+ return btwStateFor(sessionId);
146
+ }
147
+
148
+ /** One topic of this session, or a `btw_topic_not_found` refusal. */
149
+ async function requireTopic(sessionId: string, topicId: string) {
150
+ const topic = await getBtwTopic(topicId);
151
+ if (!topic || topic.sessionId !== sessionId) throw new BtwError('Unknown side question.', 'btw_topic_not_found');
152
+ return topic;
153
+ }
154
+
155
+ /**
156
+ * Re-target a topic's model. With a live child the change reaches it through
157
+ * `set_model` (omp applies it to the running turn, which is what the dropdown
158
+ * promises); an idle topic simply records it for the next spawn.
159
+ */
160
+ export async function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
161
+ await requireTopic(sessionId, topicId);
162
+ const runtime = getBtwRuntime(topicId);
163
+ if (runtime) {
164
+ await runtime.setModel(provider, modelId);
165
+ } else {
166
+ await setBtwTopicModel(topicId, { provider, id: modelId });
167
+ await publishBtwState(sessionId);
168
+ }
169
+ return btwStateFor(sessionId);
170
+ }
171
+
172
+ export async function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
173
+ await requireTopic(sessionId, topicId);
174
+ await setBtwTopicThinkingLevel(topicId, level === 'auto' ? null : level);
175
+ await getBtwRuntime(topicId)?.setThinkingLevel(level);
176
+ await publishBtwState(sessionId);
177
+ return btwStateFor(sessionId);
178
+ }
179
+
180
+ /**
181
+ * Adopt a new tool-approval mode for a topic. omp has no RPC for it, so an idle
182
+ * child is dropped and the next question respawns with the new flag; a running
183
+ * turn keeps its child (and therefore its mode) until it settles.
184
+ */
185
+ export async function setBtwApprovalMode(sessionId: string, topicId: string, mode: ApprovalMode): Promise<BtwState> {
186
+ await requireTopic(sessionId, topicId);
187
+ await setBtwTopicApprovalMode(topicId, mode);
188
+ await getBtwRuntime(topicId)?.setApprovalMode(mode);
189
+ await publishBtwState(sessionId);
190
+ return btwStateFor(sessionId);
191
+ }
192
+
193
+ /** Answer an ask/approval dialog a side child is blocked on. */
194
+ export async function respondBtwDialog(sessionId: string, topicId: string, id: string, response: Record<string, unknown>): Promise<BtwState> {
195
+ await requireTopic(sessionId, topicId);
196
+ getBtwRuntime(topicId)?.respondToDialog(id, response);
96
197
  return btwStateFor(sessionId);
97
198
  }
98
199
 
@@ -145,7 +246,7 @@ export async function promoteBtw(sessionId: string, topicId: string): Promise<{
145
246
  }
146
247
 
147
248
  const paths = await resolveBtwWorkspacePaths(sessionId, topicId);
148
- const title = topicTitle(topic.turns[0].question);
249
+ const title = `${PROMOTED_TITLE_PREFIX}${topicTitle(topic.turns[0].question)}`;
149
250
  const created = await materializePromotedTopic({
150
251
  parentSessionFile: parent.sessionFile,
151
252
  topicSessionFile: paths.sessionFile,