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,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]);
@@ -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 {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Which shells are live, and which of them are doing work.
8
+ *
9
+ * Split from the registry that mutates them (`runtime.server.ts`) because the
10
+ * question is asked from outside the socket: the terminal inventory endpoint
11
+ * ("which shells can this client adopt?"), and the capacity check that runs
12
+ * before a new shell is created. Both ask it about every session at once, so
13
+ * both pay for one `ps` rather than one per shell.
14
+ *
15
+ * Two kinds of session are deliberately excluded from `countLiveTerminals`: an
16
+ * exited shell (a corpse with a scrollback nobody is reading) and a shell
17
+ * sitting at its prompt — the PTY's foreground process group tells them apart
18
+ * exactly (see `foreground.ts`). Counting idle shells is how a panel that was
19
+ * restarted or switched a few times pins the budget with nothing running, and
20
+ * the next attach is refused.
21
+ */
22
+
23
+ import path from 'path';
24
+ import { detectBusyShells, type ShellProcess } from '@/server/lib/terminal/foreground';
25
+ import { snapshotOf } from '@/server/lib/terminal/session.server';
26
+ import { terminalStore } from '@/server/lib/terminal/store';
27
+ import type { TerminalSnapshot } from '@/shared/lib/workspace/terminal/protocol';
28
+
29
+ /** Shells that are actually doing work, which is what the cap budgets. */
30
+ export async function countLiveTerminals(): Promise<number> {
31
+ const running: ShellProcess[] = [];
32
+ for (const session of terminalStore().sessions.values()) {
33
+ if (session.status !== 'running' || !session.proc) continue;
34
+ running.push({ id: session.id, pid: session.proc.pid });
35
+ }
36
+ const busy = await detectBusyShells(running);
37
+ return busy.size;
38
+ }
39
+
40
+ /**
41
+ * Live terminal summaries, optionally narrowed to one scope, so a client can
42
+ * adopt a shell it does not have an id for (a reloaded page, a second tab).
43
+ *
44
+ * `busy` marks a shell with a foreground command — the panel shows it as
45
+ * running, and the cap budgets it.
46
+ */
47
+ export async function listTerminals(root?: string | null, repo?: string | null): Promise<TerminalSnapshot[]> {
48
+ const scope = root ? (repo && repo !== '.' ? path.join(root, repo) : root) : null;
49
+ // Resolved once: a scope boundary either ends in a separator already or gets
50
+ // one, so a sibling directory that merely shares the prefix (`/a/bc`) is not
51
+ // read as being inside `/a/b`.
52
+ const scopePrefix = scope && !scope.endsWith(path.sep) ? scope + path.sep : scope;
53
+
54
+ const running: ShellProcess[] = [];
55
+ const out: TerminalSnapshot[] = [];
56
+ for (const session of terminalStore().sessions.values()) {
57
+ if (scopePrefix && session.cwd !== scope && !session.cwd.startsWith(scopePrefix)) continue;
58
+ if (session.status === 'running' && session.proc) {
59
+ running.push({ id: session.id, pid: session.proc.pid });
60
+ }
61
+ out.push(snapshotOf(session));
62
+ }
63
+ const busy = await detectBusyShells(running);
64
+ return out.map((snapshot) => ({ ...snapshot, busy: busy.has(snapshot.id) }));
65
+ }