ompchamber 3.4.0 → 3.6.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 (87) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +387 -383
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-h05z90mw.js → index-qp2sfmn2.js} +614 -615
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/common/session-actions-menu/index.tsx +201 -0
  8. package/src/client/components/common/session-delete-modal/index.tsx +128 -0
  9. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
  10. package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
  11. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  12. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  13. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
  14. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
  15. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  16. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  17. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  18. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  19. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  20. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  21. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  22. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  23. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  24. package/src/client/data/settings/command.ts +0 -40
  25. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  26. package/src/client/hooks/ui/updates/index.ts +178 -0
  27. package/src/client/hooks/workspace/session-delete.ts +94 -0
  28. package/src/server/db.server.ts +41 -11
  29. package/src/server/index.ts +17 -1
  30. package/src/server/lib/btw/purge.server.ts +56 -0
  31. package/src/server/lib/db/client.ts +17 -0
  32. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  33. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  34. package/src/server/lib/fs/path-segment.test.ts +100 -0
  35. package/src/server/lib/fs/path-segment.ts +45 -0
  36. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  37. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  38. package/src/server/lib/omp/config/commands.test.ts +167 -0
  39. package/src/server/lib/omp/config/commands.ts +195 -0
  40. package/src/server/lib/omp/config/scope.test.ts +49 -0
  41. package/src/server/lib/omp/config/scope.ts +65 -0
  42. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  43. package/src/server/lib/omp/config/skills.test.ts +105 -0
  44. package/src/server/lib/omp/config/skills.ts +260 -47
  45. package/src/server/lib/omp/rpc/process.ts +22 -14
  46. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  47. package/src/server/lib/omp/rpc/utility.ts +45 -23
  48. package/src/server/lib/omp/session/delete.server.ts +136 -0
  49. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  50. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  51. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  52. package/src/server/lib/omp/session/reader.ts +13 -0
  53. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  54. package/src/server/lib/omp/session/reload.server.ts +47 -0
  55. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  56. package/src/server/lib/terminal/liveness.server.ts +65 -0
  57. package/src/server/lib/terminal/runtime.server.ts +70 -117
  58. package/src/server/lib/terminal/session.server.ts +5 -2
  59. package/src/server/lib/terminal/store.ts +72 -0
  60. package/src/server/lib/updates/apply.ts +62 -33
  61. package/src/server/lib/updates/install.ts +9 -2
  62. package/src/server/lib/updates/omp.ts +37 -9
  63. package/src/server/lib/updates/single-flight.test.ts +61 -0
  64. package/src/server/lib/updates/single-flight.ts +68 -0
  65. package/src/server/routes/chat/session.ts +13 -3
  66. package/src/server/routes/health/probe.ts +8 -0
  67. package/src/server/routes/omp/index.ts +0 -2
  68. package/src/server/routes/sessions/delete.test.ts +84 -0
  69. package/src/server/routes/sessions/delete.ts +83 -0
  70. package/src/server/routes/sessions/index.ts +5 -0
  71. package/src/server/routes/settings/commands.ts +102 -15
  72. package/src/server/routes/settings/skills.ts +165 -113
  73. package/src/server/routes/terminal/sessions.ts +1 -1
  74. package/src/server/routes/updates/apply.ts +101 -14
  75. package/src/shared/lib/chat/composer/client.ts +27 -18
  76. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  77. package/src/shared/lib/markdown/katex.ts +11 -1
  78. package/src/shared/lib/markdown/marked.ts +68 -13
  79. package/src/shared/lib/markdown/math.test.ts +53 -1
  80. package/src/shared/lib/updates/progress.test.ts +54 -0
  81. package/src/shared/lib/updates/progress.ts +50 -0
  82. package/src/shared/lib/workspace/session-state/store.ts +20 -0
  83. package/src/shared/types/settings/command.ts +15 -2
  84. package/src/shared/types/settings/skill.ts +13 -0
  85. package/dist/client/static/js/index-bsxxt89j.css +0 -1
  86. package/src/client/hooks/ui/updates.ts +0 -108
  87. package/src/server/routes/omp/commands.ts +0 -29
