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
|
@@ -4,90 +4,302 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Skill discovery and SKILL.md authoring for the chamber's Skills settings.
|
|
8
|
+
*
|
|
9
|
+
* Discovery is delegated to `omp skill list <dir> --json` rather than
|
|
10
|
+
* re-implemented here. omp's own resolver is the only thing that knows every
|
|
11
|
+
* root it reads — `.omp/skills` (project walk-up), `~/.omp/agent/skills`,
|
|
12
|
+
* `.claude`, `.agents`, `.codex`, `.github`, `.opencode`, plugin packages, the
|
|
13
|
+
* skillshare store — plus the `skills.*` settings gates and the frontmatter
|
|
14
|
+
* rules (`enabled: false`, a missing `description`, `hide`). A hand-rolled scan
|
|
15
|
+
* of two directories silently disagreed with the agent: it listed skills the
|
|
16
|
+
* agent could not run and hid the ones it could (measured: 6 shown vs 9 loaded
|
|
17
|
+
* in this repository, and a catalog install into `~/.agents/skills` was
|
|
18
|
+
* invisible to the chamber while omp ran it happily).
|
|
19
|
+
*
|
|
20
|
+
* `omp skill list` is cheap (measured 0.38-0.42s, no session, no model) and is
|
|
21
|
+
* cached per directory for a few seconds so a panel poll or a settings reload
|
|
22
|
+
* does not spawn a process per request.
|
|
23
|
+
*
|
|
24
|
+
* Writing is deliberately narrow: only the two roots the chamber owns as
|
|
25
|
+
* omp-native skills (`~/.omp/agent/skills` and `<workspace>/.omp/skills`) can
|
|
26
|
+
* be created in, edited or deleted. A skill from another provider (a Claude
|
|
27
|
+
* plugin, a registry package) is read-only here — the chamber is not its
|
|
28
|
+
* owner, and deleting a file another tool manages is not a "delete skill".
|
|
11
29
|
*/
|
|
12
30
|
|
|
13
31
|
import fs from 'fs';
|
|
14
|
-
import { join } from 'path';
|
|
32
|
+
import { join, resolve } from 'path';
|
|
15
33
|
import { getAgentDir, pathExists } from '@/server/lib/omp/core/paths';
|
|
34
|
+
import { resolveOmpBin } from '@/server/lib/omp/core/cli';
|
|
16
35
|
import { writeFileAtomic } from '@/server/lib/fs/atomic-write';
|
|
17
36
|
import { parseFrontmatter } from '@/server/lib/omp/config/yaml';
|
|
18
37
|
|
|
19
38
|
const MAX_SKILL_MD_BYTES = 512 * 1024;
|
|
39
|
+
const SKILL_LIST_TIMEOUT_MS = 30_000;
|
|
40
|
+
/** Discovery spawns a process, so a short cache absorbs the panel poll and the
|
|
41
|
+
* composer's own fetch without ever serving a stale list for long. */
|
|
42
|
+
const CACHE_TTL_MS = 5_000;
|
|
20
43
|
|
|
21
44
|
export interface DiscoveredSkill {
|
|
45
|
+
/** Stable id: `omp-<user|project>-<name>`. */
|
|
22
46
|
id: string;
|
|
23
47
|
name: string;
|
|
24
48
|
description: string;
|
|
25
|
-
/**
|
|
49
|
+
/** omp's provider:level source, e.g. `native:user`, `agents:project`. */
|
|
50
|
+
source: string;
|
|
26
51
|
sourceRoot: 'user' | 'project';
|
|
27
52
|
filePath: string;
|
|
53
|
+
baseDir: string;
|
|
54
|
+
/** Excluded from the model's skill listing (`hide` / disableModelInvocation). */
|
|
55
|
+
hidden: boolean;
|
|
56
|
+
/** True when the chamber owns the files and may rewrite or remove them. */
|
|
57
|
+
managed: boolean;
|
|
28
58
|
}
|
|
29
59
|
|
|
30
|
-
|
|
60
|
+
declare global {
|
|
61
|
+
// eslint-disable-next-line no-var
|
|
62
|
+
var __ompSkillListCache: Map<string, { at: number; skills: DiscoveredSkill[] }> | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getCache(): Map<string, { at: number; skills: DiscoveredSkill[] }> {
|
|
66
|
+
if (!globalThis.__ompSkillListCache) globalThis.__ompSkillListCache = new Map();
|
|
67
|
+
return globalThis.__ompSkillListCache;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Drop the discovery cache so the next read reflects a write just made. */
|
|
71
|
+
export function invalidateSkillCache(): void {
|
|
72
|
+
getCache().clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The omp-native roots the chamber may create, edit and delete in — and the
|
|
77
|
+
* only directories a `delete` may touch. A skill folder sits directly under one
|
|
78
|
+
* of these.
|
|
79
|
+
*/
|
|
80
|
+
export function managedSkillRoots(projectDir?: string | null): string[] {
|
|
81
|
+
const roots = [join(getAgentDir(), 'skills')];
|
|
82
|
+
if (projectDir) roots.push(join(projectDir, '.omp', 'skills'));
|
|
83
|
+
return roots;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A skill the chamber owns: omp-native AND sitting directly under a managed
|
|
87
|
+
* root. Everything else (a Claude plugin, a registry package) is read-only. */
|
|
88
|
+
function isManagedSkill(skill: { source: string; baseDir: string }, projectDir?: string | null): boolean {
|
|
89
|
+
if (!skill.source.startsWith('native:')) return false;
|
|
90
|
+
const parent = resolve(skill.baseDir, '..');
|
|
91
|
+
return managedSkillRoots(projectDir).some((root) => resolve(root) === parent);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface CliSkill {
|
|
95
|
+
name?: unknown;
|
|
96
|
+
description?: unknown;
|
|
97
|
+
filePath?: unknown;
|
|
98
|
+
baseDir?: unknown;
|
|
99
|
+
source?: unknown;
|
|
100
|
+
hide?: unknown;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Enumerate the skills an omp session in `cwd` would load, through omp itself.
|
|
105
|
+
* `projectDir` is the workspace the list is being shown for — it decides which
|
|
106
|
+
* roots count as chamber-managed, and is NOT the same thing as `cwd` for the
|
|
107
|
+
* user scope (whose cwd is the agent dir, which is not a workspace).
|
|
108
|
+
* Returns [] when omp is not installed or the directory is unreadable; a
|
|
109
|
+
* discovery failure must never surface as "you have no skills" being fatal.
|
|
110
|
+
*/
|
|
111
|
+
export async function discoverSkills(
|
|
112
|
+
cwd?: string | null,
|
|
113
|
+
projectDir?: string | null,
|
|
114
|
+
): Promise<DiscoveredSkill[]> {
|
|
115
|
+
const bin = resolveOmpBin();
|
|
116
|
+
if (!bin) return [];
|
|
117
|
+
|
|
118
|
+
// A missing cwd falls back to the agent dir — the user scope — because that
|
|
119
|
+
// is the only cwd whose inventory is a real scope rather than the server's
|
|
120
|
+
// own working directory.
|
|
121
|
+
const target = cwd && (await pathExists(cwd)) ? cwd : getAgentDir();
|
|
122
|
+
const cache = getCache();
|
|
123
|
+
const cached = cache.get(target);
|
|
124
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.skills;
|
|
125
|
+
|
|
126
|
+
const skills = await runSkillList(bin, target, projectDir ?? null);
|
|
127
|
+
cache.set(target, { at: Date.now(), skills });
|
|
128
|
+
return skills;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function runSkillList(bin: string, cwd: string, projectDir: string | null): Promise<DiscoveredSkill[]> {
|
|
132
|
+
let stdout: string;
|
|
133
|
+
try {
|
|
134
|
+
const proc = Bun.spawn({
|
|
135
|
+
cmd: [bin, 'skill', 'list', cwd, '--json'],
|
|
136
|
+
stdout: 'pipe',
|
|
137
|
+
stderr: 'pipe',
|
|
138
|
+
timeout: SKILL_LIST_TIMEOUT_MS,
|
|
139
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
140
|
+
windowsHide: true,
|
|
141
|
+
});
|
|
142
|
+
const [out, err] = await Promise.all([
|
|
143
|
+
new Response(proc.stdout).text(),
|
|
144
|
+
new Response(proc.stderr).text(),
|
|
145
|
+
]);
|
|
146
|
+
const exitCode = await proc.exited;
|
|
147
|
+
if (exitCode !== 0) {
|
|
148
|
+
console.error(`omp skill list failed (${exitCode}): ${err.trim().slice(0, 400)}`);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
stdout = out;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error(`omp skill list failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let parsed: { skills?: unknown };
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(stdout);
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(parsed.skills)) return [];
|
|
164
|
+
|
|
165
|
+
const result: DiscoveredSkill[] = [];
|
|
166
|
+
for (const raw of parsed.skills as CliSkill[]) {
|
|
167
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
168
|
+
const filePath = typeof raw.filePath === 'string' ? raw.filePath : '';
|
|
169
|
+
if (!name || !filePath) continue;
|
|
170
|
+
const source = typeof raw.source === 'string' ? raw.source : 'native:user';
|
|
171
|
+
const baseDir = typeof raw.baseDir === 'string' && raw.baseDir ? raw.baseDir : resolve(filePath, '..');
|
|
172
|
+
result.push({
|
|
173
|
+
id: `omp-${source.endsWith(':project') ? 'project' : 'user'}-${name}`,
|
|
174
|
+
name,
|
|
175
|
+
description: typeof raw.description === 'string' ? raw.description : '',
|
|
176
|
+
source,
|
|
177
|
+
sourceRoot: source.endsWith(':project') ? 'project' : 'user',
|
|
178
|
+
filePath,
|
|
179
|
+
baseDir,
|
|
180
|
+
hidden: raw.hide === true,
|
|
181
|
+
managed: isManagedSkill({ source, baseDir }, projectDir),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read one skill's SKILL.md, split into frontmatter data and body. */
|
|
188
|
+
export async function readSkillFile(
|
|
189
|
+
filePath: string,
|
|
190
|
+
): Promise<{ description: string; body: string; hidden: boolean } | null> {
|
|
31
191
|
try {
|
|
32
192
|
const file = Bun.file(filePath);
|
|
33
|
-
if ((await file.stat()).size > MAX_SKILL_MD_BYTES) return
|
|
193
|
+
if (!(await file.exists()) || (await file.stat()).size > MAX_SKILL_MD_BYTES) return null;
|
|
34
194
|
const text = await file.text();
|
|
35
|
-
const { data } = parseFrontmatter(text);
|
|
36
|
-
const name = data.name || filePath.split('/').slice(-2, -1)[0] || '';
|
|
37
|
-
if (!name) return undefined;
|
|
195
|
+
const { data, body } = parseFrontmatter(text);
|
|
38
196
|
return {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
sourceRoot,
|
|
43
|
-
filePath,
|
|
197
|
+
description: data.description ?? '',
|
|
198
|
+
body: body.replace(/^\r?\n/, ''),
|
|
199
|
+
hidden: data.disableModelInvocation === 'true' || data.hide === 'true',
|
|
44
200
|
};
|
|
45
201
|
} catch {
|
|
46
|
-
return
|
|
202
|
+
return null;
|
|
47
203
|
}
|
|
48
204
|
}
|
|
49
205
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
206
|
+
/** A skill name that is safe as a single directory component. */
|
|
207
|
+
function assertSafeName(name: string): string {
|
|
208
|
+
const trimmed = name.trim();
|
|
209
|
+
if (!trimmed) throw new Error('Skill name is required');
|
|
210
|
+
if (/[\\/]/.test(trimmed) || trimmed === '.' || trimmed === '..') {
|
|
211
|
+
throw new Error('Skill name may not contain a path separator');
|
|
212
|
+
}
|
|
213
|
+
return trimmed;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Serialize a SKILL.md from its parts. `description` is mandatory for omp to
|
|
217
|
+
* load the skill at all, so it is always written as a frontmatter field. */
|
|
218
|
+
function serializeSkillMd(input: { name: string; description: string; hidden: boolean; body: string }): string {
|
|
219
|
+
const lines = ['---', `name: ${JSON.stringify(input.name)}`, `description: ${JSON.stringify(input.description)}`];
|
|
220
|
+
if (input.hidden) lines.push('disableModelInvocation: true');
|
|
221
|
+
lines.push('---', '', input.body.replace(/\r\n/g, '\n').replace(/\s*$/, ''), '');
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface WriteSkillInput {
|
|
226
|
+
/** Target root: `user` writes to the agent dir, `project` to the workspace. */
|
|
227
|
+
scope: 'user' | 'project';
|
|
228
|
+
/** Workspace root, required for `project`. */
|
|
229
|
+
projectDir?: string | null;
|
|
230
|
+
/** Base directory of the skill being edited, when it already exists. */
|
|
231
|
+
previousDir?: string | null;
|
|
232
|
+
name: string;
|
|
233
|
+
description: string;
|
|
234
|
+
hidden: boolean;
|
|
235
|
+
body: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Create or overwrite a chamber-managed SKILL.md. Refuses a scope it cannot
|
|
239
|
+
* own (a project write with no workspace) rather than writing somewhere else. */
|
|
240
|
+
export async function writeSkillFile(input: WriteSkillInput): Promise<{ filePath: string }> {
|
|
241
|
+
const name = assertSafeName(input.name);
|
|
242
|
+
if (input.scope === 'project' && !input.projectDir) {
|
|
243
|
+
throw new Error('A workspace root is required to write a project skill');
|
|
244
|
+
}
|
|
245
|
+
const root = input.scope === 'project' ? join(input.projectDir!, '.omp', 'skills') : join(getAgentDir(), 'skills');
|
|
246
|
+
|
|
247
|
+
const dir = join(root, name);
|
|
248
|
+
const filePath = join(dir, 'SKILL.md');
|
|
249
|
+
|
|
250
|
+
// A rename or a scope move is a move: the old directory must not linger as a
|
|
251
|
+
// second skill. It is removed only when it is a DIFFERENT directory inside a
|
|
252
|
+
// managed root, so an edit in place never deletes the file just written and a
|
|
253
|
+
// move across scopes cleans up the source.
|
|
254
|
+
const previous = input.previousDir?.trim();
|
|
255
|
+
if (previous) {
|
|
256
|
+
const oldDir = resolve(previous);
|
|
257
|
+
if (oldDir !== resolve(dir)) {
|
|
258
|
+
const parent = resolve(oldDir, '..');
|
|
259
|
+
if (managedSkillRoots(input.projectDir).some((candidate) => resolve(candidate) === parent)) {
|
|
260
|
+
if (await pathExists(oldDir)) await fs.promises.rm(oldDir, { recursive: true, force: true });
|
|
261
|
+
}
|
|
64
262
|
}
|
|
65
|
-
} catch {
|
|
66
|
-
// Unreadable root yields no skills rather than throwing.
|
|
67
263
|
}
|
|
68
|
-
|
|
264
|
+
|
|
265
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
266
|
+
await writeFileAtomic(
|
|
267
|
+
filePath,
|
|
268
|
+
serializeSkillMd({ name, description: input.description, hidden: input.hidden, body: input.body }),
|
|
269
|
+
{ createMode: 0o644 },
|
|
270
|
+
);
|
|
271
|
+
invalidateSkillCache();
|
|
272
|
+
return { filePath };
|
|
69
273
|
}
|
|
70
274
|
|
|
71
275
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
276
|
+
* Delete a chamber-managed skill directory. Refused unless the target is an
|
|
277
|
+
* omp-native skill sitting directly under a managed root, so a delete can never
|
|
278
|
+
* reach a skill another tool installed.
|
|
74
279
|
*/
|
|
75
|
-
export async function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
280
|
+
export async function deleteSkillDir(id: string, projectDir?: string | null): Promise<boolean> {
|
|
281
|
+
// A user-scope delete (no workspace) is discovered from the agent dir, which
|
|
282
|
+
// is also the only managed root that applies there.
|
|
283
|
+
const found = (await discoverSkills(projectDir ?? getAgentDir(), projectDir)).find((skill) => skill.id === id);
|
|
284
|
+
if (!found) return false;
|
|
285
|
+
if (!found.managed) throw new Error(`"${found.name}" is managed by ${found.source}; the chamber cannot delete it`);
|
|
286
|
+
|
|
287
|
+
const dir = resolve(found.baseDir);
|
|
288
|
+
const parent = resolve(dir, '..');
|
|
289
|
+
if (!managedSkillRoots(projectDir).some((root) => resolve(root) === parent)) {
|
|
290
|
+
throw new Error(`Refusing to delete outside the managed skill roots: ${dir}`);
|
|
84
291
|
}
|
|
85
|
-
|
|
292
|
+
if (!(await pathExists(dir))) return false;
|
|
293
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
294
|
+
invalidateSkillCache();
|
|
295
|
+
return true;
|
|
86
296
|
}
|
|
87
297
|
|
|
88
298
|
/**
|
|
89
299
|
* Atomically toggle `disableModelInvocation` in a SKILL.md frontmatter —
|
|
90
|
-
* read-modify-write via temp+rename, preserving the rest of the file.
|
|
300
|
+
* read-modify-write via temp+rename, preserving the rest of the file. The
|
|
301
|
+
* skill stays loadable through `skill://` and `/skill:<name>`; it only drops
|
|
302
|
+
* out of the model's `<skills>` listing.
|
|
91
303
|
*/
|
|
92
304
|
export async function setSkillModelInvocation(filePath: string, disable: boolean): Promise<boolean> {
|
|
93
305
|
try {
|
|
@@ -111,6 +323,7 @@ export async function setSkillModelInvocation(filePath: string, disable: boolean
|
|
|
111
323
|
}
|
|
112
324
|
if (next === text) return false;
|
|
113
325
|
await writeFileAtomic(filePath, next);
|
|
326
|
+
invalidateSkillCache();
|
|
114
327
|
return true;
|
|
115
328
|
} catch {
|
|
116
329
|
return false;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { Subprocess } from 'bun';
|
|
18
|
+
import { describeSpawnFailure } from '@/server/lib/lifecycle/fd-pressure';
|
|
18
19
|
import { resolveOmpBin } from '@/server/lib/omp/core/cli';
|
|
19
20
|
import { RpcFrameDecoder, encodeRpcFrames, type RpcFrameRecord, type RpcProtocolVersion } from '@/shared/lib/omp/rpc/frame';
|
|
20
21
|
import { killProcessTree } from '@/server/lib/omp/rpc/kill-tree';
|
|
@@ -57,20 +58,27 @@ export class RpcProcess {
|
|
|
57
58
|
const childEnv = sanitizeProjectCommandEnvironment({ ...Bun.env, ...options.env });
|
|
58
59
|
if (options.env?.OMP_PROFILE === undefined) delete childEnv.OMP_PROFILE;
|
|
59
60
|
if (options.env?.PI_PROFILE === undefined) delete childEnv.PI_PROFILE;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
61
|
+
// A spawn fails synchronously when the process is out of descriptors, and
|
|
62
|
+
// Bun's own `EBADF` names the binary rather than the cause; `status` and
|
|
63
|
+
// the terminal panel are the diagnostics, so the message has to be right.
|
|
64
|
+
try {
|
|
65
|
+
this.child = Bun.spawn({
|
|
66
|
+
cmd: [bin, ...args],
|
|
67
|
+
cwd: options.cwd,
|
|
68
|
+
env: childEnv,
|
|
69
|
+
stdin: 'pipe',
|
|
70
|
+
stdout: 'pipe',
|
|
71
|
+
stderr: 'pipe',
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
// On POSIX, omp launches grandchildren (LSP servers, extension subprocesses). Run the
|
|
74
|
+
// child in its own process group so dispose() can SIGTERM/SIGKILL the whole
|
|
75
|
+
// tree — otherwise a crashed omp would orphan its LSP children as zombies.
|
|
76
|
+
// Windows uses taskkill /t instead, so detaching would only create a console.
|
|
77
|
+
detached: process.platform !== 'win32',
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(describeSpawnFailure(error));
|
|
81
|
+
}
|
|
74
82
|
|
|
75
83
|
let resolveReady: (frame: RpcFrame) => void;
|
|
76
84
|
let rejectReady: (error: Error) => void;
|
|
@@ -43,6 +43,12 @@ export function getRpcSession(sessionId: string): AgentSessionWrapper | undefine
|
|
|
43
43
|
return getRegistry().get(sessionId);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Every live session wrapper, for operations that must reach all of them
|
|
47
|
+
* (a plugin/skill reload) rather than one addressed id. */
|
|
48
|
+
export function listRpcSessions(): AgentSessionWrapper[] {
|
|
49
|
+
return [...getRegistry().values()].filter((session) => session.isAlive());
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
/**
|
|
47
53
|
* Sessions whose omp process is parked on a dialog only the user can release —
|
|
48
54
|
* an `ask` question or an approval gate. Deliberately independent of
|
|
@@ -72,37 +72,57 @@ interface UtilityRpcState {
|
|
|
72
72
|
|
|
73
73
|
declare global {
|
|
74
74
|
// eslint-disable-next-line no-var
|
|
75
|
-
var
|
|
75
|
+
var __ompUtilityRpcStates: Map<string, UtilityRpcState> | undefined;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Utility processes are pooled PER WORKING DIRECTORY, because a cwd is not just
|
|
80
|
+
* a spawn detail: omp resolves project-scope config from it. `.omp/commands`,
|
|
81
|
+
* `.omp/skills`, `.claude`, `.agents` and `AGENTS.md` in a workspace are only
|
|
82
|
+
* discovered by a child started in that workspace (measured: a child in $HOME
|
|
83
|
+
* reported 7 skill entries and one `file` command; the same child in a repo
|
|
84
|
+
* reported 9 and the repo's own command). One process per cwd keeps the global
|
|
85
|
+
* registry queries (`get_available_models`, `get_login_providers`) on the
|
|
86
|
+
* cheap home-scoped child while letting a workspace-scoped read see its own
|
|
87
|
+
* project config. Each pool entry is idle-killed on its own timer.
|
|
88
|
+
*/
|
|
89
|
+
function getState(cwd: string): UtilityRpcState {
|
|
90
|
+
if (!globalThis.__ompUtilityRpcStates) {
|
|
91
|
+
globalThis.__ompUtilityRpcStates = new Map();
|
|
92
|
+
// Dispose every pooled process on server shutdown so none outlives the
|
|
93
|
+
// server. Idempotent and safe to call any time.
|
|
83
94
|
const cleanup = () => disposeUtilityRpc();
|
|
84
95
|
process.once('exit', cleanup);
|
|
85
96
|
process.once('SIGINT', cleanup);
|
|
86
97
|
process.once('SIGTERM', cleanup);
|
|
87
98
|
}
|
|
88
|
-
|
|
99
|
+
const states = globalThis.__ompUtilityRpcStates;
|
|
100
|
+
let state = states.get(cwd);
|
|
101
|
+
if (!state) {
|
|
102
|
+
state = { proc: null, idleTimer: null, queue: Promise.resolve() };
|
|
103
|
+
states.set(cwd, state);
|
|
104
|
+
}
|
|
105
|
+
return state;
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
/**
|
|
92
|
-
* Tear down the shared utility omp
|
|
93
|
-
* Safe to call any time — it just clears any pending idle kill and
|
|
94
|
-
* the live
|
|
109
|
+
* Tear down the shared utility omp processes immediately (skip the idle
|
|
110
|
+
* timers). Safe to call any time — it just clears any pending idle kill and
|
|
111
|
+
* disposes the live children.
|
|
95
112
|
*/
|
|
96
113
|
export function disposeUtilityRpc(): void {
|
|
97
|
-
const
|
|
98
|
-
if (!
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
const states = globalThis.__ompUtilityRpcStates;
|
|
115
|
+
if (!states) return;
|
|
116
|
+
for (const state of states.values()) {
|
|
117
|
+
if (state.idleTimer) {
|
|
118
|
+
clearTimeout(state.idleTimer);
|
|
119
|
+
state.idleTimer = null;
|
|
120
|
+
}
|
|
121
|
+
const proc = state.proc;
|
|
122
|
+
state.proc = null;
|
|
123
|
+
if (proc) void proc.dispose();
|
|
102
124
|
}
|
|
103
|
-
|
|
104
|
-
state.proc = null;
|
|
105
|
-
if (proc) void proc.dispose();
|
|
125
|
+
states.clear();
|
|
106
126
|
}
|
|
107
127
|
|
|
108
128
|
function scheduleIdleKill(state: UtilityRpcState): void {
|
|
@@ -116,9 +136,9 @@ function scheduleIdleKill(state: UtilityRpcState): void {
|
|
|
116
136
|
state.idleTimer.unref?.();
|
|
117
137
|
}
|
|
118
138
|
|
|
119
|
-
async function startProcess(state: UtilityRpcState): Promise<RpcProcess> {
|
|
139
|
+
async function startProcess(state: UtilityRpcState, cwd: string): Promise<RpcProcess> {
|
|
120
140
|
const proc = new RpcProcess({
|
|
121
|
-
cwd
|
|
141
|
+
cwd,
|
|
122
142
|
extraArgs: UTILITY_EXTRA_ARGS,
|
|
123
143
|
onExit: () => {
|
|
124
144
|
if (state.proc === proc) state.proc = null;
|
|
@@ -135,12 +155,14 @@ async function startProcess(state: UtilityRpcState): Promise<RpcProcess> {
|
|
|
135
155
|
}
|
|
136
156
|
|
|
137
157
|
/** Run one RPC command on the shared utility process (lazy start, serialized,
|
|
138
|
-
* idle-killed). Rejections from earlier commands never poison the queue.
|
|
158
|
+
* idle-killed). Rejections from earlier commands never poison the queue.
|
|
159
|
+
* `cwd` scopes discovery to a workspace; omitted, the child runs in $HOME. */
|
|
139
160
|
export function runUtilityCommand<T = unknown>(
|
|
140
161
|
command: { type: string; [key: string]: unknown },
|
|
141
162
|
timeoutMs: number = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
163
|
+
cwd: string = homedir(),
|
|
142
164
|
): Promise<T> {
|
|
143
|
-
const state = getState();
|
|
165
|
+
const state = getState(cwd);
|
|
144
166
|
const run = state.queue.then(async () => {
|
|
145
167
|
if (state.idleTimer) {
|
|
146
168
|
clearTimeout(state.idleTimer);
|
|
@@ -148,7 +170,7 @@ export function runUtilityCommand<T = unknown>(
|
|
|
148
170
|
}
|
|
149
171
|
try {
|
|
150
172
|
if (!state.proc || !state.proc.isAlive) {
|
|
151
|
-
state.proc = await startProcess(state);
|
|
173
|
+
state.proc = await startProcess(state, cwd);
|
|
152
174
|
}
|
|
153
175
|
return await state.proc.sendCommand<T>(command, timeoutMs);
|
|
154
176
|
} finally {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* Where an unmatched raw turn lands.
|
|
6
|
+
*
|
|
7
|
+
* omp answers a builtin on the command path and writes NO session entry for it,
|
|
8
|
+
* so `/usage` or `/compact` exists only in the chamber's own copy — no JSONL
|
|
9
|
+
* user turn relates to it, and it cannot be matched into place. It was
|
|
10
|
+
* positioned by scanning the stored array forward for the next row the JSONL
|
|
11
|
+
* carried, which only worked because the overlay also held the mirrored rows —
|
|
12
|
+
* the very rows `overlayRowsForOmpSession` drops. So this is the regression the
|
|
13
|
+
* narrowing introduced: those turns fell to the END of the timeline, after
|
|
14
|
+
* answers that came later. The clock fixes it, and the two tests below are the
|
|
15
|
+
* behaviour and the equivalence.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
|
|
20
|
+
import { mergeOmpAttachments, overlayRowsForOmpSession, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
|
|
21
|
+
|
|
22
|
+
/** Arbitrary epoch ms, one second per step, so the order is exact. */
|
|
23
|
+
const at = (second: number) => 1_790_000_000_000 + second * 1000;
|
|
24
|
+
|
|
25
|
+
/** The conversation omp recorded. `/usage` is absent: a builtin writes nothing. */
|
|
26
|
+
function jsonl(): StoredMessage[] {
|
|
27
|
+
return [
|
|
28
|
+
{ id: 'omp-u1', role: 'user', content: 'first question', startedAt: at(0) },
|
|
29
|
+
{ id: 'omp-a1', role: 'ai', content: 'first answer', startedAt: at(1) },
|
|
30
|
+
{ id: 'omp-u2', role: 'user', content: 'second question', startedAt: at(30) },
|
|
31
|
+
{ id: 'omp-a2', role: 'ai', content: 'second answer', startedAt: at(31) },
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The chamber's copy of the same timeline, built in the order it was lived:
|
|
36
|
+
* mirrors and local rows interleaved by time. */
|
|
37
|
+
function storedFull(): StoredMessage[] {
|
|
38
|
+
return [
|
|
39
|
+
{ id: 'msg-1-user', role: 'user', content: 'first question', startedAt: at(0) },
|
|
40
|
+
{ id: 'omp-a1', role: 'ai', content: 'first answer', startedAt: at(1) },
|
|
41
|
+
{ id: 'msg-2-user', role: 'user', content: '/usage', startedAt: at(2) },
|
|
42
|
+
{ id: 'cmdout-1', role: 'ai', content: '', notice: '/usage output', startedAt: at(3) },
|
|
43
|
+
{ id: 'msg-3-user', role: 'user', content: 'second question', startedAt: at(30) },
|
|
44
|
+
{ id: 'omp-a2', role: 'ai', content: 'second answer', startedAt: at(31) },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const order = (messages: StoredMessage[]) => messages.map((m) => m.notice ?? m.content);
|
|
49
|
+
|
|
50
|
+
describe('unmatched raw turn placement', () => {
|
|
51
|
+
test('lands where the user sent it, not after the turns that followed', () => {
|
|
52
|
+
const merged = mergeOmpAttachments(jsonl(), JSON.stringify(overlayRowsForOmpSession(storedFull())));
|
|
53
|
+
|
|
54
|
+
expect(order(merged)).toEqual([
|
|
55
|
+
'first question',
|
|
56
|
+
'first answer',
|
|
57
|
+
'/usage',
|
|
58
|
+
'/usage output',
|
|
59
|
+
'second question',
|
|
60
|
+
'second answer',
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('narrowing the overlay does not move it', () => {
|
|
65
|
+
const full = mergeOmpAttachments(jsonl(), JSON.stringify(storedFull()));
|
|
66
|
+
const narrowed = mergeOmpAttachments(
|
|
67
|
+
jsonl(),
|
|
68
|
+
JSON.stringify(overlayRowsForOmpSession(storedFull())),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(narrowed).toEqual(full);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a turn with no clock to read still renders rather than being dropped', () => {
|
|
75
|
+
const clockless: StoredMessage[] = [
|
|
76
|
+
{ id: 'msg-1-user', role: 'user', content: 'first question', startedAt: at(0) },
|
|
77
|
+
{ id: 'msg-2-user', role: 'user', content: '/usage' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const merged = mergeOmpAttachments(jsonl(), JSON.stringify(clockless));
|
|
81
|
+
|
|
82
|
+
expect(order(merged)).toContain('/usage');
|
|
83
|
+
expect(merged.some((m) => m.startedAt === at(0))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|