ompchamber 3.3.2 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +275 -268
  3. package/dist/client/static/js/{index-e3axkbk1.js → index-h05z90mw.js} +581 -581
  4. package/package.json +1 -1
  5. package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
  6. package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
  7. package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
  8. package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
  9. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
  10. package/src/client/components/workspace/btw-panel/index.tsx +20 -27
  11. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
  12. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
  13. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
  14. package/src/client/hooks/chat/btw/index.ts +7 -73
  15. package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
  16. package/src/client/hooks/chat/btw/intercept.ts +5 -2
  17. package/src/client/hooks/chat/btw/mode.ts +16 -76
  18. package/src/client/hooks/chat/timeline/actions.ts +22 -30
  19. package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
  20. package/src/client/hooks/chat/timeline/send.ts +8 -1
  21. package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
  22. package/src/server/lib/assets/font-css.server.test.ts +87 -0
  23. package/src/server/lib/assets/font-css.server.ts +46 -20
  24. package/src/server/lib/assets/font-faces.ts +69 -0
  25. package/src/server/lib/assets/fonts.server.ts +27 -4
  26. package/src/server/lib/btw/child.server.test.ts +68 -0
  27. package/src/server/lib/btw/child.server.ts +17 -16
  28. package/src/server/lib/btw/frames.test.ts +3 -3
  29. package/src/server/lib/btw/frames.ts +1 -1
  30. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  31. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  32. package/src/server/lib/btw/prompt.ts +7 -11
  33. package/src/server/lib/btw/registry.server.ts +1 -9
  34. package/src/server/lib/btw/reply.ts +54 -0
  35. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  36. package/src/server/lib/btw/runtime.server.ts +77 -86
  37. package/src/server/lib/btw/schema.server.test.ts +84 -0
  38. package/src/server/lib/btw/schema.server.ts +8 -0
  39. package/src/server/lib/btw/service.server.ts +47 -78
  40. package/src/server/lib/btw/store.server.ts +9 -20
  41. package/src/server/lib/bundler/build-errors.server.ts +8 -3
  42. package/src/server/lib/bundler/css.ts +12 -61
  43. package/src/server/lib/omp/rpc/constants.ts +10 -0
  44. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  45. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  46. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  47. package/src/server/lib/omp/session/messages.ts +27 -2
  48. package/src/server/plugins/ssr.ts +4 -4
  49. package/src/server/plugins/static.ts +14 -10
  50. package/src/server/routes/agent/command.ts +9 -1
  51. package/src/server/routes/btw/topics.ts +2 -59
  52. package/src/shared/lib/chat/btw/client.ts +0 -30
  53. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  54. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  55. package/src/shared/lib/chat/notice-text.ts +33 -0
  56. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  57. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  58. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  59. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  60. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  61. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  62. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  63. package/src/shared/types/btw.ts +13 -34
@@ -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 = ?', [
@@ -33,9 +33,9 @@
33
33
  import { join, relative } from 'path';
34
34
  import type { BuildOutput, BunPlugin } from 'bun';
35
35
 
36
- import { packageRoot } from '@/server/lib/assets/fonts.server';
36
+ import { packageDir } from '@/server/lib/assets/fonts.server';
37
37
 
38
- /** The shell bundle's entrypoint, relative to the package root. */
38
+ /** The shell bundle's entrypoint, relative to the package directory. */
39
39
  const SHELL_ENTRYPOINT = 'index.html';
40
40
 
41
41
  /** Bun's own config file, and the section naming the bundle's plugins. */