@@ -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
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * Where an unmatched raw turn lands.
6
+ *
7
+ * omp answers a builtin on the command path and writes NO session entry for it,
8
+ * so `/usage` or `/compact` exists only in the chamber's own copy — no JSONL
9
+ * user turn relates to it, and it cannot be matched into place. It was
10
+ * positioned by scanning the stored array forward for the next row the JSONL
11
+ * carried, which only worked because the overlay also held the mirrored rows —
12
+ * the very rows `overlayRowsForOmpSession` drops. So this is the regression the
13
+ * narrowing introduced: those turns fell to the END of the timeline, after
14
+ * answers that came later. The clock fixes it, and the two tests below are the
15
+ * behaviour and the equivalence.
16
+ */
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+
20
+ import { mergeOmpAttachments, overlayRowsForOmpSession, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
21
+
22
+ /** Arbitrary epoch ms, one second per step, so the order is exact. */
23
+ const at = (second: number) => 1_790_000_000_000 + second * 1000;
24
+
25
+ /** The conversation omp recorded. `/usage` is absent: a builtin writes nothing. */
26
+ function jsonl(): StoredMessage[] {
27
+ return [
28
+ { id: 'omp-u1', role: 'user', content: 'first question', startedAt: at(0) },
29
+ { id: 'omp-a1', role: 'ai', content: 'first answer', startedAt: at(1) },
30
+ { id: 'omp-u2', role: 'user', content: 'second question', startedAt: at(30) },
31
+ { id: 'omp-a2', role: 'ai', content: 'second answer', startedAt: at(31) },
32
+ ];
33
+ }
34
+
35
+ /** The chamber's copy of the same timeline, built in the order it was lived:
36
+ * mirrors and local rows interleaved by time. */
37
+ function storedFull(): StoredMessage[] {
38
+ return [
39
+ { id: 'msg-1-user', role: 'user', content: 'first question', startedAt: at(0) },
40
+ { id: 'omp-a1', role: 'ai', content: 'first answer', startedAt: at(1) },
41
+ { id: 'msg-2-user', role: 'user', content: '/usage', startedAt: at(2) },
42
+ { id: 'cmdout-1', role: 'ai', content: '', notice: '/usage output', startedAt: at(3) },
43
+ { id: 'msg-3-user', role: 'user', content: 'second question', startedAt: at(30) },
44
+ { id: 'omp-a2', role: 'ai', content: 'second answer', startedAt: at(31) },
45
+ ];
46
+ }
47
+
48
+ const order = (messages: StoredMessage[]) => messages.map((m) => m.notice ?? m.content);
49
+
50
+ describe('unmatched raw turn placement', () => {
51
+ test('lands where the user sent it, not after the turns that followed', () => {
52
+ const merged = mergeOmpAttachments(jsonl(), JSON.stringify(overlayRowsForOmpSession(storedFull())));
53
+
54
+ expect(order(merged)).toEqual([
55
+ 'first question',
56
+ 'first answer',
57
+ '/usage',
58
+ '/usage output',
59
+ 'second question',
60
+ 'second answer',
61
+ ]);
62
+ });
63
+
64
+ test('narrowing the overlay does not move it', () => {
65
+ const full = mergeOmpAttachments(jsonl(), JSON.stringify(storedFull()));
66
+ const narrowed = mergeOmpAttachments(
67
+ jsonl(),
68
+ JSON.stringify(overlayRowsForOmpSession(storedFull())),
69
+ );
70
+
71
+ expect(narrowed).toEqual(full);
72
+ });
73
+
74
+ test('a turn with no clock to read still renders rather than being dropped', () => {
75
+ const clockless: StoredMessage[] = [
76
+ { id: 'msg-1-user', role: 'user', content: 'first question', startedAt: at(0) },
77
+ { id: 'msg-2-user', role: 'user', content: '/usage' },
78
+ ];
79
+
80
+ const merged = mergeOmpAttachments(jsonl(), JSON.stringify(clockless));
81
+
82
+ expect(order(merged)).toContain('/usage');
83
+ expect(merged.some((m) => m.startedAt === at(0))).toBe(true);
84
+ });
85
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * The overlay narrowing. For a session omp owns on disk the transcript lives in
6
+ * the JSONL, so the chamber's copy only has to carry what omp cannot record
7
+ * there — the user turns (raw composer text, attachment metadata) and the notice
8
+ * rows. Everything else was a mirror written on every `message_end`.
9
+ *
10
+ * The load-bearing property is the EQUIVALENCE below: narrowing the stored copy
11
+ * must not change a single rendered row. `mergeOmpAttachments` is the only
12
+ * reader, and it reads user rows and notices only — if that ever stops being
13
+ * true, this test fails rather than the timeline silently losing turns.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+
18
+ import { mergeOmpAttachments, overlayRowsForOmpSession, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
19
+
20
+ const NOTICE = 'Context window: 1000000 tokens (1% used)';
21
+
22
+ /** The conversation as omp wrote it: the `@agent` turn was delivered as a task
23
+ * delegation prompt, and the builtin command wrote no entry at all. */
24
+ function jsonl(): StoredMessage[] {
25
+ return [
26
+ { id: 'omp-u1', role: 'user', content: 'hello' },
27
+ { id: 'omp-a1', role: 'ai', content: 'hi there' },
28
+ { id: 'omp-u2', role: 'user', content: 'Use the task tool to delegate this request: @agent' },
29
+ { id: 'omp-a2', role: 'ai', content: 'delegated' },
30
+ ];
31
+ }
32
+
33
+ /** The same conversation as the chamber mirrored it: its own id on the turns the
34
+ * user typed, the attachment metadata the JSONL cannot carry, and the command's
35
+ * notice row — which is the only place its output exists at all. */
36
+ function storedFull(): StoredMessage[] {
37
+ return [
38
+ { id: 'msg-1-user', role: 'user', content: 'hello' },
39
+ { id: 'omp-a1', role: 'ai', content: 'hi there' },
40
+ {
41
+ id: 'msg-2-user',
42
+ role: 'user',
43
+ content: '@agent',
44
+ attachments: [{ name: 'notes.md', content: '# notes' }],
45
+ },
46
+ { id: 'omp-a2', role: 'ai', content: 'delegated' },
47
+ { id: 'cmdout-1', role: 'ai', content: '', notice: NOTICE },
48
+ ];
49
+ }
50
+
51
+ describe('overlayRowsForOmpSession', () => {
52
+ test('keeps the user turns and the notice rows, in their original order', () => {
53
+ const kept = overlayRowsForOmpSession(storedFull());
54
+
55
+ expect(kept.map((m) => m.id)).toEqual(['msg-1-user', 'msg-2-user', 'cmdout-1']);
56
+ });
57
+
58
+ test('keeps the attachment metadata the JSONL cannot carry', () => {
59
+ const kept = overlayRowsForOmpSession(storedFull());
60
+
61
+ expect(kept[1].attachments).toEqual([{ name: 'notes.md', content: '# notes' }]);
62
+ });
63
+
64
+ test('drops every role the merge never reads, including assistant rows', () => {
65
+ const rows: StoredMessage[] = [
66
+ { id: 'u', role: 'user', content: 'ask' },
67
+ { id: 'a', role: 'ai', content: 'answer' },
68
+ { id: 'as', role: 'assistant', content: 'answer' },
69
+ { id: 's', role: 'system', content: 'You are helpful.' },
70
+ ];
71
+
72
+ expect(overlayRowsForOmpSession(rows).map((m) => m.id)).toEqual(['u']);
73
+ });
74
+
75
+ test('drops a notice with no text and entries that are not objects', () => {
76
+ const rows = [
77
+ null,
78
+ 'a bare string',
79
+ 42,
80
+ { id: 'blank', role: 'ai', content: '', notice: ' \n ' },
81
+ { id: 'kept', role: 'ai', content: '', notice: NOTICE },
82
+ ];
83
+
84
+ expect(overlayRowsForOmpSession(rows as StoredMessage[]).map((m) => m.id)).toEqual(['kept']);
85
+ });
86
+
87
+ test('narrows the stored copy without changing a single rendered row', () => {
88
+ const full = mergeOmpAttachments(jsonl(), JSON.stringify(storedFull()));
89
+ const narrowed = mergeOmpAttachments(
90
+ jsonl(),
91
+ JSON.stringify(overlayRowsForOmpSession(storedFull())),
92
+ );
93
+
94
+ expect(narrowed).toEqual(full);
95
+ // The rows only the overlay could supply are still there: the turn as the
96
+ // user typed it (`@agent`, not the delegation prompt omp delivered) and the
97
+ // command notice, which the session file never recorded.
98
+ expect(narrowed.map((m) => m.notice ?? m.content)).toEqual([
99
+ 'hello',
100
+ 'hi there',
101
+ '@agent',
102
+ NOTICE,
103
+ 'delegated',
104
+ ]);
105
+ });
106
+ });
@@ -41,6 +41,14 @@ export interface StoredMessage {
41
41
  content: string;
42
42
  date?: string;
43
43
  timestamp?: string;
44
+ /**
45
+ * When omp recorded the entry, in epoch ms — `toChatMessage` stamps every
46
+ * JSONL-derived row with it, and the chamber's own rows carry the clock they
47
+ * were created at. It is what positions a raw turn that no JSONL turn relates
48
+ * to (see `anchorIndexFor`); `date` cannot serve, because a chamber row's is a
49
+ * display string ("Today, 03:57 PM") rather than an ISO instant.
50
+ */
51
+ startedAt?: number;
44
52
  attachments?: StoredAttachment[];
45
53
  /**
46
54
  * A notice row — omp's own reminder/task-result entries, or the chamber's
@@ -52,6 +60,39 @@ export interface StoredMessage {
52
60
  notice?: string;
53
61
  }
54
62
 
63
+ /**
64
+ * The only rows a chamber overlay needs for a session whose transcript omp owns
65
+ * on disk: the user turns (the raw composer text and the attachment metadata the
66
+ * JSONL cannot record) and the notice rows (builtin command output, which omp
67
+ * writes nowhere).
68
+ *
69
+ * Every other row is a mirror of a JSONL entry: this module never reads one, and
70
+ * rewriting the transcript on every `message_end` is what grew the overlay to
71
+ * the size of the conversation it copies — measured at 165.6 MB of 167.9 MB of
72
+ * `ai` rows that nothing consumes. A session with NO session file is a different
73
+ * case and must stay whole — there the DB is the only copy — so callers gate on
74
+ * the file, never on the id's shape.
75
+ *
76
+ * The merge depends on no mirror to place a turn either: a stored raw turn that
77
+ * no JSONL user turn relates to is positioned by its own clock
78
+ * (`anchorIndexFor`), not by scanning the stored array for a row the JSONL still
79
+ * carries — which is what makes this narrowing self-sufficient rather than a
80
+ * change that quietly moves a `/usage` or `/compact` bubble to the end of the
81
+ * timeline. Verified against this install's own files: of 260 stored sessions
82
+ * compared against their JSONLs, only the turns whose anchor the scan had been
83
+ * supplying change position, and they change to the time they were sent.
84
+ */
85
+ export function overlayRowsForOmpSession<T>(messages: T[]): T[] {
86
+ return messages.filter((message) => {
87
+ if (typeof message !== 'object' || message === null) return false;
88
+ const record = message as { role?: unknown; notice?: unknown };
89
+ if (record.role === 'user') return true;
90
+ // Whitespace-only notices are dropped by the merge anyway; keeping one would
91
+ // only carry bytes no renderer ever shows.
92
+ return typeof record.notice === 'string' && record.notice.trim().length > 0;
93
+ });
94
+ }
95
+
55
96
  function isRawComposerInput(content: string): boolean {
56
97
  return /^\s*\//.test(content) || /(^|\s)@[A-Za-z0-9_-]+/.test(content);
57
98
  }
@@ -100,6 +141,40 @@ function mergeAttachmentLists(
100
141
  return [...merged, ...remaining];
101
142
  }
102
143
 
144
+ /**
145
+ * Where an unmatched raw turn belongs in the merged timeline.
146
+ *
147
+ * Its own clock decides: the turn was typed before the next row omp recorded
148
+ * after it, so it is inserted ahead of the first merged row that carries a later
149
+ * `startedAt`. This replaced a scan for "the next stored row the JSONL still
150
+ * carries" — that scan worked only while the overlay also held the mirrored
151
+ * rows, which are exactly the rows `overlayRowsForOmpSession` drops, so it sent
152
+ * every such turn to the tail of the timeline of a narrowed session (measured:
153
+ * a `/usage` or `/compact` turn, 7 of them in one install).
154
+ *
155
+ * The scan is kept for a row with no clock to read — a legacy stored turn —
156
+ * where it still resolves against an un-narrowed overlay, and `merged.length`
157
+ * (append at the end) is the last resort rather than a silent drop.
158
+ */
159
+ function anchorIndexFor(
160
+ raw: StoredMessage,
161
+ merged: StoredMessage[],
162
+ jsonlIndex: Map<string, number>,
163
+ stored: StoredMessage[],
164
+ ): number {
165
+ const at = raw.startedAt;
166
+ if (typeof at === 'number' && Number.isFinite(at)) {
167
+ const after = merged.findIndex((m) => typeof m.startedAt === 'number' && m.startedAt > at);
168
+ if (after !== -1) return after;
169
+ }
170
+ for (let i = stored.indexOf(raw) + 1; i < stored.length; i += 1) {
171
+ const id = stored[i]?.id;
172
+ const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
173
+ if (found !== undefined) return found;
174
+ }
175
+ return merged.length;
176
+ }
177
+
103
178
  /** Merge user turns and attachments from the chamber DB copy onto the
104
179
  * JSONL-loaded messages. */
105
180
  export function mergeOmpAttachments(
@@ -136,21 +211,13 @@ export function mergeOmpAttachments(
136
211
  return attachments.length ? { ...next, attachments } : next;
137
212
  });
138
213
 
139
- // Unmatched raw turns are anchored before the next stored message the JSONL
140
- // still carries; splice highest-anchor-first so earlier indices stay valid.
214
+ // Unmatched raw turns are positioned by their own clock, and spliced
215
+ // highest-anchor-first so earlier indices stay valid.
141
216
  const insertions = new Map<number, StoredMessage[]>();
142
217
  storedUsers.forEach((storedMsg, userIndex) => {
143
218
  if (used.has(userIndex) || !isRawComposerInput(storedMsg.content)) return;
144
219
  if (storedMsg.id && jsonlIndex.has(storedMsg.id)) return;
145
- let anchor = merged.length;
146
- for (let i = stored.indexOf(storedMsg) + 1; i < stored.length; i += 1) {
147
- const id = stored[i]?.id;
148
- const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
149
- if (found !== undefined) {
150
- anchor = found;
151
- break;
152
- }
153
- }
220
+ const anchor = anchorIndexFor(storedMsg, merged, jsonlIndex, stored);
154
221
  const group = insertions.get(anchor);
155
222
  if (group) group.push(storedMsg);
156
223
  else insertions.set(anchor, [storedMsg]);
@@ -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>> {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * omp discovers plugins once per process, so a skill written while a session
6
+ * runs is invisible to it until a reload. `/reload-plugins` is the refresh, and
7
+ * it is only safe to send to an IDLE session: it answers `agentInvoked: false`
8
+ * (no model turn, nothing written to the transcript), but a prompt dispatched
9
+ * into a RUNNING turn is a queue insert with user-visible consequences. The
10
+ * busy guard is therefore an invariant, not an optimization — a broadcast that
11
+ * ignored it would inject a command into every in-flight turn.
12
+ */
13
+
14
+ import { afterAll, describe, expect, mock, test } from 'bun:test';
15
+
16
+ interface FakeSession {
17
+ sessionId: string;
18
+ isBusy: () => boolean;
19
+ send: (command: unknown) => Promise<unknown>;
20
+ }
21
+
22
+ const sent: Array<{ sessionId: string; command: unknown }> = [];
23
+ let sessions: FakeSession[] = [];
24
+ let failFor: string | null = null;
25
+
26
+ mock.module('@/server/lib/omp/rpc/session-registry', () => ({
27
+ listRpcSessions: () => sessions,
28
+ }));
29
+
30
+ // Static import cannot work here: `mock.module` only intercepts modules
31
+ // registered BEFORE the importer is evaluated, so the module under test has to
32
+ // load after the mock is in place.
33
+ const { reloadLiveSessions } = await import('@/server/lib/omp/session/reload.server');
34
+
35
+ function session(sessionId: string, busy: boolean): FakeSession {
36
+ return {
37
+ sessionId,
38
+ isBusy: () => busy,
39
+ async send(command: unknown) {
40
+ if (failFor === sessionId) throw new Error('session gone');
41
+ sent.push({ sessionId, command });
42
+ return { agentInvoked: false };
43
+ },
44
+ };
45
+ }
46
+
47
+ describe('reloadLiveSessions', () => {
48
+ test('sends /reload-plugins to idle sessions only', async () => {
49
+ sent.length = 0;
50
+ failFor = null;
51
+ sessions = [session('idle-1', false), session('busy-1', true), session('idle-2', false)];
52
+
53
+ const reloaded = await reloadLiveSessions();
54
+
55
+ expect(reloaded.sort()).toEqual(['idle-1', 'idle-2']);
56
+ expect(sent.map((entry) => entry.sessionId).sort()).toEqual(['idle-1', 'idle-2']);
57
+ // The prompt form is the point: no RPC command exists for a reload.
58
+ expect(sent.every((entry) => JSON.stringify(entry.command) === JSON.stringify({ type: 'prompt', message: '/reload-plugins' }))).toBe(true);
59
+ });
60
+
61
+ test('a failing session does not stop the others', async () => {
62
+ sent.length = 0;
63
+ failFor = 'idle-1';
64
+ sessions = [session('idle-1', false), session('idle-2', false)];
65
+
66
+ const reloaded = await reloadLiveSessions();
67
+
68
+ expect(reloaded).toEqual(['idle-2']);
69
+ expect(sent.map((entry) => entry.sessionId)).toEqual(['idle-2']);
70
+ });
71
+
72
+ test('no live sessions is a no-op', async () => {
73
+ sent.length = 0;
74
+ failFor = null;
75
+ sessions = [];
76
+
77
+ expect(await reloadLiveSessions()).toEqual([]);
78
+ expect(sent.length).toBe(0);
79
+ });
80
+ });
81
+
82
+ afterAll(() => {
83
+ mock.restore();
84
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Refresh the plugin/skill inventory of the LIVE omp children.
8
+ *
9
+ * omp discovers skills, commands and plugins once per process and does not
10
+ * watch the filesystem: a SKILL.md written while a session runs is invisible to
11
+ * that session until it restarts (measured: `get_available_commands` kept
12
+ * reporting 8 skill entries after a ninth landed on disk, and `/skill:<new>`
13
+ * resolved to nothing). The only refresh omp exposes is the `/reload-plugins`
14
+ * builtin, which has a text-mode `handle` — so it is reachable as a prompt.
15
+ *
16
+ * Verified against omp 18.3.5: sending `/reload-plugins` as a prompt answers
17
+ * `agentInvoked: false` (no model turn), writes nothing to the transcript, and
18
+ * emits `available_commands_update`, after which the new skill is listed and
19
+ * runnable. That is what makes broadcasting it safe.
20
+ *
21
+ * Only IDLE sessions are reloaded. A busy session is mid-turn, and a prompt
22
+ * dispatched into a running turn is a queue insert with user-visible
23
+ * consequences; its next turn picks the new skills up from the restart path
24
+ * instead. Best-effort: one session failing never blocks the others.
25
+ */
26
+
27
+ import { listRpcSessions } from '@/server/lib/omp/rpc/session-registry';
28
+
29
+ /** Ask every idle live session to re-discover plugins. Returns the ids that
30
+ * accepted the reload. */
31
+ export async function reloadLiveSessions(): Promise<string[]> {
32
+ const reloaded: string[] = [];
33
+ await Promise.all(
34
+ listRpcSessions().map(async (session) => {
35
+ if (session.isBusy()) return;
36
+ try {
37
+ await session.send({ type: 'prompt', message: '/reload-plugins' });
38
+ reloaded.push(session.sessionId);
39
+ } catch (error) {
40
+ console.error(
41
+ `Failed to reload plugins for session ${session.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
42
+ );
43
+ }
44
+ }),
45
+ );
46
+ return reloaded;
47
+ }
@@ -21,7 +21,7 @@
21
21
  * parse it exists to avoid.
22
22
  */
23
23
 
24
- import { promises as fs } from 'fs';
24
+ import { promises as fs, statSync } from 'fs';
25
25
  import { extractSubagentHistory } from '@/server/lib/omp/subagent/history';
26
26
  import { siblingDirForSession } from '@/server/lib/omp/subagent/history/paths';
27
27
 
@@ -35,9 +35,33 @@ const MAX_ENTRIES = 2_000;
35
35
 
36
36
  const cache = new Map<string, PresenceEntry>();
37
37
 
38
- async function siblingDirMtimeMs(dir: string): Promise<number> {
38
+ /**
39
+ * Version of the sibling artifacts directory, or `0` when it does not exist —
40
+ * which is the COMMON case, since most sessions never spawn a subagent.
41
+ *
42
+ * The stat is SYNC (`statSync` + `throwIfNoEntry: false`) rather than an
43
+ * awaited `fs.stat`, because an awaited one rejects on that common path and a
44
+ * caught rejection from `fs` is still reported by Bun's rejection tracker when
45
+ * this module is re-evaluated by `bun run --hot` — the dev server, and any
46
+ * `ompchamber serve` whose NODE_ENV is unset. The result was a full ENOENT
47
+ * report with a `Bun v1.4.2` footer printed over a healthy server's own sidebar
48
+ * polls. Measured on Bun 1.4.2 against this exact function — 40 concurrent
49
+ * probes per request, 40 module rewrites, 6 runs: `try { await fs.stat(dir) }
50
+ * catch {}` reported ENOENT 8-12 times, `.catch()` 4-8 times, while both
51
+ * `throwIfNoEntry: false` and `statSync` reported zero, all yielding the same
52
+ * `0` and the same `hasSubagents: false`. The distinguishing property is not
53
+ * the error handling but whether a rejected promise is created at all; a sync
54
+ * throw is caught on the spot and never reaches the tracker.
55
+ *
56
+ * Cost is not a concern: 343 sessions (this machine's sidebar) cost 0.37 ms per
57
+ * poll versus 0.32 ms for the async form — 0.055 ms, on a poll that runs at
58
+ * most every few seconds.
59
+ */
60
+ function siblingDirVersion(dir: string): number {
39
61
  try {
40
- return (await fs.stat(dir)).mtimeMs;
62
+ // Only ENOENT is suppressed; EACCES and the like still throw and are
63
+ // swallowed here, leaving the `0` contract intact.
64
+ return statSync(dir, { throwIfNoEntry: false })?.mtimeMs ?? 0;
41
65
  } catch {
42
66
  return 0;
43
67
  }
@@ -48,17 +72,24 @@ export async function sessionHasSubagents(
48
72
  sessionLastEntryAt: string,
49
73
  ): Promise<boolean> {
50
74
  const siblingDir = siblingDirForSession(sessionPath);
51
- const version = `${sessionLastEntryAt}:${await siblingDirMtimeMs(siblingDir)}`;
75
+ const siblingMtime = siblingDirVersion(siblingDir);
76
+ const version = `${sessionLastEntryAt}:${siblingMtime}`;
52
77
 
53
78
  const cached = cache.get(sessionPath);
54
79
  if (cached && cached.version === version) return cached.hasSubagents;
55
80
 
56
81
  let hasSubagents = false;
57
- try {
58
- const files = await fs.readdir(siblingDir);
59
- hasSubagents = files.some((file) => file.endsWith('.jsonl'));
60
- } catch {
61
- hasSubagents = false;
82
+ // Guarded on the version: an unguarded readdir on the absent dir is the same
83
+ // spurious-rejection leak, and it rejects on the same sessions. The try/catch
84
+ // is what remains for a genuine read failure (the dir removed between the
85
+ // stat and here), which is rare enough not to be the reported noise.
86
+ if (siblingMtime !== 0) {
87
+ try {
88
+ const files = await fs.readdir(siblingDir);
89
+ hasSubagents = files.some((file) => file.endsWith('.jsonl'));
90
+ } catch {
91
+ hasSubagents = false;
92
+ }
62
93
  }
63
94
  if (!hasSubagents) {
64
95
  try {