ompchamber 3.3.3 → 3.5.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 (73) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +315 -308
  3. package/dist/client/static/js/index-vx0ernrv.css +1 -0
  4. package/dist/client/static/js/{index-8ejde0b7.js → index-w0cem7n5.js} +641 -641
  5. package/package.json +1 -1
  6. package/src/client/components/common/session-actions-menu/index.tsx +201 -0
  7. package/src/client/components/common/session-delete-modal/index.tsx +128 -0
  8. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
  9. package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
  10. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
  11. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
  12. package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
  13. package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
  14. package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
  15. package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
  16. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
  17. package/src/client/components/workspace/btw-panel/index.tsx +20 -27
  18. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
  19. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
  20. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
  21. package/src/client/hooks/chat/btw/index.ts +7 -73
  22. package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
  23. package/src/client/hooks/chat/btw/intercept.ts +5 -2
  24. package/src/client/hooks/chat/btw/mode.ts +16 -76
  25. package/src/client/hooks/chat/timeline/actions.ts +22 -30
  26. package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
  27. package/src/client/hooks/chat/timeline/send.ts +8 -1
  28. package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
  29. package/src/client/hooks/workspace/session-delete.ts +94 -0
  30. package/src/server/lib/btw/child.server.test.ts +68 -0
  31. package/src/server/lib/btw/child.server.ts +17 -16
  32. package/src/server/lib/btw/frames.test.ts +3 -3
  33. package/src/server/lib/btw/frames.ts +1 -1
  34. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  35. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  36. package/src/server/lib/btw/prompt.ts +7 -11
  37. package/src/server/lib/btw/purge.server.ts +56 -0
  38. package/src/server/lib/btw/registry.server.ts +1 -9
  39. package/src/server/lib/btw/reply.ts +54 -0
  40. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  41. package/src/server/lib/btw/runtime.server.ts +77 -86
  42. package/src/server/lib/btw/schema.server.test.ts +84 -0
  43. package/src/server/lib/btw/schema.server.ts +8 -0
  44. package/src/server/lib/btw/service.server.ts +47 -78
  45. package/src/server/lib/btw/store.server.ts +9 -20
  46. package/src/server/lib/fs/path-segment.test.ts +100 -0
  47. package/src/server/lib/fs/path-segment.ts +45 -0
  48. package/src/server/lib/omp/rpc/constants.ts +10 -0
  49. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  50. package/src/server/lib/omp/session/delete.server.ts +136 -0
  51. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  52. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  53. package/src/server/lib/omp/session/messages.ts +27 -2
  54. package/src/server/lib/omp/session/reader.ts +13 -0
  55. package/src/server/routes/agent/command.ts +9 -1
  56. package/src/server/routes/btw/topics.ts +2 -59
  57. package/src/server/routes/sessions/delete.test.ts +84 -0
  58. package/src/server/routes/sessions/delete.ts +83 -0
  59. package/src/server/routes/sessions/index.ts +5 -0
  60. package/src/shared/lib/chat/btw/client.ts +0 -30
  61. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  62. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  63. package/src/shared/lib/chat/notice-text.ts +33 -0
  64. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  65. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  66. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  67. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  68. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  69. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  70. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  71. package/src/shared/lib/workspace/session-state/store.ts +20 -0
  72. package/src/shared/types/btw.ts +13 -34
  73. package/dist/client/static/js/index-bsxxt89j.css +0 -1
