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
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * Builtin-command output must survive a reload. omp writes NOTHING to the
6
+ * session JSONL for a `/context` or `/usage` turn — the output exists only in
7
+ * the live `command_output` frame — so the chamber's own copy is the only place
8
+ * it can be kept, and `mergeOmpAttachments` has to splice it back in.
9
+ *
10
+ * The anchor is the user turn that produced it, matched by CONTENT: the
11
+ * chamber's optimistic user row carries a `msg-…-user` id while the JSONL
12
+ * records omp's timestamp id, so an id match never fires.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import { mergeOmpAttachments, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
18
+
19
+ const CONTEXT_NOTICE = 'Context window: 1000000 tokens (1% used)';
20
+
21
+ /** What the JSONL yields for a turn whose command wrote no entry. */
22
+ function jsonlTurn(): StoredMessage[] {
23
+ return [
24
+ { id: 'omp-user-1', role: 'user', content: '/context' },
25
+ { id: 'omp-ai-1', role: 'ai', content: 'Done.' },
26
+ ];
27
+ }
28
+
29
+ /** The chamber's own copy of that same conversation, plus the notice row. */
30
+ function storedWithNotice(): string {
31
+ const stored: StoredMessage[] = [
32
+ { id: 'msg-1-user', role: 'user', content: '/context' },
33
+ { id: 'cmdout-1', role: 'ai', content: '', notice: CONTEXT_NOTICE },
34
+ { id: 'omp-ai-1', role: 'ai', content: 'Done.' },
35
+ ];
36
+ return JSON.stringify(stored);
37
+ }
38
+
39
+ describe('mergeOmpAttachments notice restoration', () => {
40
+ test('splices a stored notice back under its user turn', () => {
41
+ const merged = mergeOmpAttachments(jsonlTurn(), storedWithNotice());
42
+
43
+ expect(merged.map((m) => m.notice ?? m.content)).toEqual([
44
+ '/context',
45
+ CONTEXT_NOTICE,
46
+ 'Done.',
47
+ ]);
48
+ });
49
+
50
+ test('leaves a conversation with no stored notices untouched', () => {
51
+ const stored = JSON.stringify([{ id: 'msg-1-user', role: 'user', content: '/context' }]);
52
+ const merged = mergeOmpAttachments(jsonlTurn(), stored);
53
+ expect(merged.some((m) => m.notice !== undefined)).toBe(false);
54
+ expect(merged).toHaveLength(2);
55
+ });
56
+
57
+ test('does not duplicate a notice the JSONL itself carried', () => {
58
+ const messages: StoredMessage[] = [
59
+ { id: 'omp-user-1', role: 'user', content: 'run it' },
60
+ { id: 'omp-notice-1', role: 'ai', content: '', notice: CONTEXT_NOTICE },
61
+ ];
62
+ const merged = mergeOmpAttachments(messages, storedWithNotice());
63
+ expect(merged.filter((m) => m.notice === CONTEXT_NOTICE)).toHaveLength(1);
64
+ });
65
+
66
+ test('appends an unanchorable notice instead of dropping it', () => {
67
+ // The stored user turn has no counterpart in the JSONL at all (omp rewrote
68
+ // or pruned it), so there is nothing to anchor to — the row must still show.
69
+ const stored = JSON.stringify([
70
+ { id: 'msg-x', role: 'user', content: 'a turn the JSONL never recorded' },
71
+ { id: 'cmdout-x', role: 'ai', content: '', notice: 'Orphan notice' },
72
+ ]);
73
+ const merged = mergeOmpAttachments(jsonlTurn(), stored);
74
+ expect(merged.map((m) => m.notice).filter(Boolean)).toContain('Orphan notice');
75
+ });
76
+
77
+ test('tolerates malformed stored JSON', () => {
78
+ const merged = mergeOmpAttachments(jsonlTurn(), '{not json');
79
+ expect(merged).toHaveLength(2);
80
+ });
81
+ });
@@ -42,6 +42,14 @@ export interface StoredMessage {
42
42
  date?: string;
43
43
  timestamp?: string;
44
44
  attachments?: StoredAttachment[];
45
+ /**
46
+ * A notice row — omp's own reminder/task-result entries, or the chamber's
47
+ * rendering of a builtin command's output. The JSONL cannot round-trip
48
+ * command output at all: a `/context` or `/usage` turn writes no entry to the
49
+ * session file, so the notice exists ONLY in the live frame, and without this
50
+ * the row vanished on reload.
51
+ */
52
+ notice?: string;
45
53
  }
