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
@@ -4,90 +4,302 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Native OMP skill discovery — scans SKILL.md files under ~/.omp/agent/skills
8
- * and <project>/.omp/skills so the settings skills list reflects the skills
9
- * the omp agent actually loads. Supports atomic frontmatter toggles
10
- * (disableModelInvocation) for real skill files.
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
- /** Which disk root the skill was discovered from. */
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
- async function parseSkillMd(filePath: string, sourceRoot: 'user' | 'project'): Promise<DiscoveredSkill | undefined> {
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 undefined;
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
- id: `omp-${sourceRoot}-${name}`,
40
- name,
41
- description: data.description || '',
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 undefined;
202
+ return null;
47
203
  }
48
204
  }
49
205
 
50
- /**
51
- * Scan one skill root: either a flat dir of skill folders (each containing a
52
- * SKILL.md) or nested layouts — any SKILL.md within depth 2 is accepted.
53
- */
54
- async function scanSkillRoot(root: string, sourceRoot: 'user' | 'project'): Promise<DiscoveredSkill[]> {
55
- if (!(await pathExists(root))) return [];
56
- const found: DiscoveredSkill[] = [];
57
- try {
58
- const entries = await fs.promises.readdir(root, { withFileTypes: true });
59
- for (const entry of entries) {
60
- if (!entry.isDirectory()) continue;
61
- const skillMd = join(root, entry.name, 'SKILL.md');
62
- const skill = await parseSkillMd(skillMd, sourceRoot);
63
- if (skill) found.push(skill);
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
- return found;
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
- * Discover native skills from user + optional project roots. User skills win
73
- * over project skills with the same folder name. Never throws.
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 discoverNativeSkills(projectDir?: string): Promise<DiscoveredSkill[]> {
76
- const userRoot = join(getAgentDir(), 'skills');
77
- const projectRoot = projectDir ? join(projectDir, '.omp', 'skills') : undefined;
78
- const byName = new Map<string, DiscoveredSkill>();
79
- for (const skill of await scanSkillRoot(userRoot, 'user')) byName.set(skill.name, skill);
80
- if (projectRoot) {
81
- for (const skill of await scanSkillRoot(projectRoot, 'project')) {
82
- if (!byName.has(skill.name)) byName.set(skill.name, skill);
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
- return [...byName.values()];
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
- this.child = Bun.spawn({
61
- cmd: [bin, ...args],
62
- cwd: options.cwd,
63
- env: childEnv,
64
- stdin: 'pipe',
65
- stdout: 'pipe',
66
- stderr: 'pipe',
67
- windowsHide: true,
68
- // On POSIX, omp launches grandchildren (LSP servers, extension subprocesses). Run the
69
- // child in its own process group so dispose() can SIGTERM/SIGKILL the whole
70
- // tree — otherwise a crashed omp would orphan its LSP children as zombies.
71
- // Windows uses taskkill /t instead, so detaching would only create a console.
72
- detached: process.platform !== 'win32',
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 __ompUtilityRpcState: UtilityRpcState | undefined;
75
+ var __ompUtilityRpcStates: Map<string, UtilityRpcState> | undefined;
76
76
  }
77
77
 
78
- function getState(): UtilityRpcState {
79
- if (!globalThis.__ompUtilityRpcState) {
80
- globalThis.__ompUtilityRpcState = { proc: null, idleTimer: null, queue: Promise.resolve() };
81
- // Dispose the shared utility omp process on server shutdown so it does not
82
- // outlive the server. Idempotent and safe to call any time.
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
- return globalThis.__ompUtilityRpcState;
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 process immediately (skip the idle timer).
93
- * Safe to call any time — it just clears any pending idle kill and disposes
94
- * the live child.
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 state = globalThis.__ompUtilityRpcState;
98
- if (!state) return;
99
- if (state.idleTimer) {
100
- clearTimeout(state.idleTimer);
101
- state.idleTimer = null;
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
- const proc = state.proc;
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: homedir(),
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 {