ompchamber 3.3.3 → 3.4.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 (55) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +275 -268
  3. package/dist/client/static/js/{index-8ejde0b7.js → index-h05z90mw.js} +581 -581
  4. package/package.json +1 -1
  5. package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
  6. package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
  7. package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
  8. package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
  9. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
  10. package/src/client/components/workspace/btw-panel/index.tsx +20 -27
  11. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
  12. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
  13. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
  14. package/src/client/hooks/chat/btw/index.ts +7 -73
  15. package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
  16. package/src/client/hooks/chat/btw/intercept.ts +5 -2
  17. package/src/client/hooks/chat/btw/mode.ts +16 -76
  18. package/src/client/hooks/chat/timeline/actions.ts +22 -30
  19. package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
  20. package/src/client/hooks/chat/timeline/send.ts +8 -1
  21. package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
  22. package/src/server/lib/btw/child.server.test.ts +68 -0
  23. package/src/server/lib/btw/child.server.ts +17 -16
  24. package/src/server/lib/btw/frames.test.ts +3 -3
  25. package/src/server/lib/btw/frames.ts +1 -1
  26. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  27. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  28. package/src/server/lib/btw/prompt.ts +7 -11
  29. package/src/server/lib/btw/registry.server.ts +1 -9
  30. package/src/server/lib/btw/reply.ts +54 -0
  31. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  32. package/src/server/lib/btw/runtime.server.ts +77 -86
  33. package/src/server/lib/btw/schema.server.test.ts +84 -0
  34. package/src/server/lib/btw/schema.server.ts +8 -0
  35. package/src/server/lib/btw/service.server.ts +47 -78
  36. package/src/server/lib/btw/store.server.ts +9 -20
  37. package/src/server/lib/omp/rpc/constants.ts +10 -0
  38. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  39. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  41. package/src/server/lib/omp/session/messages.ts +27 -2
  42. package/src/server/routes/agent/command.ts +9 -1
  43. package/src/server/routes/btw/topics.ts +2 -59
  44. package/src/shared/lib/chat/btw/client.ts +0 -30
  45. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  46. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  47. package/src/shared/lib/chat/notice-text.ts +33 -0
  48. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  49. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  50. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  51. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  52. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  53. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  54. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  55. package/src/shared/types/btw.ts +13 -34