@@ -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 = ?', [
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The session-delete blast radius.
8
+ *
9
+ * `DELETE /api/sessions/:sessionId` removes a directory under the sessions root
10
+ * and another under the btw root, both by joining the id onto the root. The
11
+ * case that shipped broken was `.`: `join(root, '.')` is `root` itself, so the
12
+ * btw purge's unconditional workspace removal deleted EVERY session's side
13
+ * questions and the route still answered 404 — an invisible, root-wide wipe
14
+ * from a request that looked like a miss.
15
+ *
16
+ * These tests pin the two properties that made it possible: the guard rejects
17
+ * every value that is not one segment, and the btw purge refuses a non-segment
18
+ * even when called directly (the route is not the only entry point).
19
+ */
20
+
21
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
25
+ import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
26
+
27
+ describe('isSinglePathSegment', () => {
28
+ test('accepts the id shapes the app actually produces', () => {
29
+ // omp session ids are UUIDs; mock ids are integers; pending chats are `new-…`.
30
+ expect(isSinglePathSegment('11111111-2222-3333-4444-555555555555')).toBe(true);
31
+ expect(isSinglePathSegment('3')).toBe(true);
32
+ expect(isSinglePathSegment('new-1758800000000')).toBe(true);
33
+ // A dot INSIDE a name is fine — only a name that resolves to a directory is not.
34
+ expect(isSinglePathSegment('session.v2')).toBe(true);
35
+ });
36
+
37
+ test('refuses the values that resolve somewhere other than one child', () => {
38
+ // The shipped bug: join(root, '.') === root.
39
+ expect(isSinglePathSegment('.')).toBe(false);
40
+ expect(isSinglePathSegment('..')).toBe(false);
41
+ expect(isSinglePathSegment('a/b')).toBe(false);
42
+ expect(isSinglePathSegment('..\\a')).toBe(false);
43
+ expect(isSinglePathSegment('')).toBe(false);
44
+ // A NUL byte truncates at the syscall boundary.
45
+ expect(isSinglePathSegment('a\0b')).toBe(false);
46
+ });
47
+
48
+ test('the guard agrees with the join it is guarding', () => {
49
+ // The property that matters: whenever the guard says yes, joining onto a
50
+ // root stays directly inside it. Asserted against `path.join` itself rather
51
+ // than a restatement of the rule.
52
+ const root = '/tmp/root';
53
+ for (const id of ['11111111-2222-3333-4444-555555555555', '3', 'new-1', 'session.v2']) {
54
+ expect(isSinglePathSegment(id)).toBe(true);
55
+ expect(path.join(root, id).startsWith(`${root}${path.sep}`)).toBe(true);
56
+ expect(path.dirname(path.join(root, id))).toBe(root);
57
+ }
58
+ for (const id of ['.', '..', 'a/b', '../x']) {
59
+ expect(isSinglePathSegment(id)).toBe(false);
60
+ }
61
+ });
62
+ });
63
+
64
+ describe('purgeBtwForSession refuses a non-segment id', () => {
65
+ const root = '/tmp/omc-path-segment-test';
66
+ const btwRoot = path.join(root, 'btw');
67
+ const seeded = [
68
+ path.join(btwRoot, 'sessA', 'topicA', 'session.jsonl'),
69
+ path.join(btwRoot, 'sessB', 'topicB', 'session.jsonl'),
70
+ ];
71
+
72
+ beforeAll(async () => {
73
+ await fs.promises.rm(root, { recursive: true, force: true });
74
+ // Two unrelated sessions' side questions, exactly as the bug destroyed them.
75
+ // The btw root is derived from the database path, so pointing that at this
76
+ // tree is what puts them where the purge would look.
77
+ for (const file of seeded) {
78
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
79
+ await Bun.write(file, '{"type":"session"}\n');
80
+ }
81
+ Bun.env.OMPCHAMBER_DB_PATH = path.join(root, 'db.sqlite');
82
+ });
83
+
84
+ afterAll(async () => {
85
+ delete Bun.env.OMPCHAMBER_DB_PATH;
86
+ await fs.promises.rm(root, { recursive: true, force: true });
87
+ });
88
+
89
+ test('`.` does not remove the btw root', async () => {
90
+ // The guard returns before `listBtwTopics`, so no database is touched.
91
+ expect(await purgeBtwForSession('.')).toBe(0);
92
+ // Both sessions' transcripts must survive — this is the regression.
93
+ for (const file of seeded) expect(fs.existsSync(file)).toBe(true);
94
+ });
95
+
96
+ test('a separator does not reach outside the root', async () => {
97
+ expect(await purgeBtwForSession('../btw')).toBe(0);
98
+ for (const file of seeded) expect(fs.existsSync(file)).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One rule, two callers: does this id name exactly one child of a root?
8
+ *
9
+ * The purge paths join a session id onto a root to find the directory it owns —
10
+ * `join(sessionsRoot, …)`, `join(btwRoot, sessionId)` — and then remove it
11
+ * recursively. `path.join` NORMALIZES, so a value that is not a plain segment
12
+ * silently retargets the removal:
13
+ *
14
+ * join(root, '.') === root → deletes every child of the root
15
+ * join(root, '..') === dirname(root) → deletes outside the root
16
+ * join(root, 'a/b') → reaches into a sibling subtree
17
+ *
18
+ * Measured: `DELETE /api/sessions/.` (curl needs `--path-as-is`; it strips the
19
+ * trailing dot itself) passed a `/^[A-Za-z0-9._-]+$/` guard, normalized to the
20
+ * btw root, and removed two unrelated sessions' side-question transcripts.
21
+ *
22
+ * The check is deliberately structural rather than an enumeration of dangerous
23
+ * spellings: an id is safe exactly when it is one non-dot segment. Real ids
24
+ * satisfy that by construction — omp session ids are UUIDs and mock ids are
25
+ * integers — so nothing legitimate is refused.
26
+ *
27
+ * `normalize` is compared, not just scanned for separators, because it is the
28
+ * same operation the callers' `join` performs: asserting the two agree is what
29
+ * makes this a proof about the path that will actually be built, rather than a
30
+ * guess about which inputs look suspicious.
31
+ */
32
+
33
+ import { normalize } from 'node:path';
34
+
35
+ /** True when `id` names one child of a root, and nothing else. */
36
+ export function isSinglePathSegment(id: string): boolean {
37
+ if (id.length === 0) return false;
38
+ if (id === '.' || id === '..') return false;
39
+ if (id.includes('/') || id.includes('\\')) return false;
40
+ // A NUL byte would truncate the path at the syscall boundary.
41
+ if (id.includes('\0')) return false;
42
+ // The load-bearing assertion: normalization must be a no-op, so `join(root, id)`
43
+ // cannot resolve anywhere but directly under `root`.
44
+ return normalize(id) === id;
45
+ }
@@ -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.
@@ -15,6 +15,7 @@ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
15
15
  import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
16
16
  import { clearStreamStatus, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
17
17
  import { buildWebState, type WebStateHost } from '@/server/lib/omp/rpc/web-state';
18
+ import { isTuiOnlySlashCommand, tuiOnlyCommandNotice } from '@/shared/lib/chat/composer/tui-only';
18
19
 
19
20
  /** Runtime surface AgentSessionWrapper exposes to the command dispatcher. */
20
21
  export interface SessionCommandHost extends WebStateHost {
@@ -50,6 +51,44 @@ async function settleCommandTimeout(host: SessionCommandHost): Promise<never> {
50
51
  throw new WebRpcError('The OMP session stopped responding and was reset.', 'session_unresponsive');
51
52
  }
52
53
 
54
+ /**
55
+ * Refuse a prompt that invokes a command omp implements only in its TUI, and
56
+ * report it the way omp reports a real command result.
57
+ *
58
+ * This is the SERVER half of the guard in
59
+ * `client/hooks/chat/timeline/tui-only-guard.ts`. It exists because one prompt
60
+ * path never passes through the composer: the follow-up queue's auto-delivery
61
+ * calls `session.send({ type: 'prompt' })` directly from
62
+ * `lib/queue/delivery.server.ts`. A `/plan` queued before a run would otherwise
63
+ * be delivered to the model as literal text once the run ends.
64
+ *
65
+ * The refusal is framed as a real command result — a `command_output` notice
66
+ * plus, for a non-streaming prompt, the `agentInvoked:false` ack — so the
67
+ * client's existing fold renders the notice and settles the optimistic spinner
68
+ * with no new protocol.
69
+ *
70
+ * `streaming` is the caller's own knowledge of whether a turn is running: a
71
+ * steer of a refused command must not clear the flags of the turn it was aimed
72
+ * at (omp runs `session.steer()` with no slash handling at all, so a steer of
73
+ * `/plan` is exactly as meaningless as a fresh one).
74
+ *
75
+ * Returns true when the prompt was refused (the caller must not dispatch).
76
+ */
77
+ function refuseTuiOnlyPrompt(host: SessionCommandHost, message: unknown, streaming: boolean): boolean {
78
+ if (typeof message !== 'string' || !isTuiOnlySlashCommand(message)) return false;
79
+ host.emit({ type: 'command_output', text: tuiOnlyCommandNotice(message) });
80
+ if (!streaming) {
81
+ host.promptRunning = false;
82
+ host.awaitingAgentStart = false;
83
+ host.awaitingAgentStartDeadline = 0;
84
+ host.emit({ type: 'prompt_result', agentInvoked: false });
85
+ // Nothing ran, so the queue may hold the next item — give it the same
86
+ // delivery window a real consumed builtin or a run end would.
87
+ scheduleQueueDelivery(host);
88
+ }
89
+ return true;
90
+ }
91
+
53
92
  export async function dispatchSessionCommand(host: SessionCommandHost, command: Record<string, unknown>): Promise<unknown> {
54
93
  if (host.restarting) throw new WebRpcError(RESTARTING_MESSAGE, 'session_restarting');
55
94
  if (!host.isAlive()) throw new Error('Session is no longer running');
@@ -66,6 +105,11 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
66
105
  if (host.bashRunning) {
67
106
  throw new Error('Cannot send a prompt while a shell command is running');
68
107
  }
108
+ // Checked before the streaming-behavior split: a steer of a TUI-only
109
+ // command is just as meaningless as a fresh one. `streaming` keeps the
110
+ // refusal from clearing a running turn's flags when the prompt was a
111
+ // steer aimed at that turn.
112
+ if (refuseTuiOnlyPrompt(host, command.message, Boolean(command.streamingBehavior))) return null;
69
113
  const streamingBehavior = command.streamingBehavior as 'steer' | 'followUp' | undefined;
70
114
  // The live `stream` row starts with the DISPATCH, not with agent_start:
71
115
  // the spawn/ack round trip that precedes agent_start must not read as
@@ -229,6 +273,7 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
229
273
  if (!host.isRunning()) {
230
274
  throw new WebRpcError('The session is idle — start a prompt first.', 'session_idle');
231
275
  }
276
+ if (refuseTuiOnlyPrompt(host, command.message, true)) return null;
232
277
  await host.proc.sendCommand({
233
278
  type,
234
279
  message: command.message as string,
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Deleting a session: the transcript on disk, its subagent artifacts, and every
8
+ * chamber-side row keyed to the session id.
9
+ *
10
+ * The transcript is authoritative — omp's own session listing is derived from
11
+ * the JSONL alone — so a delete that removed only the chamber's rows would see
12
+ * the session come straight back on the next sidebar scan. What "delete" means
13
+ * here, in one place:
14
+ *
15
+ * - the session JSONL, plus any `<name>.jsonl.bak-*` copy an earlier rewind
16
+ * left beside it (a full copy of the same transcript: leaving it behind
17
+ * would mean the conversation was not actually deleted);
18
+ * - its sibling artifacts directory, which holds the subagent transcripts;
19
+ * - the `archived_sessions`, `session_ui_state`, `session_stream_state`,
20
+ * `queued_messages` and `chat_sessions` rows for that id;
21
+ * - in mock mode the demo `sessions` row and its `files` tree.
22
+ *
23
+ * The caller owns the process: a live omp child MUST be destroyed before this
24
+ * runs, because omp flushes its session state on shutdown and would otherwise
25
+ * recreate the file this just removed. Side-question rows and workspaces are
26
+ * the btw module's business (`purgeBtwForSession`).
27
+ */
28
+
29
+ import fs from 'fs';
30
+ import path from 'path';
31
+ import { getDb } from '@/server/db.server';
32
+ import type { DbClient } from '@/server/lib/db/client';
33
+ import { findSessionFileById } from '@/server/lib/omp/session/locator';
34
+ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
35
+ import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader';
36
+ import { siblingDirForSession } from '@/server/lib/omp/subagent/history/paths';
37
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
38
+
39
+ export interface SessionPurgeResult {
40
+ /** Something keyed to this id existed — a transcript, a mock session row, or
41
+ * a chamber row. False means the id resolved nowhere at all. */
42
+ found: boolean;
43
+ fileRemoved: boolean;
44
+ artifactsRemoved: boolean;
45
+ }
46
+
47
+ /**
48
+ * Chamber rows keyed by a session id. Every statement is a no-op when the id
49
+ * matches nothing, which is what makes one purge serve both modes: real
50
+ * sessions (omp UUIDs, present on disk only) and mock ones (numeric ids in
51
+ * `sessions`).
52
+ *
53
+ * `files` carries a FOREIGN KEY to `sessions` and `foreign_keys` is ON, so its
54
+ * children go before the session row or the delete is refused.
55
+ */
56
+ const SESSION_ROW_DELETES: readonly (readonly [string, string])[] = [
57
+ ['archived_sessions', 'session_id'],
58
+ ['session_ui_state', 'session_id'],
59
+ ['session_stream_state', 'session_id'],
60
+ ['queued_messages', 'session_id'],
61
+ ['chat_sessions', 'session_id'],
62
+ ['files', 'session_id'],
63
+ ];
64
+
65
+ async function purgeChamberRows(db: DbClient, sessionId: string): Promise<number> {
66
+ let changes = 0;
67
+ for (const [table, column] of SESSION_ROW_DELETES) {
68
+ const result = await db.run(`DELETE FROM ${table} WHERE ${column} = ?`, [sessionId]);
69
+ changes += result.changes ?? 0;
70
+ }
71
+ const mockRow = await db.run('DELETE FROM sessions WHERE id = ?', [sessionId]);
72
+ return changes + (mockRow.changes ?? 0);
73
+ }
74
+
75
+ /** Remove the transcript and the `.bak-*` copies beside it. */
76
+ async function removeTranscript(filePath: string): Promise<boolean> {
77
+ const existed = await Bun.file(filePath).exists();
78
+ await fs.promises.rm(filePath, { force: true });
79
+ const dir = path.dirname(filePath);
80
+ const prefix = `${path.basename(filePath)}.bak-`;
81
+ try {
82
+ for (const entry of await fs.promises.readdir(dir)) {
83
+ if (entry.startsWith(prefix)) await fs.promises.rm(path.join(dir, entry), { force: true });
84
+ }
85
+ } catch {
86
+ // A backup that cannot be listed is not worth failing a delete the
87
+ // transcript half of which already succeeded.
88
+ }
89
+ return existed;
90
+ }
91
+
92
+ /**
93
+ * Purge everything the chamber and the agent directory hold for `sessionId`.
94
+ * `sessionId` must be a single safe path segment — it names the artifacts
95
+ * directory — which the route validates before calling.
96
+ */
97
+ export async function purgeSessionData(sessionId: string): Promise<SessionPurgeResult> {
98
+ // Second gate, independent of the route's: an id that is not one segment
99
+ // would make `siblingDirForSession` and the btw purge resolve outside the
100
+ // session it names. Refusing here means a future caller that forgets the
101
+ // route's guard still cannot widen the blast radius.
102
+ if (!isSinglePathSegment(sessionId)) {
103
+ return { found: false, fileRemoved: false, artifactsRemoved: false };
104
+ }
105
+
106
+ const filePath = await findSessionFileById(sessionId);
107
+ let fileRemoved = false;
108
+ let artifactsRemoved = false;
109
+ if (filePath) {
110
+ fileRemoved = await removeTranscript(filePath);
111
+ const artifactsDir = siblingDirForSession(filePath);
112
+ // `stat`, not `Bun.file().exists()`: the latter answers for regular files
113
+ // only and reports a directory as absent, which would claim every session
114
+ // had no subagent transcripts.
115
+ artifactsRemoved = await fs.promises
116
+ .stat(artifactsDir)
117
+ .then((stats) => stats.isDirectory(), () => false);
118
+ await fs.promises.rm(artifactsDir, { recursive: true, force: true });
119
+ }
120
+
121
+ const db = await getDb();
122
+ const rowChanges = await purgeChamberRows(db, sessionId);
123
+
124
+ // Both caches outlive the mutation by their own keys: the file list/scan pair
125
+ // is mtime-keyed (a delete bumps the parent directory, but the memo can still
126
+ // answer first) and the sidebar dataset is TTL'd, so without this the deleted
127
+ // row is served back to the refresh that follows the delete.
128
+ clearSessionFileCaches();
129
+ invalidateOmpSidebarData();
130
+
131
+ return {
132
+ found: Boolean(filePath) || rowChanges > 0,
133
+ fileRemoved,
134
+ artifactsRemoved,
135
+ };
136
+ }