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,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,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The session-delete blast radius.
8
+ *
9
+ * `DELETE /api/sessions/:sessionId` removes a directory under the sessions root
10
+ * and another under the btw root, both by joining the id onto the root. The
11
+ * case that shipped broken was `.`: `join(root, '.')` is `root` itself, so the
12
+ * btw purge's unconditional workspace removal deleted EVERY session's side
13
+ * questions and the route still answered 404 — an invisible, root-wide wipe
14
+ * from a request that looked like a miss.
15
+ *
16
+ * These tests pin the two properties that made it possible: the guard rejects
17
+ * every value that is not one segment, and the btw purge refuses a non-segment
18
+ * even when called directly (the route is not the only entry point).
19
+ */
20
+
21
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
25
+ import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
26
+
27
+ describe('isSinglePathSegment', () => {
28
+ test('accepts the id shapes the app actually produces', () => {
29
+ // omp session ids are UUIDs; mock ids are integers; pending chats are `new-…`.
30
+ expect(isSinglePathSegment('11111111-2222-3333-4444-555555555555')).toBe(true);
31
+ expect(isSinglePathSegment('3')).toBe(true);
32
+ expect(isSinglePathSegment('new-1758800000000')).toBe(true);
33
+ // A dot INSIDE a name is fine — only a name that resolves to a directory is not.
34
+ expect(isSinglePathSegment('session.v2')).toBe(true);
35
+ });
36
+
37
+ test('refuses the values that resolve somewhere other than one child', () => {
38
+ // The shipped bug: join(root, '.') === root.
39
+ expect(isSinglePathSegment('.')).toBe(false);
40
+ expect(isSinglePathSegment('..')).toBe(false);
41
+ expect(isSinglePathSegment('a/b')).toBe(false);
42
+ expect(isSinglePathSegment('..\\a')).toBe(false);
43
+ expect(isSinglePathSegment('')).toBe(false);
44
+ // A NUL byte truncates at the syscall boundary.
45
+ expect(isSinglePathSegment('a\0b')).toBe(false);
46
+ });
47
+
48
+ test('the guard agrees with the join it is guarding', () => {
49
+ // The property that matters: whenever the guard says yes, joining onto a
50
+ // root stays directly inside it. Asserted against `path.join` itself rather
51
+ // than a restatement of the rule.
52
+ const root = '/tmp/root';
53
+ for (const id of ['11111111-2222-3333-4444-555555555555', '3', 'new-1', 'session.v2']) {
54
+ expect(isSinglePathSegment(id)).toBe(true);
55
+ expect(path.join(root, id).startsWith(`${root}${path.sep}`)).toBe(true);
56
+ expect(path.dirname(path.join(root, id))).toBe(root);
57
+ }
58
+ for (const id of ['.', '..', 'a/b', '../x']) {
59
+ expect(isSinglePathSegment(id)).toBe(false);
60
+ }
61
+ });
62
+ });
63
+
64
+ describe('purgeBtwForSession refuses a non-segment id', () => {
65
+ const root = '/tmp/omc-path-segment-test';
66
+ const btwRoot = path.join(root, 'btw');
67
+ const seeded = [
68
+ path.join(btwRoot, 'sessA', 'topicA', 'session.jsonl'),
69
+ path.join(btwRoot, 'sessB', 'topicB', 'session.jsonl'),
70
+ ];
71
+
72
+ beforeAll(async () => {
73
+ await fs.promises.rm(root, { recursive: true, force: true });
74
+ // Two unrelated sessions' side questions, exactly as the bug destroyed them.
75
+ // The btw root is derived from the database path, so pointing that at this
76
+ // tree is what puts them where the purge would look.
77
+ for (const file of seeded) {
78
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
79
+ await Bun.write(file, '{"type":"session"}\n');
80
+ }
81
+ Bun.env.OMPCHAMBER_DB_PATH = path.join(root, 'db.sqlite');
82
+ });
83
+
84
+ afterAll(async () => {
85
+ delete Bun.env.OMPCHAMBER_DB_PATH;
86
+ await fs.promises.rm(root, { recursive: true, force: true });
87
+ });
88
+
89
+ test('`.` does not remove the btw root', async () => {
90
+ // The guard returns before `listBtwTopics`, so no database is touched.
91
+ expect(await purgeBtwForSession('.')).toBe(0);
92
+ // Both sessions' transcripts must survive — this is the regression.
93
+ for (const file of seeded) expect(fs.existsSync(file)).toBe(true);
94
+ });
95
+
96
+ test('a separator does not reach outside the root', async () => {
97
+ expect(await purgeBtwForSession('../btw')).toBe(0);
98
+ for (const file of seeded) expect(fs.existsSync(file)).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One rule, two callers: does this id name exactly one child of a root?
8
+ *
9
+ * The purge paths join a session id onto a root to find the directory it owns —
10
+ * `join(sessionsRoot, …)`, `join(btwRoot, sessionId)` — and then remove it
11
+ * recursively. `path.join` NORMALIZES, so a value that is not a plain segment
12
+ * silently retargets the removal:
13
+ *
14
+ * join(root, '.') === root → deletes every child of the root
15
+ * join(root, '..') === dirname(root) → deletes outside the root
16
+ * join(root, 'a/b') → reaches into a sibling subtree
17
+ *
18
+ * Measured: `DELETE /api/sessions/.` (curl needs `--path-as-is`; it strips the
19
+ * trailing dot itself) passed a `/^[A-Za-z0-9._-]+$/` guard, normalized to the
20
+ * btw root, and removed two unrelated sessions' side-question transcripts.
21
+ *
22
+ * The check is deliberately structural rather than an enumeration of dangerous
23
+ * spellings: an id is safe exactly when it is one non-dot segment. Real ids
24
+ * satisfy that by construction — omp session ids are UUIDs and mock ids are
25
+ * integers — so nothing legitimate is refused.
26
+ *
27
+ * `normalize` is compared, not just scanned for separators, because it is the
28
+ * same operation the callers' `join` performs: asserting the two agree is what
29
+ * makes this a proof about the path that will actually be built, rather than a
30
+ * guess about which inputs look suspicious.
31
+ */
32
+
33
+ import { normalize } from 'node:path';
34
+
35
+ /** True when `id` names one child of a root, and nothing else. */
36
+ export function isSinglePathSegment(id: string): boolean {
37
+ if (id.length === 0) return false;
38
+ if (id === '.' || id === '..') return false;
39
+ if (id.includes('/') || id.includes('\\')) return false;
40
+ // A NUL byte would truncate the path at the syscall boundary.
41
+ if (id.includes('\0')) return false;
42
+ // The load-bearing assertion: normalization must be a no-op, so `join(root, id)`
43
+ // cannot resolve anywhere but directly under `root`.
44
+ return normalize(id) === id;
45
+ }
@@ -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
+ });