ompchamber 3.5.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 (71) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +357 -353
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  8. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  9. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  10. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  11. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  12. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  13. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  14. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  15. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  16. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  17. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  18. package/src/client/data/settings/command.ts +0 -40
  19. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  20. package/src/client/hooks/ui/updates/index.ts +178 -0
  21. package/src/server/db.server.ts +41 -11
  22. package/src/server/index.ts +17 -1
  23. package/src/server/lib/db/client.ts +17 -0
  24. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  25. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  26. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  27. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  28. package/src/server/lib/omp/config/commands.test.ts +167 -0
  29. package/src/server/lib/omp/config/commands.ts +195 -0
  30. package/src/server/lib/omp/config/scope.test.ts +49 -0
  31. package/src/server/lib/omp/config/scope.ts +65 -0
  32. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  33. package/src/server/lib/omp/config/skills.test.ts +105 -0
  34. package/src/server/lib/omp/config/skills.ts +260 -47
  35. package/src/server/lib/omp/rpc/process.ts +22 -14
  36. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  37. package/src/server/lib/omp/rpc/utility.ts +45 -23
  38. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  39. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  41. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  42. package/src/server/lib/omp/session/reload.server.ts +47 -0
  43. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  44. package/src/server/lib/terminal/liveness.server.ts +65 -0
  45. package/src/server/lib/terminal/runtime.server.ts +70 -117
  46. package/src/server/lib/terminal/session.server.ts +5 -2
  47. package/src/server/lib/terminal/store.ts +72 -0
  48. package/src/server/lib/updates/apply.ts +62 -33
  49. package/src/server/lib/updates/install.ts +9 -2
  50. package/src/server/lib/updates/omp.ts +37 -9
  51. package/src/server/lib/updates/single-flight.test.ts +61 -0
  52. package/src/server/lib/updates/single-flight.ts +68 -0
  53. package/src/server/routes/chat/session.ts +13 -3
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/server/routes/omp/index.ts +0 -2
  56. package/src/server/routes/settings/commands.ts +102 -15
  57. package/src/server/routes/settings/skills.ts +165 -113
  58. package/src/server/routes/terminal/sessions.ts +1 -1
  59. package/src/server/routes/updates/apply.ts +101 -14
  60. package/src/shared/lib/chat/composer/client.ts +27 -18
  61. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  62. package/src/shared/lib/markdown/katex.ts +11 -1
  63. package/src/shared/lib/markdown/marked.ts +68 -13
  64. package/src/shared/lib/markdown/math.test.ts +53 -1
  65. package/src/shared/lib/updates/progress.test.ts +54 -0
  66. package/src/shared/lib/updates/progress.ts +50 -0
  67. package/src/shared/types/settings/command.ts +15 -2
  68. package/src/shared/types/settings/skill.ts +13 -0
  69. package/dist/client/static/js/index-vx0ernrv.css +0 -1
  70. package/src/client/hooks/ui/updates.ts +0 -108
  71. package/src/server/routes/omp/commands.ts +0 -29
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * The overlay repair. Its whole risk is asymmetric: narrowing a row it should
6
+ * not touch DESTROYS a conversation (for a chamber-created session the DB is the
7
+ * only copy there is), while leaving a row it should narrow merely wastes disk.
8
+ * So the file-existence gate is the behaviour under test, and it is exercised
9
+ * against a real temp tree rather than a stub — `findSessionFileById` is the
10
+ * thing being trusted.
11
+ *
12
+ * The pass has no marker on purpose (see the module): the per-row "did the
13
+ * filter shrink it?" guard is what makes it self-limiting, so a second run must
14
+ * find nothing left to do.
15
+ */
16
+
17
+ import { afterAll, describe, expect, test } from 'bun:test';
18
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { createDb, type DbClient } from '@/server/lib/db/client';
22
+ import { initSchema } from '@/server/lib/db/schema';
23
+ import { compactChatOverlayRows } from '@/server/lib/db/compact-chat-overlay';
24
+
25
+ const WITH_FILE = '01a0dddd-1111-7222-8333-444455556666';
26
+ const CHAMBER_ONLY = 'new-1700000000000';
27
+ const NOTICE = 'Context window: 1000000 tokens (1% used)';
28
+
29
+ /** Mirrored shape: the user turn plus assistant rows no reader ever touches. */
30
+ const MIRRORED = [
31
+ { id: 'msg-1-user', role: 'user', content: 'hello' },
32
+ { id: 'omp-a1', role: 'ai', content: 'hi there' },
33
+ { id: 'cmdout-1', role: 'ai', content: '', notice: NOTICE },
34
+ { id: 'omp-a2', role: 'ai', content: 'more' },
35
+ ];
36
+ /** A chamber-created conversation: no session file exists for it. */
37
+ const CHAMBER = [
38
+ { id: 'msg-9-user', role: 'user', content: 'ask' },
39
+ { id: 'local-ai', role: 'ai', content: 'answer' },
40
+ ];
41
+
42
+ const cleanup: string[] = [];
43
+ afterAll(() => {
44
+ for (const target of cleanup) rmSync(target, { recursive: true, force: true });
45
+ });
46
+
47
+ function tempDir(label: string): string {
48
+ const dir = join(tmpdir(), `overlay-${label}-${crypto.randomUUID()}`);
49
+ cleanup.push(dir);
50
+ mkdirSync(dir, { recursive: true });
51
+ return dir;
52
+ }
53
+
54
+ /** A sessions root holding exactly one session file, named the way omp names it. */
55
+ function sessionsRoot(): string {
56
+ const root = tempDir('root');
57
+ const project = join(root, '--proj--');
58
+ mkdirSync(project, { recursive: true });
59
+ writeFileSync(
60
+ join(project, `2026-01-01T00-00-00-000Z_${WITH_FILE}.jsonl`),
61
+ `${JSON.stringify({ type: 'session', id: WITH_FILE, timestamp: '2026-01-01T00:00:00.000Z', cwd: '/' })}\n`,
62
+ );
63
+ return root;
64
+ }
65
+
66
+ async function seededDb() {
67
+ const db = createDb(join(tempDir('db'), 'db.sqlite'));
68
+ await initSchema(db);
69
+ const put = (sessionId: string, messages: unknown[]) =>
70
+ db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages) VALUES (?, ?, ?)', [
71
+ sessionId,
72
+ 't',
73
+ JSON.stringify(messages),
74
+ ]);
75
+ await put(WITH_FILE, MIRRORED);
76
+ await put(CHAMBER_ONLY, CHAMBER);
77
+ return db;
78
+ }
79
+
80
+ async function storedMessages(db: DbClient, sessionId: string) {
81
+ const row = (await db.get('SELECT messages FROM chat_sessions WHERE session_id = ?', [sessionId])) as {
82
+ messages: string;
83
+ };
84
+ return JSON.parse(row.messages) as { id: string; role: string }[];
85
+ }
86
+
87
+ describe('compactChatOverlayRows', () => {
88
+ test('narrows a mirrored row whose session file exists', async () => {
89
+ const db = await seededDb();
90
+ await compactChatOverlayRows(db, sessionsRoot());
91
+
92
+ const kept = await storedMessages(db, WITH_FILE);
93
+ expect(kept.map((m) => m.id)).toEqual(['msg-1-user', 'cmdout-1']);
94
+ });
95
+
96
+ test('leaves a chamber-created row untouched — the DB is its only copy', async () => {
97
+ const db = await seededDb();
98
+ await compactChatOverlayRows(db, sessionsRoot());
99
+
100
+ expect(await storedMessages(db, CHAMBER_ONLY)).toEqual(CHAMBER);
101
+ });
102
+
103
+ test('finds nothing left to do on a second run', async () => {
104
+ const db = await seededDb();
105
+ const root = sessionsRoot();
106
+ await compactChatOverlayRows(db, root);
107
+ const once = JSON.stringify({
108
+ mirrored: await storedMessages(db, WITH_FILE),
109
+ chamber: await storedMessages(db, CHAMBER_ONLY),
110
+ });
111
+
112
+ await compactChatOverlayRows(db, root);
113
+
114
+ expect(
115
+ JSON.stringify({
116
+ mirrored: await storedMessages(db, WITH_FILE),
117
+ chamber: await storedMessages(db, CHAMBER_ONLY),
118
+ }),
119
+ ).toBe(once);
120
+ });
121
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Boot-time repair of the `chat_sessions` overlay.
8
+ *
9
+ * The overlay used to be a full mirror of every conversation: the chat timeline
10
+ * posted its whole rendered message list on every `message_end`, so each stored
11
+ * row carried a copy of the transcript omp already owns in the session JSONL.
12
+ * `overlayRowsForOmpSession` narrows what is written from now on, and this pass
13
+ * applies the same rule to rows written before it existed — measured on the
14
+ * author's install, 165.6 MB of 167.9 MB of mirrored rows were assistant
15
+ * messages no reader ever touches.
16
+ *
17
+ * It has NO marker, deliberately, and that is the difference from the
18
+ * `queue_fk_removed` migration: the guard here is per-row ("does the filter
19
+ * actually shrink this row?"), which makes the pass self-limiting rather than
20
+ * once-only. A marker would be set after the first clean pass and then leave
21
+ * every row written by an older build uncompacted for good — a live server has
22
+ * to be restarted for the narrowed writer to take effect, and it keeps writing
23
+ * mirrors until it is.
24
+ *
25
+ * Two properties keep it safe on an existing database:
26
+ *
27
+ * - It only rewrites a row whose session file EXISTS. For a chamber-created
28
+ * session the DB is the only copy, so those rows are left exactly as they are.
29
+ * - It only rewrites a row it actually shrinks, so the cost is proportional to
30
+ * what is left to fix: 762 ms for a 173 MB overlay on first run, and one
31
+ * parse-and-skip of the remaining ~5 MB on every later start.
32
+ *
33
+ * A failure is logged rather than thrown: it must never be able to stop the
34
+ * server from starting. It is called from the server bootstrap right after the
35
+ * "listening" banner and deliberately NOT from `getDb()` — the test suite and
36
+ * the CLI reach that in real mode, so a database HANDLE would be able to launch
37
+ * the rewrite (verified the hard way: a `bun test` run compacted a live
38
+ * database).
39
+ */
40
+
41
+ import type { DbClient } from '@/server/lib/db/client';
42
+ import { findSessionFileById } from '@/server/lib/omp/session/locator';
43
+ import { overlayRowsForOmpSession } from '@/server/lib/omp/session/merge-stored';
44
+
45
+ /**
46
+ * Rewrite every over-full `chat_sessions` row whose transcript omp owns.
47
+ *
48
+ * `sessionsRoot` is the locator's own seam, forwarded so a test can point the
49
+ * file lookup at its own tree instead of the real agent directory.
50
+ */
51
+ export async function compactChatOverlayRows(db: DbClient, sessionsRoot?: string): Promise<void> {
52
+ try {
53
+ const rows = (await db.all('SELECT session_id, messages FROM chat_sessions')) as {
54
+ session_id: string;
55
+ messages: string;
56
+ }[];
57
+
58
+ let compacted = 0;
59
+ let reclaimed = 0;
60
+ for (const row of rows) {
61
+ let parsed: unknown;
62
+ try {
63
+ parsed = JSON.parse(row.messages);
64
+ } catch {
65
+ // Unparseable is not ours to fix: the readers already treat it as empty.
66
+ continue;
67
+ }
68
+ if (!Array.isArray(parsed) || parsed.length === 0) continue;
69
+
70
+ const reduced = overlayRowsForOmpSession(parsed);
71
+ // The filter only ever removes, so an unchanged length means nothing to do
72
+ // — and the file lookup below is the expensive half, so it comes second.
73
+ if (reduced.length === parsed.length) continue;
74
+ if (!(await findSessionFileById(row.session_id, sessionsRoot))) continue;
75
+
76
+ const next = JSON.stringify(reduced);
77
+ await db.run('UPDATE chat_sessions SET messages = ? WHERE session_id = ?', [next, row.session_id]);
78
+ compacted += 1;
79
+ reclaimed += row.messages.length - next.length;
80
+ }
81
+
82
+ if (compacted > 0) {
83
+ console.log(
84
+ `[chat-overlay] compacted ${compacted} mirrored session row(s), reclaimed ${(reclaimed / 1e6).toFixed(1)} MB`,
85
+ );
86
+ }
87
+ } catch (error) {
88
+ console.warn('[chat-overlay] compaction failed; will retry on next start:', error);
89
+ }
90
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import fs from 'node:fs';
3
+ import { SPAWN_CLIFF, countOpenFileDescriptors, describeSpawnFailure } from '@/server/lib/lifecycle/fd-pressure';
4
+
5
+ describe('countOpenFileDescriptors', () => {
6
+ test('sweeps this process own descriptors on a POSIX host', () => {
7
+ const pressure = countOpenFileDescriptors();
8
+ if (process.platform === 'win32') {
9
+ expect(pressure).toBeNull();
10
+ return;
11
+ }
12
+ expect(pressure).not.toBeNull();
13
+ // Stdio is always open, and the sweep never walks past Darwin's OPEN_MAX —
14
+ // a descriptor above it cannot be opened, which is the whole point.
15
+ expect(pressure?.open).toBeGreaterThanOrEqual(3);
16
+ expect(pressure?.highest).toBeGreaterThanOrEqual(2);
17
+ expect(pressure?.limit).toBeLessThanOrEqual(SPAWN_CLIFF);
18
+ expect(pressure?.nearCliff).toBe(false);
19
+ });
20
+
21
+ test('counts descriptors opened and closed around it', () => {
22
+ const before = countOpenFileDescriptors();
23
+ if (!before) return; // Host cannot answer; nothing to assert.
24
+ const opened = [0, 1, 2, 3, 4].map(() => fs.openSync('/dev/null', 'r'));
25
+ try {
26
+ const during = countOpenFileDescriptors();
27
+ expect(during!.open - before.open).toBeGreaterThanOrEqual(opened.length);
28
+ expect(during!.highest).toBeGreaterThanOrEqual(Math.max(...opened));
29
+ } finally {
30
+ for (const fd of opened) fs.closeSync(fd);
31
+ }
32
+ });
33
+ });
34
+
35
+ describe('describeSpawnFailure', () => {
36
+ test('passes an unrelated failure through untouched', () => {
37
+ // The helper must not relabel a genuine bad-descriptor or ENOENT bug as
38
+ // resource pressure; only an observable full table earns the extra text.
39
+ expect(describeSpawnFailure(new Error('spawn ENOENT'))).toBe('spawn ENOENT');
40
+ expect(describeSpawnFailure('boom')).toBe('boom');
41
+ });
42
+
43
+ test('names exhaustion only when the table really is full', () => {
44
+ const opened: number[] = [];
45
+ try {
46
+ while (opened.length < SPAWN_CLIFF + 16) {
47
+ try {
48
+ opened.push(fs.openSync('/dev/null', 'r'));
49
+ } catch {
50
+ break; // Host limit is lower than the cliff; the gate is untestable here.
51
+ }
52
+ }
53
+ if (!countOpenFileDescriptors()?.nearCliff) return;
54
+
55
+ const raw = "EBADF: bad file descriptor, posix_spawn '/bin/sh'";
56
+ const described = describeSpawnFailure(new Error(raw));
57
+ expect(described.startsWith(raw)).toBe(true);
58
+ expect(described).toContain('out of file descriptors');
59
+ // The symptom gate still holds under pressure: an unrelated failure keeps
60
+ // its own message instead of being blamed on the descriptor table.
61
+ expect(describeSpawnFailure(new Error('spawn ENOENT'))).toBe('spawn ENOENT');
62
+ } finally {
63
+ for (const fd of opened) fs.closeSync(fd);
64
+ }
65
+ });
66
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * File-descriptor pressure, and the honest name for a spawn that dies from it.
8
+ *
9
+ * On macOS `posix_spawn` fails with `EBADF: bad file descriptor` the moment it
10
+ * must hand the child a descriptor numbered at or above `OPEN_MAX` (10240). The
11
+ * error names the shim it tried to exec — `posix_spawn '/bin/sh'` — and says
12
+ * nothing about the cause, so a terminal panel reads "Failed to start /bin/zsh:
13
+ * EBADF: bad file descriptor, posix_spawn '/bin/sh'" while the real condition is
14
+ * that the whole server process has run out of descriptors. Measured A/B on Bun
15
+ * 1.4.2 / macOS 26: 10230 open descriptors spawn fine, 10240 fails with exactly
16
+ * that message.
17
+ *
18
+ * A dev server reaches that state on its own, without a leak in this codebase:
19
+ * `Bun.serve({ development: true })` keeps the client module graph open
20
+ * (measured: 3545 descriptors for this app, against 17 in production), and a
21
+ * macOS reload never closes the previous generation's (bun#40706, bun#40907 —
22
+ * the pre-exec sweep exists only on Linux). The count therefore grows with
23
+ * every rebuild, and past the cliff every spawn in the process fails at once:
24
+ * the PTY shell, the `omp` child, `git`, `rg`.
25
+ *
26
+ * `countOpenFileDescriptors()` answers "how close?" with `fcntl(F_GETFD)` over
27
+ * the descriptor table — the same `bun:ffi` route `flock.ts` takes. The sweep
28
+ * stops at the cliff, because a descriptor above it cannot be opened anyway and
29
+ * that bound keeps the probe near 1 ms. It returns null where the symbols are
30
+ * unavailable (and on Windows), and every caller must read null as "unknown",
31
+ * never as "fine": `describeSpawnFailure` only names exhaustion when it can see
32
+ * the pressure, so a genuine bad-descriptor bug still reports itself plainly.
33
+ */
34
+
35
+ import { dlopen, FFIType } from 'bun:ffi';
36
+ import type { FFIFunction, Library } from 'bun:ffi';
37
+
38
+ /**
39
+ * Darwin's `OPEN_MAX`: the first descriptor number a process cannot hold, and
40
+ * therefore the point where `posix_spawn` starts answering `EBADF`. Verified on
41
+ * Bun 1.4.2 (10230 ok, 10240 fails).
42
+ */
43
+ export const SPAWN_CLIFF = 10_240;
44
+
45
+ /**
46
+ * Pressure is reported as "near" from 90% of the swept range. The remaining
47
+ * 1024 descriptors are what a burst of spawns needs (an omp child takes
48
+ * several), and the failure arrives without a warning of its own: once the
49
+ * table is full there is room for neither a retry nor a log line.
50
+ */
51
+ const NEAR_CLIFF_RATIO = 0.9;
52
+
53
+ const FD_SYMBOLS = {
54
+ fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
55
+ getdtablesize: { args: [], returns: FFIType.i32 },
56
+ } satisfies Record<string, FFIFunction>;
57
+
58
+ /** Library names to try, in the order each platform resolves them. */
59
+ const LIBC_NAMES = ['libc.dylib', 'libSystem.B.dylib', 'libc.so.6'] as const;
60
+
61
+ /** `F_GETFD` — the cheapest question that distinguishes open from closed. */
62
+ const F_GETFD = 1;
63
+
64
+ declare global {
65
+ // eslint-disable-next-line no-var
66
+ var __ompChamberFdLib: Library<typeof FD_SYMBOLS> | null | undefined;
67
+ }
68
+
69
+ /** libc with `fcntl`/`getdtablesize`, or null where this host has neither. */
70
+ function libc(): Library<typeof FD_SYMBOLS> | null {
71
+ if (globalThis.__ompChamberFdLib !== undefined) return globalThis.__ompChamberFdLib;
72
+ globalThis.__ompChamberFdLib = null;
73
+ if (process.platform === 'win32') return null;
74
+ for (const name of LIBC_NAMES) {
75
+ try {
76
+ globalThis.__ompChamberFdLib = dlopen(name, FD_SYMBOLS);
77
+ return globalThis.__ompChamberFdLib;
78
+ } catch {
79
+ // Try the next name; a host with none of them keeps the null answer.
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ export interface FdPressure {
86
+ /** Descriptors open below `limit`. */
87
+ open: number;
88
+ /** Highest descriptor number seen, or -1 when the table looked empty. */
89
+ highest: number;
90
+ /** Where the sweep stopped: `getdtablesize()`, capped at the cliff. */
91
+ limit: number;
92
+ /** Whether the next spawn is at risk. See `NEAR_CLIFF_RATIO`. */
93
+ nearCliff: boolean;
94
+ }
95
+
96
+ /**
97
+ * Sweep the descriptor table, or null when this host cannot answer.
98
+ *
99
+ * Descriptors are allocated lowest-first, so the number of open descriptors and
100
+ * the highest one agree on a saturated table — which is the only state that
101
+ * matters here, and the state a dev server reaches by degrees.
102
+ *
103
+ * Never throws: `GET /api/health` is the CLI's readiness probe and the client's
104
+ * liveness check, so a host where the symbol resolves but the call does not
105
+ * must degrade to "unknown" rather than take those down. `open` saturates at
106
+ * `limit`, since the sweep stops there by design.
107
+ */
108
+ export function countOpenFileDescriptors(): FdPressure | null {
109
+ const symbols = libc();
110
+ if (!symbols) return null;
111
+
112
+ try {
113
+ const reported = symbols.symbols.getdtablesize();
114
+ const table = Number.isFinite(reported) && reported > 0 ? reported : SPAWN_CLIFF;
115
+ const limit = Math.min(table, SPAWN_CLIFF);
116
+
117
+ let open = 0;
118
+ let highest = -1;
119
+ for (let fd = 0; fd < limit; fd++) {
120
+ if (symbols.symbols.fcntl(fd, F_GETFD, 0) !== -1) {
121
+ open += 1;
122
+ highest = fd;
123
+ }
124
+ }
125
+ return { open, highest, limit, nearCliff: highest >= limit * NEAR_CLIFF_RATIO };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * What descriptor exhaustion looks like in Bun's own words. macOS names the
133
+ * failing syscall (`EBADF … posix_spawn '/bin/sh'`), Linux the errno
134
+ * (`EMFILE: too many open files, socketpair`) — measured on both.
135
+ */
136
+ const EXHAUSTION_SYMPTOM = /\bEBADF\b|\bEMFILE\b|too many open files/i;
137
+
138
+ /**
139
+ * The message to surface for a failed spawn: Bun's own, plus what it takes to
140
+ * act on it when the cause is descriptor exhaustion.
141
+ *
142
+ * Two conditions, both required. The message must carry the symptom, and the
143
+ * sweep must see real pressure — a truncated table is not the only way `EBADF`
144
+ * reaches here, and appending "out of descriptors" to a genuine bad-descriptor
145
+ * bug (or to an unrelated `ENOENT` that failed while the table happened to be
146
+ * full) sends the next reader down the wrong path.
147
+ */
148
+ export function describeSpawnFailure(error: unknown): string {
149
+ const raw = error instanceof Error ? error.message : String(error);
150
+ if (!EXHAUSTION_SYMPTOM.test(raw)) return raw;
151
+ const pressure = countOpenFileDescriptors();
152
+ if (!pressure || !pressure.nearCliff) return raw;
153
+ return (
154
+ `${raw} — the chamber server is out of file descriptors ` +
155
+ `(${pressure.open} of ${pressure.limit} open, highest #${pressure.highest}); restart the instance. ` +
156
+ `A dev server holds the whole client module graph open and a macOS reload never releases ` +
157
+ `the previous generation (bun#40706), so the count only climbs.`
158
+ );
159
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Command-file location and authoring.
8
+ *
9
+ * `get_available_commands` reports a command's name, description and hint but
10
+ * never the file behind it, so the pane's read-only/editable decision and the
11
+ * delete's safety both rest on this lookup. The rules worth locking are the
12
+ * ones a user cannot see: which root a duplicate name resolves to (omp runs the
13
+ * PROJECT file), which roots the chamber may write to (only the two
14
+ * `omp`-native ones), and the `.prompt.md` suffix VS Code prompt files carry.
15
+ *
16
+ * Everything runs inside a temp workspace and a temp `$HOME`, so the real
17
+ * `~/.omp/agent/commands` is never read or written.
18
+ */
19
+
20
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
21
+ import fs from 'fs';
22
+ import { tmpdir } from 'os';
23
+ import { join } from 'path';
24
+
25
+ import {
26
+ deleteCommandFile,
27
+ locateCommandFile,
28
+ readCommandFile,
29
+ writeCommandFile,
30
+ } from '@/server/lib/omp/config/commands';
31
+
32
+ let projectDir = '';
33
+ let homeDir = '';
34
+ let realHome: string | undefined;
35
+
36
+ const write = (filePath: string, body = 'body') => {
37
+ fs.mkdirSync(join(filePath, '..'), { recursive: true });
38
+ fs.writeFileSync(filePath, body);
39
+ };
40
+
41
+ beforeAll(() => {
42
+ projectDir = fs.mkdtempSync(join(tmpdir(), 'ompchamber-cmd-project-'));
43
+ homeDir = fs.mkdtempSync(join(tmpdir(), 'ompchamber-cmd-home-'));
44
+ realHome = Bun.env.HOME;
45
+ // `userCommandDirs()` reads `$HOME` for the `.claude`/`.codex`/`.agents`/
46
+ // `.config/opencode` roots, so redirecting it keeps the test off the real one.
47
+ Bun.env.HOME = homeDir;
48
+ });
49
+
50
+ afterAll(() => {
51
+ if (realHome === undefined) delete Bun.env.HOME;
52
+ else Bun.env.HOME = realHome;
53
+ fs.rmSync(projectDir, { recursive: true, force: true });
54
+ fs.rmSync(homeDir, { recursive: true, force: true });
55
+ });
56
+
57
+ describe('locateCommandFile', () => {
58
+ test('a workspace .omp/commands file is the managed project root', async () => {
59
+ write(join(projectDir, '.omp', 'commands', 'proj.md'));
60
+ expect(await locateCommandFile('proj', projectDir)).toEqual({
61
+ filePath: join(projectDir, '.omp', 'commands', 'proj.md'),
62
+ scope: 'project',
63
+ managed: true,
64
+ });
65
+ });
66
+
67
+ test("another tool's file is found but never managed", async () => {
68
+ write(join(projectDir, '.claude', 'commands', 'claude-cmd.md'));
69
+ expect(await locateCommandFile('claude-cmd', projectDir)).toEqual({
70
+ filePath: join(projectDir, '.claude', 'commands', 'claude-cmd.md'),
71
+ scope: 'project',
72
+ managed: false,
73
+ });
74
+ });
75
+
76
+ test('a prompt file is found under its .prompt.md suffix', async () => {
77
+ write(join(projectDir, '.github', 'prompts', 'copilot.prompt.md'));
78
+ expect((await locateCommandFile('copilot', projectDir))?.filePath)
79
+ .toBe(join(projectDir, '.github', 'prompts', 'copilot.prompt.md'));
80
+ });
81
+
82
+ test('a duplicate name resolves to the project file', async () => {
83
+ write(join(homeDir, '.claude', 'commands', 'dupe.md'));
84
+ write(join(projectDir, '.omp', 'commands', 'dupe.md'));
85
+ expect((await locateCommandFile('dupe', projectDir))?.filePath)
86
+ .toBe(join(projectDir, '.omp', 'commands', 'dupe.md'));
87
+ // With no workspace the same name is the user-level file, which the
88
+ // chamber does not own.
89
+ expect(await locateCommandFile('dupe')).toEqual({
90
+ filePath: join(homeDir, '.claude', 'commands', 'dupe.md'),
91
+ scope: 'user',
92
+ managed: false,
93
+ });
94
+ });
95
+
96
+ test('a name that is not a plain file name is refused', async () => {
97
+ expect(await locateCommandFile('../escape', projectDir)).toBeNull();
98
+ expect(await locateCommandFile('nested/name', projectDir)).toBeNull();
99
+ expect(await locateCommandFile(' ', projectDir)).toBeNull();
100
+ });
101
+
102
+ test('a builtin has no file at all', async () => {
103
+ expect(await locateCommandFile('model', projectDir)).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe('writeCommandFile / deleteCommandFile', () => {
108
+ test('round-trips a project command through the file', async () => {
109
+ const { filePath } = await writeCommandFile({
110
+ scope: 'project',
111
+ projectDir,
112
+ name: 'round',
113
+ description: 'round trip',
114
+ argumentHint: '[a|b]',
115
+ body: 'do $ARGUMENTS',
116
+ });
117
+ expect(filePath).toBe(join(projectDir, '.omp', 'commands', 'round.md'));
118
+ // The parsed body carries the trailing newline the serializer emits, and
119
+ // the leading blank line that separates it from the frontmatter is dropped.
120
+ expect(await readCommandFile(filePath)).toEqual({
121
+ description: 'round trip',
122
+ argumentHint: '[a|b]',
123
+ body: 'do $ARGUMENTS\n',
124
+ });
125
+ // Saving what was read must not grow the file: the pane posts exactly this.
126
+ const first = fs.readFileSync(filePath, 'utf8');
127
+ const parsed = await readCommandFile(filePath);
128
+ await writeCommandFile({
129
+ scope: 'project',
130
+ projectDir,
131
+ name: 'round',
132
+ description: parsed!.description,
133
+ argumentHint: parsed!.argumentHint,
134
+ body: parsed!.body,
135
+ });
136
+ expect(fs.readFileSync(filePath, 'utf8')).toBe(first);
137
+ expect(await deleteCommandFile('round', projectDir)).toBe(true);
138
+ expect(await locateCommandFile('round', projectDir)).toBeNull();
139
+ });
140
+
141
+ test('a rename moves the file instead of leaving two', async () => {
142
+ await writeCommandFile({ scope: 'project', projectDir, name: 'before', description: 'd', argumentHint: '', body: 'b' });
143
+ await writeCommandFile({
144
+ scope: 'project',
145
+ projectDir,
146
+ previousName: 'before',
147
+ name: 'after',
148
+ description: 'd',
149
+ argumentHint: '',
150
+ body: 'b',
151
+ });
152
+ expect(await locateCommandFile('before', projectDir)).toBeNull();
153
+ expect(await locateCommandFile('after', projectDir)).not.toBeNull();
154
+ await deleteCommandFile('after', projectDir);
155
+ });
156
+
157
+ test('a project write with no workspace is refused', async () => {
158
+ expect(writeCommandFile({ scope: 'project', name: 'lost', description: 'd', argumentHint: '', body: 'b' }))
159
+ .rejects.toThrow('workspace root is required');
160
+ });
161
+
162
+ test("a delete cannot reach a file another tool installed", async () => {
163
+ write(join(projectDir, '.claude', 'commands', 'notours.md'));
164
+ expect(deleteCommandFile('notours', projectDir)).rejects.toThrow('cannot delete');
165
+ expect(fs.existsSync(join(projectDir, '.claude', 'commands', 'notours.md'))).toBe(true);
166
+ });
167
+ });