46
54
 
47
55
  function isRawComposerInput(content: string): boolean {
@@ -158,7 +166,7 @@ export function mergeOmpAttachments(
158
166
  byContent.set(m.content, m.attachments);
159
167
  }
160
168
  }
161
- return merged.map((m) => {
169
+ const merged2 = merged.map((m) => {
162
170
  if (m.role !== 'user' || m.attachments?.length) return m;
163
171
  // The JSONL content may carry inlined text-file blocks appended to the
164
172
  // original prompt, so match by prefix instead of exact equality.
@@ -168,4 +176,65 @@ export function mergeOmpAttachments(
168
176
  )?.[1];
169
177
  return atts ? { ...m, attachments: atts } : m;
170
178
  });
179
+ return spliceStoredNotices(merged2, stored);
180
+ }
181
+
182
+ /**
183
+ * Re-attach notice rows the chamber stored but the JSONL cannot carry.
184
+ *
185
+ * A builtin slash command writes NOTHING to the session file — omp answers it
186
+ * on the command path and the output lives only in the `command_output` frame —
187
+ * so a `/context` or `/usage` row disappeared on reload, leaving a timeline that
188
+ * had forgotten a command the user just ran. The chamber's own copy keeps them.
189
+ *
190
+ * Placement is by the USER TURN that produced them, never by id: the chamber's
191
+ * optimistic user row carries a `msg-…-user` id while the JSONL records omp's
192
+ * own timestamp id, so the two never match. The stored list is walked backwards
193
+ * to the nearest user message, and that message is located in the merged list
194
+ * with the same content relation the attachment merge uses (omp rewrites the
195
+ * delivered prompt, so equality alone would miss it). A notice whose anchor
196
+ * cannot be resolved is appended at the end rather than dropped — losing it
197
+ * again is the bug being fixed.
198
+ */
199
+ function spliceStoredNotices(merged: StoredMessage[], stored: StoredMessage[]): StoredMessage[] {
200
+ const storedNotices = stored
201
+ .map((message, index) => ({ message, index }))
202
+ .filter(({ message }) => message?.role !== 'user' && typeof message?.notice === 'string' && message.notice.trim());
203
+ if (storedNotices.length === 0) return merged;
204
+
205
+ // Already present (a notice the JSONL itself carried, e.g. a task result):
206
+ // matching on the text keeps the JSONL's own row authoritative.
207
+ const present = new Set(merged.map((m) => (typeof m.notice === 'string' ? m.notice : '')).filter(Boolean));
208
+
209
+ const mergedUsers = merged
210
+ .map((message, index) => ({ message, index }))
211
+ .filter(({ message }) => message.role === 'user');
212
+
213
+ const insertions = new Map<number, StoredMessage[]>();
214
+ for (const { message, index } of storedNotices) {
215
+ if (present.has(message.notice as string)) continue;
216
+ let anchor = merged.length;
217
+ for (let i = index - 1; i >= 0; i -= 1) {
218
+ const candidate = stored[i];
219
+ if (candidate?.role !== 'user' || typeof candidate.content !== 'string') continue;
220
+ const hit = mergedUsers.find(({ message: user }) =>
221
+ user.id !== undefined && candidate.id !== undefined && user.id === candidate.id,
222
+ ) ?? mergedUsers.find(({ message: user }) => userTurnsRelate(user.content, candidate.content));
223
+ if (hit) {
224
+ anchor = hit.index + 1;
225
+ break;
226
+ }
227
+ }
228
+ const group = insertions.get(anchor);
229
+ if (group) group.push(message);
230
+ else insertions.set(anchor, [message]);
231
+ }
232
+ if (insertions.size === 0) return merged;
233
+
234
+ const next = [...merged];
235
+ for (const anchor of [...insertions.keys()].sort((a, b) => b - a)) {
236
+ const group = insertions.get(anchor);
237
+ if (group) next.splice(anchor, 0, ...group);
238
+ }
239
+ return next;
171
240
  }
@@ -235,6 +235,32 @@ export async function loadSessionModel(filePath: string): Promise<{ provider: st
235
235
  * `thinking_level_change` entries (omp records the level string, e.g.
236
236
  * "off" | "minimal" | "low" | "medium" | "high" | "max"). */
237
237
  export async function loadSessionThinkingLevel(filePath: string): Promise<string | undefined> {
238
+ return readThinkingSelector(filePath, (record) => normalizeThinkingLevel(record.thinkingLevel));
239
+ }
240
+
241
+ /**
242
+ * The thinking SELECTOR a session is configured with — `auto` when automatic
243
+ * per-turn classification is on, else the concrete level.
244
+ *
245
+ * omp writes both fields on one entry (`thinkingLevel` = the provisional
246
+ * concrete effort, `configured` = the selector) and restores with
247
+ * `parseConfiguredThinkingLevel(configured) ?? parseThinkingLevel(level)`, so a
248
+ * reader that takes `thinkingLevel` alone turns an `auto` session into a pinned
249
+ * one. Anything spawning a child that must classify its turns the way the
250
+ * session does has to read this, not the concrete level.
251
+ */
252
+ export async function loadSessionThinkingSelector(filePath: string): Promise<string | undefined> {
253
+ return readThinkingSelector(filePath, (record) =>
254
+ typeof record.configured === 'string' && record.configured
255
+ ? record.configured
256
+ : normalizeThinkingLevel(record.thinkingLevel),
257
+ );
258
+ }
259
+
260
+ async function readThinkingSelector(
261
+ filePath: string,
262
+ read: (record: Record<string, unknown>) => string,
263
+ ): Promise<string | undefined> {
238
264
  try {
239
265
  const file = Bun.file(filePath);
240
266
  if ((await file.stat()).size > MAX_SESSION_LOAD_BYTES) return undefined;
@@ -247,8 +273,7 @@ export async function loadSessionThinkingLevel(filePath: string): Promise<string
247
273
  let last: string | undefined;
248
274
  for (const record of records) {
249
275
  if (record?.type !== 'thinking_level_change') continue;
250
- // A change entry always yields a display level: null/unset → "off".
251
- last = normalizeThinkingLevel(record.thinkingLevel);
276
+ last = read(record);
252
277
  }
253
278
  return last;
254
279
  } catch {
@@ -60,6 +60,19 @@ export async function loadOmpSidebarData(): Promise<OmpSidebarData> {
60
60
  return slot.inFlight;
61
61
  }
62
62
 
63
+ /**
64
+ * Drop the cached dataset so the next load re-scans disk. For a mutation the
65
+ * TTL cannot see: deleting a session removes a row rather than changing one, so
66
+ * nothing in the cached snapshot is stale — it is simply wrong, and the refresh
67
+ * that follows the delete would otherwise serve it back for up to the TTL.
68
+ */
69
+ export function invalidateOmpSidebarData(): void {
70
+ const slot = globalThis.__ompChamberSidebarDataCache;
71
+ if (!slot) return;
72
+ slot.data = undefined as unknown as OmpSidebarData;
73
+ slot.expiresAt = 0;
74
+ }
75
+
63
76
  /** Resolve each unique cwd to its project root; bounded concurrency so 100+
64
77
  * unique cwds don't spawn 100 parallel git processes. */
65
78
  async function resolveRootsByCwd(cwds: string[]): Promise<Map<string, string>> {
@@ -5,8 +5,9 @@ import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession, type Agen
5
5
  import { getSpawnApprovalMode, reconcileSpawnApprovalMode } from '@/server/lib/omp/rpc/session-registry';
6
6
  import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
7
7
  import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
8
- import { OBSERVER_ONLY_COMMANDS } from '@/server/lib/omp/rpc/constants';
8
+ import { CONVERSATION_MOVING_COMMANDS, OBSERVER_ONLY_COMMANDS } from '@/server/lib/omp/rpc/constants';
9
9
  import { rpcErrorResponse } from '@/server/lib/omp/rpc/errors';
10
+ import { assertBtwIdle } from '@/server/lib/btw/service.server';
10
11
 
11
12
  // POST /api/agent/:sessionId — send a command to an existing session (or spawn
12
13
  // it lazily). Mirrors omp-web's /api/agent/[id].
@@ -28,6 +29,13 @@ export async function sendCommand({ params, request }: ActionFunctionArgs) {
28
29
  // default. omp has no RPC to change the mode after spawn.
29
30
  const explicitMode = isApprovalMode(body.accessMode) ? body.accessMode : null;
30
31
 
32
+ // A command that moves the conversation is refused while a side question is
33
+ // in flight on this session — the question's snapshot describes the
34
+ // transcript this command would replace (omp blocks the same operations).
35
+ if (CONVERSATION_MOVING_COMMANDS.has(body.type)) {
36
+ await assertBtwIdle(sessionId, body.type.replace(/_/g, ' '));
37
+ }
38
+
31
39
  // Fast path: already-running session.
32
40
  const existing = getRpcSession(sessionId);
33
41
  if (existing?.isAlive()) {
@@ -14,25 +14,15 @@
14
14
 
15
15
  import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
16
16
  import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
17
- import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
18
17
  import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
19
18
  import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
20
- import {
21
- abortBtw,
22
- askBtw,
23
- getBtwState,
24
- promoteBtw,
25
- removeBtw,
26
- respondBtwDialog,
27
- setBtwApprovalMode,
28
- setBtwModel,
29
- setBtwThinkingLevel,
30
- } from '@/server/lib/btw/service.server';
19
+ import { abortBtw, askBtw, getBtwState, promoteBtw, removeBtw } from '@/server/lib/btw/service.server';
31
20
 
32
21
  /** Actions that can reject for a reason the client should show as-is. */
33
22
  const STATUS_BY_CODE: Record<string, number> = {
34
23
  btw_busy: 409,
35
24
  btw_stale: 409,
25
+ btw_multi_turn: 409,
36
26
  session_not_found: 404,
37
27
  btw_topic_not_found: 404,
38
28
  };
@@ -71,20 +61,6 @@ function readTextFiles(raw: unknown): AttachedTextFileData[] {
71
61
  });
72
62
  }
73
63
 
74
- /**
75
- * The response body for one dialog, in the shape omp's RPC accepts: an answer
76
- * value, a confirmation, or a cancellation. Anything else is refused rather
77
- * than forwarded — a malformed response would leave the child blocked.
78
- */
79
- function readDialogResponse(raw: unknown): Record<string, unknown> | null {
80
- if (!raw || typeof raw !== 'object') return null;
81
- const response = raw as { value?: unknown; confirmed?: unknown; cancelled?: unknown };
82
- if (response.cancelled === true) return { cancelled: true };
83
- if (typeof response.confirmed === 'boolean') return { confirmed: response.confirmed };
84
- if (typeof response.value === 'string') return { value: response.value };
85
- return null;
86
- }
87
-
88
64
  export async function action({ params, request }: ActionFunctionArgs) {
89
65
  const { sessionId } = params;
90
66
  if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
@@ -100,8 +76,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
100
76
  const question = typeof body?.question === 'string' ? body.question : '';
101
77
  const parsed = readImages(body?.images);
102
78
  if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
103
- const provider = typeof body?.provider === 'string' ? body.provider : '';
104
- const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
105
79
  return json({
106
80
  success: true,
107
81
  data: await askBtw(sessionId, {
@@ -109,9 +83,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
109
83
  question,
110
84
  images: parsed.images,
111
85
  textFiles: readTextFiles(body?.textFiles),
112
- ...(provider && modelId ? { model: { provider, id: modelId } } : {}),
113
- ...(typeof body?.thinkingLevel === 'string' ? { thinkingLevel: body.thinkingLevel } : {}),
114
- ...(isApprovalMode(body?.accessMode) ? { approvalMode: body.accessMode } : {}),
115
86
  }),
116
87
  });
117
88
  }
@@ -119,34 +90,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
119
90
  if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
120
91
  return json({ success: true, data: await abortBtw(sessionId, topicId) });
121
92
  }
122
- case 'set_model': {
123
- if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
124
- const provider = typeof body?.provider === 'string' ? body.provider : '';
125
- const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
126
- if (!provider || !modelId) return json({ error: 'provider and modelId are required', code: 'model_required' }, { status: 400 });
127
- return json({ success: true, data: await setBtwModel(sessionId, topicId, provider, modelId) });
128
- }
129
- case 'set_thinking_level': {
130
- if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
131
- const level = typeof body?.level === 'string' ? body.level : '';
132
- if (!level) return json({ error: 'level is required', code: 'level_required' }, { status: 400 });
133
- return json({ success: true, data: await setBtwThinkingLevel(sessionId, topicId, level) });
134
- }
135
- case 'set_access_mode': {
136
- if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
137
- if (!isApprovalMode(body?.accessMode)) {
138
- return json({ error: 'accessMode must be always-ask, write or yolo', code: 'invalid_access_mode' }, { status: 400 });
139
- }
140
- return json({ success: true, data: await setBtwApprovalMode(sessionId, topicId, body.accessMode) });
141
- }
142
- case 'dialog_response': {
143
- if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
144
- const id = typeof body?.id === 'string' ? body.id : '';
145
- if (!id) return json({ error: 'id is required', code: 'dialog_required' }, { status: 400 });
146
- const response = readDialogResponse(body?.response);
147
- if (!response) return json({ error: 'a response value is required', code: 'dialog_required' }, { status: 400 });
148
- return json({ success: true, data: await respondBtwDialog(sessionId, topicId, id, response) });
149
- }
150
93
  case 'promote': {
151
94
  if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
152
95
  return json({ success: true, data: await promoteBtw(sessionId, topicId) });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The delete route's refusal branches.
8
+ *
9
+ * Each one guards a different failure, and two of them are about blast radius
10
+ * rather than tidiness: the id names a directory under the sessions root and
11
+ * another under the btw root, so a value that is not one segment deletes far
12
+ * more than the session it names (`.` normalized to the btw root itself and
13
+ * wiped every session's side questions — see `fs/path-segment`).
14
+ *
15
+ * The route is called directly, as the other route tests do: these are pure
16
+ * request-shape checks that must not reach the filesystem or spawn an omp
17
+ * child, and asserting that is half the point.
18
+ */
19
+
20
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
21
+ import fs from 'fs';
22
+ import path from 'path';
23
+ import { deleteSession } from '@/server/routes/sessions/delete';
24
+
25
+ /**
26
+ * The 404 branch resolves the id by scanning the sessions directory, so the
27
+ * agent dir is pointed at an empty temp tree. Without this the test walks the
28
+ * developer's real `~/.omp/agent/sessions` (read-only, but seconds of I/O and a
29
+ * result that depends on what happens to be installed).
30
+ */
31
+ const AGENT_DIR = '/tmp/omc-delete-route-test/agent';
32
+
33
+ beforeAll(() => {
34
+ fs.rmSync(AGENT_DIR, { recursive: true, force: true });
35
+ fs.mkdirSync(path.join(AGENT_DIR, 'sessions'), { recursive: true });
36
+ Bun.env.PI_CODING_AGENT_DIR = AGENT_DIR;
37
+ });
38
+
39
+ afterAll(() => {
40
+ delete Bun.env.PI_CODING_AGENT_DIR;
41
+ fs.rmSync('/tmp/omc-delete-route-test', { recursive: true, force: true });
42
+ });
43
+
44
+ function del(sessionId: string): Promise<Response> {
45
+ return deleteSession({
46
+ request: new Request(`http://localhost/api/sessions/${sessionId}`, { method: 'DELETE' }),
47
+ params: { sessionId },
48
+ } as never) as Promise<Response>;
49
+ }
50
+
51
+ describe('deleteSession refusals', () => {
52
+ test('a pending chat is refused — nothing is stored for it yet', async () => {
53
+ const res = await del('new-1758800000000');
54
+ expect(res.status).toBe(400);
55
+ expect(await res.json()).toMatchObject({ code: 'session_pending' });
56
+ });
57
+
58
+ test('`.` is refused instead of wiping the btw root', async () => {
59
+ const res = await del('.');
60
+ expect(res.status).toBe(400);
61
+ expect(await res.json()).toMatchObject({ error: 'Invalid session id' });
62
+ });
63
+
64
+ test('a traversal is refused', async () => {
65
+ for (const id of ['..', '../x', 'a/b', '..\\a']) {
66
+ expect((await del(id)).status).toBe(400);
67
+ }
68
+ });
69
+
70
+ test('an unknown but well-formed id is a 404, not a 400', async () => {
71
+ // Distinct from the branches above: the request is valid, the session is
72
+ // not there. Collapsing the two would report a typo as a malformed request.
73
+ const res = await del('00000000-0000-0000-0000-000000000000');
74
+ expect(res.status).toBe(404);
75
+ });
76
+
77
+ test('a wrong verb on the path is a 405', async () => {
78
+ const res = (await deleteSession({
79
+ request: new Request('http://localhost/api/sessions/x', { method: 'GET' }),
80
+ params: { sessionId: 'x' },
81
+ } as never)) as Response;
82
+ expect(res.status).toBe(405);
83
+ });
84
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * DELETE /api/sessions/:sessionId — remove a session and everything keyed to
8
+ * it. The mirror of `archiveSession`: archive is reversible and keeps the
9
+ * transcript, this one is the destructive path the user opts into from the
10
+ * sidebar's trash button.
11
+ *
12
+ * Order matters and is enforced here, not by the callers:
13
+ * 1. refuse a session whose omp child is mid-turn (a run, a live subagent, or
14
+ * a dialog the agent is blocked on) — the delete would kill work the user
15
+ * can still see, and omp would flush the file back on shutdown anyway;
16
+ * 2. destroy the child and AWAIT its exit — omp writes session state on
17
+ * shutdown and would otherwise recreate the transcript just removed;
18
+ * 3. purge side questions, then the transcript, artifacts and chamber rows.
19
+ *
20
+ * A pending `new-…` session is refused: it is a client-side placeholder with
21
+ * nothing stored anywhere yet, so deleting it is the same as navigating away.
22
+ */
23
+
24
+ import { json } from '@/server/lib/remix-compat';
25
+ import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
26
+ import { methodNotAllowed } from '@/server/lib/route-adapter';
27
+ import { getRpcSession } from '@/server/lib/omp/rpc/session-registry';
28
+ import { purgeSessionData } from '@/server/lib/omp/session/delete.server';
29
+ import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
30
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
31
+ import { isMockMode } from '@/server/mock.server';
32
+
33
+ export async function deleteSession({ request, params }: ActionFunctionArgs) {
34
+ if (request.method !== 'DELETE') {
35
+ return methodNotAllowed({ request, params });
36
+ }
37
+
38
+ const sessionId = params.sessionId;
39
+ if (!sessionId) return json({ error: 'Missing session id' }, { status: 400 });
40
+ if (sessionId.startsWith('new-')) {
41
+ return json({ error: 'This chat has not been created yet.', code: 'session_pending' }, { status: 400 });
42
+ }
43
+ // The id names a directory under the sessions root and another under the btw
44
+ // root, so it must be exactly one path segment — see `isSinglePathSegment`
45
+ // for the `.` case that made this load-bearing. Both purges re-check it
46
+ // independently; refusing here is what turns the attempt into a 400.
47
+ if (!isSinglePathSegment(sessionId)) {
48
+ return json({ error: 'Invalid session id' }, { status: 400 });
49
+ }
50
+
51
+ // A live child is the only other writer of the transcript. Busy means a turn,
52
+ // a live subagent, or a dialog omp is parked on — all things the user can
53
+ // still act on, so the delete is refused with a reason rather than silently
54
+ // taking them down.
55
+ const live = getRpcSession(sessionId);
56
+ if (live?.isAlive()) {
57
+ if (live.isBusy()) {
58
+ return json(
59
+ { error: 'This session is working. Stop the run before deleting it.', code: 'session_busy' },
60
+ { status: 409 },
61
+ );
62
+ }
63
+ await live.destroyAndWait();
64
+ }
65
+
66
+ // Side questions live outside the session file and are removed first: their
67
+ // children must be gone before the parent transcript they were snapshotted
68
+ // from disappears.
69
+ const btwTopicsRemoved = isMockMode() ? 0 : await purgeBtwForSession(sessionId);
70
+ const purged = await purgeSessionData(sessionId);
71
+
72
+ if (!purged.found) {
73
+ return json({ error: 'Session not found' }, { status: 404 });
74
+ }
75
+
76
+ return json({
77
+ success: true,
78
+ sessionId,
79
+ fileRemoved: purged.fileRemoved,
80
+ artifactsRemoved: purged.artifactsRemoved,
81
+ btwTopicsRemoved,
82
+ });
83
+ }
@@ -10,10 +10,15 @@ import {
10
10
  renameSession,
11
11
  } from '@/server/routes/sessions/session';
12
12
  import { listSubagents, readSubagentTranscript } from '@/server/routes/sessions/subagents';
13
+ import { deleteSession } from '@/server/routes/sessions/delete';
13
14
 
14
15
  export const sessionsBindings: HandlerBinding[] = [
15
16
  ...bindingsFor(sessionsList, '/api/sessions/list'),
16
17
  ...bindingsFor(sessionsFolder, '/api/sessions/:sessionId'),
18
+ // DELETE-only on the same path the folder loader answers GET on: mounting it
19
+ // through `actionBindings` would register a 405 GET fallback that Elysia
20
+ // applies after the loader, replacing the workspace listing with it.
21
+ { method: 'DELETE', path: '/api/sessions/:sessionId', handler: deleteSession },
17
22
  ...actionBindings(archiveSession, '/api/sessions/:sessionId/archive'),
18
23
  // Per-item queue routes. `actionBindings` mounts ALL four mutating verbs on
19
24
  // one path, and Elysia lets a later registration override an earlier one —
@@ -15,7 +15,6 @@
15
15
 
16
16
  import type { AgentImage, BtwFrame, BtwState, StreamTransport } from '@/shared/types';
17
17
  import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
18
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
18
  import { connectEvents } from '@/shared/lib/chat/omp/sse';
20
19
  import { connectSocket } from '@/shared/lib/chat/omp/socket';
21
20
  import { btwEventsUrl, btwSocketUrl } from '@/shared/lib/chat/omp/transport';
@@ -74,11 +73,6 @@ export interface AskBtwInput {
74
73
  images?: AgentImage[];
75
74
  /** Text files inlined for this question (read at attach time by the composer). */
76
75
  textFiles?: AttachedTextFileData[];
77
- /** Composer picks for a FIRST question — a topic that does not exist yet has
78
- * no row to hold them, so they ride the ask and reach the spawn. */
79
- model?: { provider: string; id: string };
80
- thinkingLevel?: string;
81
- approvalMode?: ApprovalMode;
82
76
  }
83
77
 
84
78
  export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<BtwState> {
@@ -88,9 +82,6 @@ export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<B
88
82
  ...(input.topicId ? { topicId: input.topicId } : {}),
89
83
  ...(input.images?.length ? { images: input.images } : {}),
90
84
  ...(input.textFiles?.length ? { textFiles: input.textFiles } : {}),
91
- ...(input.model ? { provider: input.model.provider, modelId: input.model.id } : {}),
92
- ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}),
93
- ...(input.approvalMode ? { accessMode: input.approvalMode } : {}),
94
85
  });
95
86
  }
96
87
 
@@ -98,27 +89,6 @@ export function abortBtwQuestion(sessionId: string, topicId: string): Promise<Bt
98
89
  return requestBtw<BtwState>(sessionId, 'POST', { action: 'abort', topicId });
99
90
  }
100
91
 
101
- export function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
102
- return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_model', topicId, provider, modelId });
103
- }
104
-
105
- export function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
106
- return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_thinking_level', topicId, level });
107
- }
108
-
109
- export function setBtwAccessMode(sessionId: string, topicId: string, accessMode: ApprovalMode): Promise<BtwState> {
110
- return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_access_mode', topicId, accessMode });
111
- }
112
-
113
- export function respondBtwDialog(
114
- sessionId: string,
115
- topicId: string,
116
- id: string,
117
- response: { value: string } | { confirmed: boolean } | { cancelled: true },
118
- ): Promise<BtwState> {
119
- return requestBtw<BtwState>(sessionId, 'POST', { action: 'dialog_response', topicId, id, response });
120
- }
121
-
122
92
  export function promoteBtwTopic(sessionId: string, topicId: string): Promise<{ sessionId: string }> {
123
93
  return requestBtw<{ sessionId: string }>(sessionId, 'POST', { action: 'promote', topicId });
124
94
  }