@@ -12,11 +12,14 @@
12
12
  * (no re-prompting it as text, and the parent's prompt-cache prefix survives)
13
13
  * and never writes the parent file.
14
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
+ * The child runs WITHOUT tools (`--no-tools`), the same end state omp's own
16
+ * `/btw` reaches through `runEphemeralTurn`: the side turn answers from the
17
+ * context it already has, never executes a tool, and discards any tool call the
18
+ * model emits anyway. With no tool surface there is no approval gate to park on,
19
+ * so no dialog can reach this child either.
20
+ *
21
+ * Model and thinking selector come from the CHAT (see `retarget`), never from a
22
+ * picker of this panel's own — omp's side turn reads `request.session.model`.
20
23
  *
21
24
  * The child is disposable by design: history lives in SQLite plus the topic
22
25
  * transcript, so an idle reclaim costs a respawn, not the conversation — a
@@ -28,7 +31,6 @@
28
31
 
29
32
  import { RpcProcess, type RpcFrame } from '@/server/lib/omp/rpc/process';
30
33
  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';
32
34
  import { resolveOmpBin } from '@/server/lib/omp/core/cli';
33
35
  import { buildBtwSpawnArgs, type BtwSpawnSettings } from '@/server/lib/btw/child.server';
34
36
  import {
@@ -42,10 +44,9 @@ import {
42
44
  trackTurnMessage,
43
45
  type BtwLifecycleHost,
44
46
  } from '@/server/lib/btw/lifecycle.server';
45
- import { setBtwTopicModel, setBtwTopicThinkingLevel } from '@/server/lib/btw/store.server';
47
+ import { setBtwTopicLeaf, setBtwTopicModel } from '@/server/lib/btw/store.server';
46
48
  import { createBtwWorkspace, resolveBtwWorkspacePaths, type BtwWorkspace } from '@/server/lib/btw/session-copy.server';
47
- import { finalAnswer, finalStatus, isAnswerableUiMethod } from '@/server/lib/btw/frames';
48
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
49
+ import { finalAnswer, finalStatus } from '@/server/lib/btw/frames';
49
50
  import type { AgentImage, BtwFrame, BtwLiveTurn, BtwModel, BtwTurnStatus, ChatMessageData } from '@/shared/types';
50
51
 
51
52
  /** Prompt images, validated by the route before they reach the child. */
@@ -59,7 +60,6 @@ export interface BtwRuntimeContext {
59
60
  cwd: string;
60
61
  model?: BtwModel;
61
62
  thinkingLevel?: string;
62
- approvalMode?: ApprovalMode;
63
63
  }
64
64
 
65
65
  /** Where the runtime reports what happened. */
@@ -94,10 +94,10 @@ export class BtwRuntime implements BtwLifecycleHost {
94
94
  /** Last activity phrase published, so repeats are not re-published. */
95
95
  activity = '';
96
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;
97
+ /** Model the live child reports, so a retarget only sends a real change. */
98
+ private reportedModel: { provider: string; id: string } | null = null;
99
+ /** Thinking selector the live child reports (`auto` included). */
100
+ private reportedThinkingLevel: string | null = null;
101
101
 
102
102
  constructor(
103
103
  private readonly context: BtwRuntimeContext,
@@ -144,41 +144,27 @@ export class BtwRuntime implements BtwLifecycleHost {
144
144
  }
145
145
 
146
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.
147
+ * Adopt the chat's current model and thinking selector before a question is
148
+ * asked. omp's side turn reads `request.session.model` at run time, so a chat
149
+ * that switched model or level is mirrored here — a live child is re-targeted
150
+ * over RPC (`set_model` / `set_thinking_level` apply to the running session),
151
+ * and a cold one simply spawns with these values.
150
152
  */
151
- async setModel(provider: string, modelId: string): Promise<void> {
152
- await setBtwTopicModel(this.topicId, { provider, id: modelId });
153
- this.sink.stateChanged();
153
+ async retarget(model: BtwModel | undefined, thinkingLevel: string | undefined): Promise<void> {
154
+ this.context.model = model;
155
+ this.context.thinkingLevel = thinkingLevel;
154
156
  const proc = this.proc;
155
157
  if (!proc?.isAlive) return;
156
- await proc.sendCommand({ type: 'set_model', provider, modelId }, GET_STATE_TIMEOUT_MS);
157
- }
158
-
159
- /** Re-target the live child's thinking level (`auto` leaves omp alone). */
160
- async setThinkingLevel(level: string): Promise<void> {
161
- const proc = this.proc;
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();
158
+ if (model && (this.reportedModel?.provider !== model.provider || this.reportedModel.id !== model.id)) {
159
+ await proc.sendCommand({ type: 'set_model', provider: model.provider, modelId: model.id }, GET_STATE_TIMEOUT_MS);
160
+ // Remember what was sent: without this the next question re-sends the same
161
+ // pair, because `reportedModel` would still hold the spawn-time value.
162
+ this.reportedModel = { provider: model.provider, id: model.id };
163
+ }
164
+ if (thinkingLevel && thinkingLevel !== this.reportedThinkingLevel) {
165
+ await proc.sendCommand({ type: 'set_thinking_level', level: thinkingLevel }, GET_STATE_TIMEOUT_MS);
166
+ this.reportedThinkingLevel = thinkingLevel;
167
+ }
182
168
  }
183
169
 
184
170
  /** Reclaim the child; the topic's history survives in SQLite and on disk. */
@@ -206,20 +192,31 @@ export class BtwRuntime implements BtwLifecycleHost {
206
192
  private async openWorkspace(): Promise<BtwWorkspace> {
207
193
  const paths = await resolveBtwWorkspacePaths(this.context.sessionId, this.context.topicId);
208
194
  const existing = await Bun.file(paths.sessionFile).exists();
209
- const workspace = existing
210
- ? { ...paths, leafId: null }
211
- : await createBtwWorkspace({
212
- parentSessionId: this.context.sessionId,
213
- topicId: this.context.topicId,
214
- parentSessionFile: this.context.parentSessionFile,
215
- });
195
+ if (existing) {
196
+ this.workspace = { ...paths, leafId: null };
197
+ return this.workspace;
198
+ }
199
+ const workspace = await createBtwWorkspace({
200
+ parentSessionId: this.context.sessionId,
201
+ topicId: this.context.topicId,
202
+ parentSessionFile: this.context.parentSessionFile,
203
+ });
204
+ // The snapshot's leaf is the promotion guard's baseline: the branch may only
205
+ // be cut while the parent still ENDS at the entry this copy was taken from.
206
+ // Written here, where the snapshot is actually taken, because a later
207
+ // transcript cannot reconstruct which entry that was.
208
+ if (workspace.leafId) await setBtwTopicLeaf(this.context.topicId, workspace.leafId);
216
209
  this.workspace = workspace;
217
- // The snapshot's leaf is what a later promotion must still find in the
218
- // parent; a topic resumed from disk keeps the leaf it was created with.
219
210
  return workspace;
220
211
  }
221
212
 
222
213
  start(): Promise<RpcProcess> {
214
+ // Reuse the live child. Without this guard every question spawns ANOTHER
215
+ // `omp --resume` for the same topic and abandons the previous one — measured:
216
+ // two live children with the same transcript, the older one holding the
217
+ // pre-retarget `--model`, both appending to the same file. A child that died
218
+ // (`proc` nulled by `handleSideChildExit`) still gets a fresh spawn.
219
+ if (this.proc?.isAlive) return Promise.resolve(this.proc);
223
220
  if (this.starting) return this.starting;
224
221
  const started = this.spawnChild();
225
222
  this.starting = started;
@@ -238,7 +235,6 @@ export class BtwRuntime implements BtwLifecycleHost {
238
235
  const settings: BtwSpawnSettings = {
239
236
  model: this.context.model,
240
237
  thinkingLevel: this.context.thinkingLevel,
241
- approvalMode: this.context.approvalMode,
242
238
  };
243
239
  const proc = new RpcProcess({
244
240
  cwd: this.context.cwd,
@@ -247,7 +243,8 @@ export class BtwRuntime implements BtwLifecycleHost {
247
243
  onExit: (info) => handleSideChildExit(this, info.stderrTail),
248
244
  });
249
245
  this.proc = proc;
250
- this.spawnedApprovalMode = this.context.approvalMode;
246
+ this.reportedModel = null;
247
+ this.reportedThinkingLevel = null;
251
248
  const ready = await proc.waitReady(READY_TIMEOUT_MS);
252
249
  await proc.negotiateProtocol(ready);
253
250
  await this.captureModel(proc);
@@ -255,22 +252,29 @@ export class BtwRuntime implements BtwLifecycleHost {
255
252
  }
256
253
 
257
254
  /**
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.
255
+ * Record what the child reports about itself: the display name of the model
256
+ * it resolved (for the run footer) and the model/selector it runs with, so a
257
+ * `retarget` before the next question only sends a real change.
258
+ *
259
+ * The THINKING LEVEL is NOT written back to the topic. `get_state` reports the
260
+ * level the child RESOLVED (an `auto` chat resolves to a concrete effort), and
261
+ * storing that would pin every later spawn to it — the side conversation would
262
+ * stop re-classifying per turn while the chat it mirrors keeps doing so. The
263
+ * chat's own recorded selector is what each ask reads instead.
262
264
  */
263
265
  private async captureModel(proc: RpcProcess): Promise<void> {
264
266
  try {
265
267
  const state = await proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
266
268
  const reported = state.model;
267
- if (reported?.provider && reported.id && reported.name) {
268
- await setBtwTopicModel(this.topicId, { provider: reported.provider, id: reported.id, name: reported.name });
269
- this.sink.stateChanged();
269
+ if (reported?.provider && reported.id) {
270
+ this.reportedModel = { provider: reported.provider, id: reported.id };
271
+ if (reported.name) {
272
+ await setBtwTopicModel(this.topicId, { provider: reported.provider, id: reported.id, name: reported.name });
273
+ this.sink.stateChanged();
274
+ }
270
275
  }
271
276
  if (typeof state.thinkingLevel === 'string' && state.thinkingLevel) {
272
- await setBtwTopicThinkingLevel(this.topicId, state.thinkingLevel);
273
- this.sink.stateChanged();
277
+ this.reportedThinkingLevel = state.thinkingLevel;
274
278
  }
275
279
  } catch {
276
280
  // A chip without a display name is cosmetic; the topic keeps its model.
@@ -280,38 +284,25 @@ export class BtwRuntime implements BtwLifecycleHost {
280
284
  private handleFrame(frame: RpcFrame): void {
281
285
  if (frame.type === 'message_update' || frame.type === 'message_start') {
282
286
  // 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.
287
+ // `trackTurnMessage`): thinking, prose and usage reach the panel through
288
+ // the same mapper the chat timeline uses.
285
289
  trackTurnMessage(this, frame);
286
290
  trackTurnActivity(this, frame);
287
291
  } else if (frame.type === 'message_end') {
288
292
  // 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);
293
+ // real usage, where the streaming frames report zeros, and its thinking is
294
+ // no longer generating. Upserting it replaces the streaming row in place
295
+ // (same message id), which is what puts token counts on the run footer
296
+ // without leaving a pulsing accordion behind on the next reload.
297
+ trackTurnMessage(this, frame, false);
295
298
  } else if (frame.type === 'agent_end') {
296
299
  // `isTerminal: false` means maintenance scheduled more work; the turn is
297
300
  // not done until a terminal end arrives (mirrors the session wrapper).
298
301
  if (frame.isTerminal === false) return;
299
302
  void settleSideTurn(this, finalStatus(frame.messages), finalAnswer(frame.messages, ''));
300
- } else if (frame.type === 'extension_ui_request') {
301
- this.trackDialog(frame);
302
303
  }
303
304
  resetSideIdleTimer(this);
304
305
  }
305
-
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();
314
- }
315
306
  }
316
307
 
317
308
  export type { BtwTurnStatus };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The BTW schema's migrations.
8
+ *
9
+ * `ensureBtwSchema` runs once per database open, so every branch has to be safe
10
+ * on a database that already has the newest shape AND on one created before the
11
+ * column or the status word existed. The status rename is the one that is easy
12
+ * to get wrong: a stored `failed` is not a column, so it needs a data migration
13
+ * rather than an `ALTER`, and a database written before the vocabulary changed
14
+ * would otherwise render a turn that never completes.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { rmSync } from 'node:fs';
21
+ import { createDb, type DbClient } from '@/server/lib/db/client';
22
+ import { ensureBtwSchema } from '@/server/lib/btw/schema.server';
23
+
24
+ const paths: string[] = [];
25
+
26
+ function tempDb(): DbClient {
27
+ const path = join(tmpdir(), `btw-schema-${crypto.randomUUID()}.sqlite`);
28
+ paths.push(path);
29
+ return createDb(path);
30
+ }
31
+
32
+ describe('ensureBtwSchema', () => {
33
+ test('creates the tables on a fresh database', async () => {
34
+ const db = tempDb();
35
+ await ensureBtwSchema(db);
36
+
37
+ const columns = (await db.all('PRAGMA table_info(btw_topics)')) as { name: string }[];
38
+ expect(columns.map((column) => column.name)).toContain('leaf_id');
39
+ expect(columns.map((column) => column.name)).toContain('thinking_level');
40
+ });
41
+
42
+ test('is idempotent', async () => {
43
+ const db = tempDb();
44
+ await ensureBtwSchema(db);
45
+ await ensureBtwSchema(db);
46
+
47
+ const tables = (await db.all("SELECT name FROM sqlite_master WHERE type = 'table'")) as { name: string }[];
48
+ expect(tables.filter((table) => table.name === 'btw_turns')).toHaveLength(1);
49
+ });
50
+
51
+ test('rewrites a legacy failed turn as error', async () => {
52
+ const db = tempDb();
53
+ // A database from before the vocabulary change: the column set is old and a
54
+ // turn is recorded as `failed`.
55
+ await db.exec(`
56
+ CREATE TABLE btw_topics (
57
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
58
+ provider TEXT, model_id TEXT, model_name TEXT, leaf_id TEXT, promoted_session_id TEXT,
59
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
60
+ );
61
+ CREATE TABLE btw_turns (
62
+ topic_id TEXT NOT NULL, turn_index INTEGER NOT NULL, question TEXT NOT NULL DEFAULT '',
63
+ answer TEXT NOT NULL DEFAULT '', status TEXT NOT NULL,
64
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
65
+ PRIMARY KEY (topic_id, turn_index)
66
+ );
67
+ `);
68
+ await db.run(
69
+ "INSERT INTO btw_turns (topic_id, turn_index, status, created_at, updated_at) VALUES ('t', 0, 'failed', 1, 1)",
70
+ );
71
+
72
+ await ensureBtwSchema(db);
73
+
74
+ const turn = (await db.get("SELECT status FROM btw_turns WHERE topic_id = 't'")) as { status: string };
75
+ expect(turn.status).toBe('error');
76
+ // The additive columns arrive on the same pass.
77
+ const columns = (await db.all('PRAGMA table_info(btw_topics)')) as { name: string }[];
78
+ expect(columns.map((column) => column.name)).toContain('approval_mode');
79
+ });
80
+ });
81
+
82
+ process.on('exit', () => {
83
+ for (const path of paths) rmSync(path, { force: true });
84
+ });
@@ -7,6 +7,10 @@
7
7
  * BTW schema, kept out of `db.server.ts`'s bootstrap block so that file's
8
8
  * growth stays attributable (it was already at the ceiling before this
9
9
  * feature). Called once per database open, like the other migrations there.
10
+ *
11
+ * `approval_mode` is retained for rows written while a side child ran WITH tools
12
+ * and an approval dropdown governed it; nothing writes or reads it now that the
13
+ * child runs `--no-tools`.
10
14
  */
11
15
 
12
16
  import type { DbClient } from '@/server/lib/db/client';
@@ -68,4 +72,8 @@ export async function ensureBtwSchema(db: DbClient): Promise<void> {
68
72
  if (await hasColumn(db, added.table, added.column)) continue;
69
73
  await db.exec(added.ddl);
70
74
  }
75
+ // A turn that ended in a provider error used to be recorded as `failed`; the
76
+ // vocabulary now matches omp's own (`error`). Row data, not a column, so it is
77
+ // an unconditional UPDATE — a second run matches nothing.
78
+ await db.run("UPDATE btw_turns SET status = 'error' WHERE status = 'failed'");
71
79
  }
@@ -12,32 +12,32 @@
12
12
  * cancel — a second question is refused, exactly like the TUI);
13
13
  * - a new question is its own topic; only a follow-up continues one, and it
14
14
  * sees only that topic's turns (plus the parent's context);
15
+ * - the side conversation runs on the CHAT's model and thinking selector, read
16
+ * from the parent transcript at every ask — the panel has no pickers of its
17
+ * own, exactly like omp's `/btw`, which runs the side turn on
18
+ * `request.session.model`;
15
19
  * - promotion is refused once the parent transcript has moved past the point
16
- * the topic was snapshotted at, and while a turn is still running.
20
+ * the topic was snapshotted at, while a turn is still running, and for a
21
+ * topic with follow-ups (a branch carries one question/answer pair).
17
22
  */
18
23
 
19
24
  import { isMockMode } from '@/server/mock.server';
20
25
  import { findSessionFileById } from '@/server/lib/omp/session/locator';
21
26
  import { readRawHeaderLine } from '@/server/lib/omp/session/files';
22
- import { loadSessionModel } from '@/server/lib/omp/session/messages';
23
- import { resolveSpawnCwd } from '@/server/lib/omp/rpc/manager';
24
- import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
25
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
27
+ import { loadSessionModel, loadSessionThinkingSelector } from '@/server/lib/omp/session/messages';
28
+ import { getRpcSession, resolveSpawnCwd } from '@/server/lib/omp/rpc/manager';
26
29
  import { composeMessageWithTextAttachments, type AttachedTextFileData } from '@/shared/lib/chat/attachments';
27
30
  import {
28
31
  createBtwTopic,
29
32
  deleteBtwTopic,
30
33
  getBtwTopic,
31
34
  readBtwTopicLeaf,
32
- setBtwTopicApprovalMode,
33
- setBtwTopicModel,
34
35
  setBtwTopicPromoted,
35
- setBtwTopicThinkingLevel,
36
36
  } from '@/server/lib/btw/store.server';
37
37
  import { promoteBtwTopic as materializePromotedTopic, readTranscriptLeaf, removeBtwWorkspace, resolveBtwWorkspacePaths } from '@/server/lib/btw/session-copy.server';
38
38
  import { btwStateFor, ensureBtwRuntime, findRunningBtwRuntime, forgetBtwRuntime, getBtwRuntime, publishBtwState } from '@/server/lib/btw/registry.server';
39
39
  import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
40
- import type { BtwModel, BtwState } from '@/shared/types';
40
+ import type { BtwState } from '@/shared/types';
41
41
 
42
42
  /** Longest a topic label may be, matching the sidebar's session-title length. */
43
43
  const TOPIC_TITLE_CHARS = 60;
@@ -93,15 +93,6 @@ export interface AskBtwInput {
93
93
  * be asked about a file the same way a chat turn can.
94
94
  */
95
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;
105
96
  }
106
97
 
107
98
  export async function askBtw(sessionId: string, input: AskBtwInput): Promise<BtwState> {
@@ -119,15 +110,18 @@ export async function askBtw(sessionId: string, input: AskBtwInput): Promise<Btw
119
110
  throw new BtwError('Unknown side question.', 'btw_topic_not_found');
120
111
  }
121
112
 
122
- const approvalMode = input.approvalMode ?? (await loadPersistedAccessMode());
113
+ // The side conversation mirrors the chat: model and thinking selector are read
114
+ // from the parent transcript at every ask, so a change made in the chat reaches
115
+ // the next side question (omp reads `request.session.model` the same way).
116
+ const model = await topicModel(parent.sessionFile);
117
+ const thinkingLevel = await loadSessionThinkingSelector(parent.sessionFile);
123
118
  const topic =
124
119
  existing ??
125
120
  (await createBtwTopic({
126
121
  sessionId,
127
122
  title: topicTitle(question),
128
- model: input.model ?? (await topicModel(parent.sessionFile)),
129
- thinkingLevel: input.thinkingLevel,
130
- approvalMode,
123
+ model,
124
+ thinkingLevel,
131
125
  }));
132
126
 
133
127
  const runtime = ensureBtwRuntime({
@@ -135,68 +129,17 @@ export async function askBtw(sessionId: string, input: AskBtwInput): Promise<Btw
135
129
  sessionId,
136
130
  parentSessionFile: parent.sessionFile,
137
131
  cwd: parent.cwd,
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,
132
+ model,
133
+ thinkingLevel,
142
134
  });
143
135
 
136
+ // A live child keeps the model it was spawned with; re-target it before the
137
+ // prompt so this question runs on what the chat is on NOW.
138
+ await runtime.retarget(model, thinkingLevel);
144
139
  await runtime.ask(composeMessageWithTextAttachments(question, input.textFiles ?? []), input.images);
145
140
  return btwStateFor(sessionId);
146
141
  }
147
142
 
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);
197
- return btwStateFor(sessionId);
198
- }
199
-
200
143
  /** The parent's model, so the side conversation runs on the visible model. */
201
144
  async function topicModel(sessionFile: string) {
202
145
  const model = await loadSessionModel(sessionFile);
@@ -210,6 +153,19 @@ export async function abortBtw(sessionId: string, topicId: string): Promise<BtwS
210
153
  return btwStateFor(sessionId);
211
154
  }
212
155
 
156
+ /**
157
+ * Refuse a session operation that would move the conversation out from under a
158
+ * running side question. omp blocks the same operations while a `/btw` answer is
159
+ * in flight or unsaved (`BtwController.withSessionMove` / `flush`); a chamber
160
+ * chat that switched sessions mid-question would leave the answer attached to a
161
+ * transcript nobody is looking at.
162
+ */
163
+ export async function assertBtwIdle(sessionId: string, action: string): Promise<void> {
164
+ if (findRunningBtwRuntime(sessionId)) {
165
+ throw new BtwError(`A side question is still running — wait for it, or cancel it, before you ${action}.`, 'btw_busy');
166
+ }
167
+ }
168
+
213
169
  /**
214
170
  * Turn the topic into a session of the chat's own: a branch of the parent
215
171
  * conversation whose tip is the side answer, opened by the caller.
@@ -225,8 +181,21 @@ export async function promoteBtw(sessionId: string, topicId: string): Promise<{
225
181
  if (topic.turns.some((turn) => turn.status === 'running')) {
226
182
  throw new BtwError('Wait for the side answer to finish, or cancel it, before promoting it.', 'btw_busy');
227
183
  }
184
+ // omp's branch action carries ONE question/answer pair and refuses a topic with
185
+ // follow-ups, so the earlier turns cannot be silently dropped from the branch.
186
+ if (topic.turns.length > 1) {
187
+ throw new BtwError(
188
+ 'A side question with follow-ups cannot be promoted — omp branches one answer at a time.',
189
+ 'btw_multi_turn',
190
+ );
191
+ }
228
192
 
229
193
  const parent = await resolveParent(sessionId);
194
+ // The branch is cut from the parent's transcript, so the chat must be idle:
195
+ // omp's guard refuses while `session.isStreaming`.
196
+ if (getRpcSession(sessionId)?.isRunning()) {
197
+ throw new BtwError('The chat is still running — wait for it to finish before promoting a side question.', 'btw_busy');
198
+ }
230
199
  const snapshotLeaf = await readBtwTopicLeaf(topicId);
231
200
  if (snapshotLeaf) {
232
201
  const currentLeaf = await readTranscriptLeaf(parent.sessionFile);
@@ -15,10 +15,14 @@
15
15
  * `leaf_id` is the parent session's transcript leaf when the topic started.
16
16
  * Promotion refuses when the parent has moved on, exactly like omp's own
17
17
  * `/btw` branch guard ("session changed since /btw started").
18
+ *
19
+ * `thinking_level` holds the chat's thinking SELECTOR (`auto` included), not the
20
+ * effort the side child resolved; `approval_mode` is no longer written — a side
21
+ * child runs `--no-tools`, so there is no approval gate to configure. Both
22
+ * columns stay in the schema for existing rows.
18
23
  */
19
24
 
20
25
  import { getDb } from '@/server/db.server';
21
- import { isApprovalMode, type ApprovalMode } from '@/shared/lib/omp/config/access-mode';
22
26
  import type { BtwModel, BtwTopic, BtwTurn, BtwTurnStatus, ChatMessageData } from '@/shared/types';
23
27
 
24
28
  interface TopicRow {
@@ -51,7 +55,7 @@ const TURN_STATUSES: Record<string, true> = {
51
55
  running: true,
52
56
  complete: true,
53
57
  cancelled: true,
54
- failed: true,
58
+ error: true,
55
59
  interrupted: true,
56
60
  };
57
61
 
@@ -72,7 +76,6 @@ function rowToTopic(row: TopicRow): BtwTopic {
72
76
  updatedAt: row.updated_at,
73
77
  ...(model ? { model } : {}),
74
78
  ...(row.thinking_level ? { thinkingLevel: row.thinking_level } : {}),
75
- ...(isApprovalMode(row.approval_mode) ? { approvalMode: row.approval_mode } : {}),
76
79
  ...(row.promoted_session_id ? { promotedSessionId: row.promoted_session_id } : {}),
77
80
  turns: [],
78
81
  };
@@ -138,10 +141,8 @@ export interface NewBtwTopicInput {
138
141
  sessionId: string;
139
142
  title: string;
140
143
  model?: BtwModel;
144
+ /** The chat's thinking selector, so the side child classifies like the chat. */
141
145
  thinkingLevel?: string;
142
- approvalMode?: ApprovalMode;
143
- /** Parent transcript leaf when the topic was created (promotion guard). */
144
- leafId?: string | null;
145
146
  }
146
147
 
147
148
  export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic> {
@@ -155,8 +156,8 @@ export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic>
155
156
  model_id: input.model?.id ?? null,
156
157
  model_name: input.model?.name ?? null,
157
158
  thinking_level: input.thinkingLevel ?? null,
158
- approval_mode: input.approvalMode ?? null,
159
- leaf_id: input.leafId ?? null,
159
+ approval_mode: null,
160
+ leaf_id: null,
160
161
  promoted_session_id: null,
161
162
  created_at: now,
162
163
  updated_at: now,
@@ -262,18 +263,6 @@ export async function setBtwTopicModel(topicId: string, model: BtwModel): Promis
262
263
  ]);
263
264
  }
264
265
 
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
-
277
266
  export async function setBtwTopicPromoted(topicId: string, promotedSessionId: string): Promise<void> {
278
267
  const db = await getDb();
279
268
  await db.run('UPDATE btw_topics SET promoted_session_id = ?, updated_at = ? WHERE id = ?', [
@@ -134,6 +134,16 @@ export const PASSTHROUGH_COMMANDS = new Set([
134
134
  // whose liveness probe had just reported `running: false`).
135
135
  export const OBSERVER_ONLY_COMMANDS = new Set(['get_subagents', 'get_subagent_messages']);
136
136
 
137
+ /**
138
+ * Commands that MOVE the conversation: they replace the transcript the session
139
+ * is running on, so a side question that snapshotted the old one would be left
140
+ * describing a conversation the chat has already left. omp blocks the same
141
+ * operations while a `/btw` answer is in flight or unsaved
142
+ * (`BtwController.withSessionMove` / `flush`); the chamber refuses them with
143
+ * `btw_busy` (409) instead.
144
+ */
145
+ export const CONVERSATION_MOVING_COMMANDS = new Set(['switch_session', 'branch', 'new_session', 'handoff']);
146
+
137
147
  // Commands that can carry user-attached images to the model. All of them must
138
148
  // pass the same server-side per-image/count/aggregate validation before the
139
149
  // payload reaches omp — a client is free to POST any of them directly.