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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +315 -308
- package/dist/client/static/js/index-vx0ernrv.css +1 -0
- package/dist/client/static/js/{index-8ejde0b7.js → index-w0cem7n5.js} +641 -641
- package/package.json +1 -1
- package/src/client/components/common/session-actions-menu/index.tsx +201 -0
- package/src/client/components/common/session-delete-modal/index.tsx +128 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
- package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
- package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
- package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
- package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
- package/src/client/components/workspace/btw-panel/index.tsx +20 -27
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
- package/src/client/hooks/chat/btw/index.ts +7 -73
- package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
- package/src/client/hooks/chat/btw/intercept.ts +5 -2
- package/src/client/hooks/chat/btw/mode.ts +16 -76
- package/src/client/hooks/chat/timeline/actions.ts +22 -30
- package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
- package/src/client/hooks/chat/timeline/send.ts +8 -1
- package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
- package/src/client/hooks/workspace/session-delete.ts +94 -0
- package/src/server/lib/btw/child.server.test.ts +68 -0
- package/src/server/lib/btw/child.server.ts +17 -16
- package/src/server/lib/btw/frames.test.ts +3 -3
- package/src/server/lib/btw/frames.ts +1 -1
- package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
- package/src/server/lib/btw/lifecycle.server.ts +41 -31
- package/src/server/lib/btw/prompt.ts +7 -11
- package/src/server/lib/btw/purge.server.ts +56 -0
- package/src/server/lib/btw/registry.server.ts +1 -9
- package/src/server/lib/btw/reply.ts +54 -0
- package/src/server/lib/btw/runtime.server.test.ts +128 -0
- package/src/server/lib/btw/runtime.server.ts +77 -86
- package/src/server/lib/btw/schema.server.test.ts +84 -0
- package/src/server/lib/btw/schema.server.ts +8 -0
- package/src/server/lib/btw/service.server.ts +47 -78
- package/src/server/lib/btw/store.server.ts +9 -20
- package/src/server/lib/fs/path-segment.test.ts +100 -0
- package/src/server/lib/fs/path-segment.ts +45 -0
- package/src/server/lib/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/session-commands.ts +45 -0
- package/src/server/lib/omp/session/delete.server.ts +136 -0
- package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
- package/src/server/lib/omp/session/merge-stored.ts +70 -1
- package/src/server/lib/omp/session/messages.ts +27 -2
- package/src/server/lib/omp/session/reader.ts +13 -0
- package/src/server/routes/agent/command.ts +9 -1
- package/src/server/routes/btw/topics.ts +2 -59
- package/src/server/routes/sessions/delete.test.ts +84 -0
- package/src/server/routes/sessions/delete.ts +83 -0
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/shared/lib/chat/btw/client.ts +0 -30
- package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
- package/src/shared/lib/chat/composer/tui-only.ts +138 -0
- package/src/shared/lib/chat/notice-text.ts +33 -0
- package/src/shared/lib/chat/omp/agent-events.ts +18 -37
- package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
- package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
- package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
- package/src/shared/lib/chat/timeline/command-output.ts +65 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
- package/src/shared/lib/omp/session/messages-map.ts +6 -1
- package/src/shared/lib/workspace/session-state/store.ts +20 -0
- package/src/shared/types/btw.ts +13 -34
- 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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
129
|
-
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
|
|
139
|
-
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
|
-
|
|
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:
|
|
159
|
-
leaf_id:
|
|
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
|
+
}
|