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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +357 -353
- package/dist/client/static/js/index-7n838fz5.css +1 -0
- package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
- package/package.json +1 -1
- package/src/cli/lib/commands/status.js +7 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
- package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
- package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
- package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
- package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
- package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
- package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
- package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
- package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
- package/src/client/data/settings/command.ts +0 -40
- package/src/client/hooks/settings/workspace-roots.ts +99 -0
- package/src/client/hooks/ui/updates/index.ts +178 -0
- package/src/server/db.server.ts +41 -11
- package/src/server/index.ts +17 -1
- package/src/server/lib/db/client.ts +17 -0
- package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
- package/src/server/lib/db/compact-chat-overlay.ts +90 -0
- package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
- package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
- package/src/server/lib/omp/config/commands.test.ts +167 -0
- package/src/server/lib/omp/config/commands.ts +195 -0
- package/src/server/lib/omp/config/scope.test.ts +49 -0
- package/src/server/lib/omp/config/scope.ts +65 -0
- package/src/server/lib/omp/config/skills-catalog.ts +35 -3
- package/src/server/lib/omp/config/skills.test.ts +105 -0
- package/src/server/lib/omp/config/skills.ts +260 -47
- package/src/server/lib/omp/rpc/process.ts +22 -14
- package/src/server/lib/omp/rpc/session-registry.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +45 -23
- package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
- package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
- package/src/server/lib/omp/session/merge-stored.ts +78 -11
- package/src/server/lib/omp/session/reload.server.test.ts +84 -0
- package/src/server/lib/omp/session/reload.server.ts +47 -0
- package/src/server/lib/omp/session/subagent-presence.ts +40 -9
- package/src/server/lib/terminal/liveness.server.ts +65 -0
- package/src/server/lib/terminal/runtime.server.ts +70 -117
- package/src/server/lib/terminal/session.server.ts +5 -2
- package/src/server/lib/terminal/store.ts +72 -0
- package/src/server/lib/updates/apply.ts +62 -33
- package/src/server/lib/updates/install.ts +9 -2
- package/src/server/lib/updates/omp.ts +37 -9
- package/src/server/lib/updates/single-flight.test.ts +61 -0
- package/src/server/lib/updates/single-flight.ts +68 -0
- package/src/server/routes/chat/session.ts +13 -3
- package/src/server/routes/health/probe.ts +8 -0
- package/src/server/routes/omp/index.ts +0 -2
- package/src/server/routes/settings/commands.ts +102 -15
- package/src/server/routes/settings/skills.ts +165 -113
- package/src/server/routes/terminal/sessions.ts +1 -1
- package/src/server/routes/updates/apply.ts +101 -14
- package/src/shared/lib/chat/composer/client.ts +27 -18
- package/src/shared/lib/chat/composer/scope.test.ts +87 -0
- package/src/shared/lib/markdown/katex.ts +11 -1
- package/src/shared/lib/markdown/marked.ts +68 -13
- package/src/shared/lib/markdown/math.test.ts +53 -1
- package/src/shared/lib/updates/progress.test.ts +54 -0
- package/src/shared/lib/updates/progress.ts +50 -0
- package/src/shared/types/settings/command.ts +15 -2
- package/src/shared/types/settings/skill.ts +13 -0
- package/dist/client/static/js/index-vx0ernrv.css +0 -1
- package/src/client/hooks/ui/updates.ts +0 -108
- 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
|
|
140
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|