@@ -109,8 +109,13 @@ function formatLog(log: BundleLog, root: string): string {
109
109
  /**
110
110
  * The bundle errors behind a shell that would not render, or null when this
111
111
  * build is not the reason (a clean bundle means the failure lies elsewhere).
112
+ *
113
+ * `root` is the PACKAGE, not `packageRoot()`: the entrypoint is `index.html`
114
+ * beside the source, and in a hoisted install the first root holding
115
+ * `node_modules` is the parent, where `index.html` does not exist — so the probe
116
+ * would report no failure at all and the real build error would never be shown.
112
117
  */
113
- export async function describeShellBuildFailure(root = packageRoot()): Promise<string | null> {
118
+ export async function describeShellBuildFailure(root = packageDir()): Promise<string | null> {
114
119
  const entrypoint = join(root, SHELL_ENTRYPOINT);
115
120
  if (!(await Bun.file(entrypoint).exists())) return null;
116
121
 
@@ -30,18 +30,17 @@
30
30
  * rewrite inside the bundle can work.
31
31
  *
32
32
  * The faces therefore leave the bundle. Each block is rewritten to
33
- * `<FONT_ROUTE_PREFIX><basename>` and written to its own file under
34
- * `FONT_FACE_DIR`, keyed by the stylesheet it came from — a document Bun
35
- * never parses, which is what lets the urls survive. The server concatenates
36
- * that directory into `/fonts.css` (`lib/assets/font-css.server.ts`) and the
37
- * shell links it, so the layout rules stay bundled while the faces stay
33
+ * `<FONT_ROUTE_PREFIX><basename>` and dropped from the module, and the server
34
+ * re-declares them on `/fonts.css` from the SAME source stylesheets
35
+ * (`lib/assets/font-css.server.ts`, `lib/assets/font-faces.ts`). The shell
36
+ * links that route, so the layout rules stay bundled while the faces stay
38
37
  * cacheable files.
39
38
  *
40
- * Per-source files rather than one accumulated file because `onEnd` is NOT
41
- * invoked by `serve.static` (verified: `onStart` and `onLoad` fire, `onEnd`
42
- * never does), so there is no "build finished" moment to flush at. Writing
43
- * each source's faces as its `onLoad` returns is order-independent and works
44
- * identically under both entry points.
39
+ * The server derives the faces from source rather than from a directory this
40
+ * plugin wrote, because `onEnd` is NOT invoked by `serve.static` (verified:
41
+ * `onStart` and `onLoad` fire, `onEnd` never does) and a written artifact is
42
+ * produced exactly once per process — a `rm -rf` after boot, or a fresh clone
43
+ * that never built, would leave `/fonts.css` empty for good.
45
44
  *
46
45
  * 3. **Nested at-rules are flattened** (`flattenNestedAtRules`, registered
47
46
  * AFTER Tailwind so it sees the expanded tree). Bun's CSS minifier keeps the
@@ -53,57 +52,14 @@
53
52
  * the measurement; this one only has to run it.
54
53
  */
55
54
 
56
- import { basename, dirname, isAbsolute, join, resolve as resolvePath } from 'path';
55
+ import { dirname } from 'path';
57
56
  import postcss from 'postcss';
58
57
  import tailwind from '@tailwindcss/postcss';
59
58
  import type { BunPlugin } from 'bun';
60
59
 
61
- import { FONT_FACE_DIR } from '@/server/lib/assets/font-css.server';
62
- import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
60
+ import { splitFontFaces } from '@/server/lib/assets/font-faces';
63
61
  import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
64
62
 
65
- /** A `url(...)` whose target is a font file. */
66
- const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
67
- const FONT_FILE = /\.(?:woff2?|ttf|otf)$/i;
68
- /** Schemes Bun leaves alone, and the prefix this plugin itself emits. */
69
- const EXTERNAL = /^(?:data:|https?:|blob:|\/\/)/i;
70
-
71
- /** One `@font-face` block, braces included. Faces cannot nest. */
72
- const FONT_FACE = /@font-face\s*\{[^{}]*\}/g;
73
-
74
- /** Absolute path of a local font url, or null when it is not one. */
75
- function localFont(specifier: string, cssDir: string): string | null {
76
- if (EXTERNAL.test(specifier) || specifier.startsWith(FONT_ROUTE_PREFIX)) return null;
77
- if (!FONT_FILE.test(specifier)) return null;
78
- return isAbsolute(specifier) ? specifier : resolvePath(cssDir, specifier);
79
- }
80
-
81
- /** Point every font `url()` at the served path. */
82
- function rewriteFontUrls(css: string, cssDir: string): string {
83
- return css.replace(URL_FUNCTION, (whole, quote: string, specifier: string) => {
84
- const absolute = localFont(specifier, cssDir);
85
- if (!absolute) return whole;
86
- return `url(${quote}${FONT_ROUTE_PREFIX}${basename(absolute)}${quote})`;
87
- });
88
- }
89
-
90
- /**
91
- * Split `@font-face` blocks out of a stylesheet.
92
- *
93
- * Only blocks whose `src` names a local font move: a face pointing at a remote
94
- * url is valid CSS the bundle can keep.
95
- */
96
- function splitFontFaces(css: string, cssDir: string): { rest: string; faces: string[] } {
97
- const faces: string[] = [];
98
- const rest = css.replace(FONT_FACE, (block) => {
99
- const hasLocal = [...block.matchAll(URL_FUNCTION)].some(([, , spec]) => localFont(spec, cssDir));
100
- if (!hasLocal) return block;
101
- faces.push(rewriteFontUrls(block, cssDir));
102
- return '';
103
- });
104
- return { rest, faces };
105
- }
106
-
107
63
  const plugin: BunPlugin = {
108
64
  name: 'ompchamber-css',
109
65
  setup(build) {
@@ -117,12 +73,7 @@ const plugin: BunPlugin = {
117
73
  const expanded = await postcss([tailwind(), flattenNestedAtRules()]).process(source, {
118
74
  from: args.path,
119
75
  });
120
- const { rest, faces } = splitFontFaces(expanded.css, dirname(args.path));
121
- if (faces.length > 0) {
122
- const target = join(packageRoot(), FONT_FACE_DIR, `${basename(args.path)}`);
123
- const header = `/* GENERATED from ${args.path} — do not edit. */\n`;
124
- await Bun.write(target, header + faces.join('\n') + '\n');
125
- }
76
+ const { rest } = splitFontFaces(expanded.css, dirname(args.path));
126
77
  return { contents: rest, loader: 'css' };
127
78
  });
128
79
  },
@@ -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,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
  }