ompchamber 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +357 -353
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  8. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  9. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  10. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  11. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  12. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  13. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  14. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  15. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  16. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  17. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  18. package/src/client/data/settings/command.ts +0 -40
  19. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  20. package/src/client/hooks/ui/updates/index.ts +178 -0
  21. package/src/server/db.server.ts +41 -11
  22. package/src/server/index.ts +17 -1
  23. package/src/server/lib/db/client.ts +17 -0
  24. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  25. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  26. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  27. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  28. package/src/server/lib/omp/config/commands.test.ts +167 -0
  29. package/src/server/lib/omp/config/commands.ts +195 -0
  30. package/src/server/lib/omp/config/scope.test.ts +49 -0
  31. package/src/server/lib/omp/config/scope.ts +65 -0
  32. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  33. package/src/server/lib/omp/config/skills.test.ts +105 -0
  34. package/src/server/lib/omp/config/skills.ts +260 -47
  35. package/src/server/lib/omp/rpc/process.ts +22 -14
  36. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  37. package/src/server/lib/omp/rpc/utility.ts +45 -23
  38. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  39. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  41. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  42. package/src/server/lib/omp/session/reload.server.ts +47 -0
  43. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  44. package/src/server/lib/terminal/liveness.server.ts +65 -0
  45. package/src/server/lib/terminal/runtime.server.ts +70 -117
  46. package/src/server/lib/terminal/session.server.ts +5 -2
  47. package/src/server/lib/terminal/store.ts +72 -0
  48. package/src/server/lib/updates/apply.ts +62 -33
  49. package/src/server/lib/updates/install.ts +9 -2
  50. package/src/server/lib/updates/omp.ts +37 -9
  51. package/src/server/lib/updates/single-flight.test.ts +61 -0
  52. package/src/server/lib/updates/single-flight.ts +68 -0
  53. package/src/server/routes/chat/session.ts +13 -3
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/server/routes/omp/index.ts +0 -2
  56. package/src/server/routes/settings/commands.ts +102 -15
  57. package/src/server/routes/settings/skills.ts +165 -113
  58. package/src/server/routes/terminal/sessions.ts +1 -1
  59. package/src/server/routes/updates/apply.ts +101 -14
  60. package/src/shared/lib/chat/composer/client.ts +27 -18
  61. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  62. package/src/shared/lib/markdown/katex.ts +11 -1
  63. package/src/shared/lib/markdown/marked.ts +68 -13
  64. package/src/shared/lib/markdown/math.test.ts +53 -1
  65. package/src/shared/lib/updates/progress.test.ts +54 -0
  66. package/src/shared/lib/updates/progress.ts +50 -0
  67. package/src/shared/types/settings/command.ts +15 -2
  68. package/src/shared/types/settings/skill.ts +13 -0
  69. package/dist/client/static/js/index-vx0ernrv.css +0 -1
  70. package/src/client/hooks/ui/updates.ts +0 -108
  71. package/src/server/routes/omp/commands.ts +0 -29
@@ -5,171 +5,223 @@ import { getDb } from '@/server/db.server';
5
5
  import { DEFAULT_CATALOG_SKILLS, DEFAULT_CATALOG_SOURCES, DEFAULT_SKILLS } from '@/client/data/settings/skill';
6
6
  import { isMockMode } from '@/server/mock.server';
7
7
  import type { SkillItem } from '@/shared/types';
8
- import { createSettingsListStore, readSettingsJson, writeSettingsJson } from '@/server/lib/db/settings-store';
9
- import { discoverNativeSkills, setSkillModelInvocation, type DiscoveredSkill } from '@/server/lib/omp/config/skills';
10
- import { installCatalogSkill, searchSkillCatalog, toCatalogSkills } from '@/server/lib/omp/config/skills-catalog';
8
+ import { readSettingsJson, writeSettingsJson } from '@/server/lib/db/settings-store';
9
+ import {
10
+ deleteSkillDir,
11
+ discoverSkills,
12
+ readSkillFile,
13
+ setSkillModelInvocation,
14
+ writeSkillFile,
15
+ type DiscoveredSkill,
16
+ } from '@/server/lib/omp/config/skills';
17
+ import {
18
+ installCatalogSkill,
19
+ searchSkillCatalog,
20
+ toCatalogSkills,
21
+ uninstallCatalogSkill,
22
+ } from '@/server/lib/omp/config/skills-catalog';
23
+ import { resolveDiscoveryScope, type DiscoveryScope } from '@/server/lib/omp/config/scope';
24
+ import { reloadLiveSessions } from '@/server/lib/omp/session/reload.server';
11
25
 
12
- const SKILLS_KEY = 'omp_skills';
13
26
  const SOURCES_KEY = 'omp_catalog_sources';
14
27
 
15
- const skillsStore = createSettingsListStore<SkillItem>({
16
- key: SKILLS_KEY,
17
- mockDefaults: DEFAULT_SKILLS,
18
- idOf: (skill) => skill.id,
19
- singular: 'skill',
20
- plural: 'skills',
21
- });
28
+ /** Human label for an omp discovery source. */
29
+ const SOURCE_LABELS: Record<string, string> = {
30
+ 'native:user': 'User / omp agent',
31
+ 'native:project': 'Project / .omp/skills',
32
+ 'agents:user': 'User / .agents',
33
+ 'agents:project': 'Project / .agents',
34
+ 'claude:user': 'User / .claude',
35
+ 'claude:project': 'Project / .claude',
36
+ 'codex:user': 'User / .codex',
37
+ 'codex:project': 'Project / .codex',
38
+ 'github:project': 'Project / .github/skills',
39
+ 'opencode:user': 'User / opencode',
40
+ 'opencode:project': 'Project / .opencode',
41
+ };
22
42
 
23
43
  /**
24
- * Convert a natively discovered SKILL.md into the chamber SkillItem shape.
25
- * location/locationLabel reflect the real disk root the skill came from.
44
+ * Convert a discovered skill into the chamber's SkillItem. A chamber-managed
45
+ * skill carries its SKILL.md body so the pane can edit it; a read-only one
46
+ * carries its file path, because the pane must not offer a save that would
47
+ * rewrite another tool's file.
26
48
  */
27
- function nativeToSkillItem(skill: DiscoveredSkill): SkillItem {
49
+ async function toSkillItem(skill: DiscoveredSkill): Promise<SkillItem> {
50
+ const locationLabel = SOURCE_LABELS[skill.source] ?? skill.source;
51
+ let instructions = skill.filePath;
52
+ if (skill.managed) {
53
+ const file = await readSkillFile(skill.filePath);
54
+ if (file) instructions = file.body;
55
+ }
28
56
  return {
29
57
  id: skill.id,
30
58
  name: skill.name,
31
59
  description: skill.description,
32
60
  location: skill.sourceRoot,
33
- locationLabel: skill.sourceRoot === 'user' ? 'User / OMP agent' : 'Project / .omp/skills',
34
- instructions: skill.filePath,
61
+ locationLabel,
62
+ instructions,
35
63
  project: 'omp',
64
+ filePath: skill.filePath,
65
+ source: skill.source,
66
+ managed: skill.managed,
67
+ hidden: skill.hidden,
36
68
  };
37
69
  }
38
70
 
39
- /** Merge native omp skills (disk scan) with app-local custom skills. */
40
- async function mergeSkills(custom: SkillItem[]): Promise<SkillItem[]> {
41
- const native = (await discoverNativeSkills()).map(nativeToSkillItem);
42
- const nativeNames = new Set(native.map((s) => s.name.toLowerCase()));
43
- return [...native, ...custom.filter((s) => !nativeNames.has(s.name.toLowerCase()))];
71
+ /** Every skill an omp session in this scope loads, newest state included. */
72
+ async function listSkills(scope: DiscoveryScope): Promise<SkillItem[]> {
73
+ const discovered = await discoverSkills(scope.cwd, scope.workspace);
74
+ return Promise.all(discovered.map((skill) => toSkillItem(skill)));
75
+ }
76
+
77
+ function errorMessage(error: unknown): string {
78
+ return error instanceof Error ? error.message : String(error);
44
79
  }
45
80
 
46
81
  export async function loader({ request }: LoaderFunctionArgs) {
82
+ const mock = isMockMode();
47
83
  try {
84
+ if (mock) {
85
+ const db = await getDb();
86
+ const storedSources = await readSettingsJson<typeof DEFAULT_CATALOG_SOURCES>(db, SOURCES_KEY, DEFAULT_CATALOG_SOURCES);
87
+ return json({
88
+ skills: DEFAULT_SKILLS,
89
+ catalogSources: Array.isArray(storedSources) && storedSources.length > 0 ? storedSources : DEFAULT_CATALOG_SOURCES,
90
+ catalogSkills: DEFAULT_CATALOG_SKILLS,
91
+ root: null,
92
+ isMock: true,
93
+ });
94
+ }
95
+
48
96
  const db = await getDb();
49
97
  const url = new URL(request.url);
50
- const includeNative = url.searchParams.get('native') === '1' || !isMockMode();
51
- const mock = isMockMode();
98
+ const scope = await resolveDiscoveryScope(url.searchParams.get('root'), url.searchParams.get('scope'));
52
99
 
53
- const skills = await skillsStore.read(db);
54
100
  const storedSources = await readSettingsJson<typeof DEFAULT_CATALOG_SOURCES>(db, SOURCES_KEY, DEFAULT_CATALOG_SOURCES);
55
101
  const catalogSources = Array.isArray(storedSources) && storedSources.length > 0 ? storedSources : DEFAULT_CATALOG_SOURCES;
56
102
 
57
- const mergedSkills = includeNative && !mock ? await mergeSkills(skills) : skills;
58
-
59
- let catalogSkills = DEFAULT_CATALOG_SKILLS;
60
- if (!mock) {
61
- const [popular, curated] = await Promise.all([
62
- searchSkillCatalog('popular', 18),
63
- searchSkillCatalog('code review', 8),
64
- ]);
65
- const seen = new Set<string>();
66
- catalogSkills = toCatalogSkills([...popular, ...curated], catalogSources[0]?.id ?? 'skills-sh')
67
- .filter((item) => (seen.has(item.repoTag) ? false : (seen.add(item.repoTag), true)));
68
- }
103
+ const [skills, popular, curated] = await Promise.all([
104
+ listSkills(scope),
105
+ searchSkillCatalog('popular', 18),
106
+ searchSkillCatalog('code review', 8),
107
+ ]);
69
108
 
109
+ const seen = new Set<string>();
110
+ const catalogSkills = toCatalogSkills([...popular, ...curated], catalogSources[0]?.id ?? 'skills-sh')
111
+ .filter((item) => (seen.has(item.repoTag) ? false : (seen.add(item.repoTag), true)));
112
+
113
+ return json({ skills, catalogSources, catalogSkills, root: scope.workspace, isMock: false });
114
+ } catch (error) {
70
115
  return json({
71
- skills: mergedSkills,
72
- catalogSources,
73
- catalogSkills,
74
- isMock: mock,
75
- });
76
- } catch (error: any) {
77
- const mock = isMockMode();
78
- return json({
79
- error: error.message,
80
- skills: mock ? DEFAULT_SKILLS : [],
116
+ error: errorMessage(error),
117
+ skills: [],
81
118
  catalogSources: DEFAULT_CATALOG_SOURCES,
82
119
  catalogSkills: DEFAULT_CATALOG_SKILLS,
120
+ root: null,
83
121
  isMock: mock,
84
122
  }, { status: 500 });
85
123
  }
86
124
  }
87
125
 
88
126
  export async function action({ request, params }: ActionFunctionArgs) {
127
+ const mock = isMockMode();
89
128
  try {
90
129
  const db = await getDb();
130
+ const url = new URL(request.url);
91
131
 
92
132
  if (request.method === 'DELETE') {
93
- const url = new URL(request.url);
133
+ const scope = await resolveDiscoveryScope(url.searchParams.get('root'), url.searchParams.get('scope'));
94
134
  const id = url.searchParams.get('id');
95
135
  if (!id) return json({ error: 'id is required' }, { status: 400 });
96
- if (id.startsWith('omp-')) {
97
- return json({ error: 'Native omp skills are managed on disk' }, { status: 403 });
98
- }
136
+ if (mock) return json({ success: true, skills: DEFAULT_SKILLS });
99
137
 
100
- const list = await skillsStore.remove(db, id);
101
- return json({ success: true, skills: list });
138
+ const removed = await deleteSkillDir(id, scope.workspace);
139
+ if (!removed) return json({ error: 'Skill not found' }, { status: 404 });
140
+ await reloadLiveSessions();
141
+ return json({ success: true, skills: await listSkills(scope) });
102
142
  }
103
143
 
104
- if (request.method === 'POST' || request.method === 'PUT') {
105
- const body = await request.json();
106
-
107
- // Toggle model invocation on a native skill file (frontmatter edit).
108
- if (body.type === 'toggle_model_invocation' && typeof body.skillId === 'string' && body.skillId.startsWith('omp-')) {
109
- const [root, sourceRoot, ...nameParts] = body.skillId.split('-');
110
- void root;
111
- const skill = (await discoverNativeSkills()).find(
112
- (s) => s.name === nameParts.join('-') && s.sourceRoot === sourceRoot,
113
- );
114
- if (!skill) return json({ error: 'Skill not found' }, { status: 404 });
115
- const changed = await setSkillModelInvocation(skill.filePath, body.disable === true);
116
- return json({ success: changed, disable: body.disable === true });
117
- }
144
+ if (request.method !== 'POST' && request.method !== 'PUT') {
145
+ return methodNotAllowed({ request, params });
146
+ }
118
147
 
119
- if (body.type === 'add_source') {
120
- const sources = await readSettingsJson<typeof DEFAULT_CATALOG_SOURCES>(db, SOURCES_KEY, DEFAULT_CATALOG_SOURCES);
121
- const updated = [...sources, body.source];
122
- await writeSettingsJson(db, SOURCES_KEY, updated);
123
- return json({ success: true, catalogSources: updated });
124
- }
148
+ const body = await request.json();
149
+ // The root may ride on the query (a plain GET-shaped read) or in the body
150
+ // (a POST that carries the draft), so a write is scoped either way.
151
+ const scope = await resolveDiscoveryScope(
152
+ url.searchParams.get('root') ?? (typeof body.root === 'string' ? body.root : null),
153
+ url.searchParams.get('scope') ?? (typeof body.scope === 'string' ? body.scope : null),
154
+ );
155
+
156
+ // Toggle model invocation: the skill stays runnable, it just drops out of
157
+ // the model's skill listing.
158
+ if (body.type === 'toggle_model_invocation') {
159
+ if (mock) return json({ success: false });
160
+ const target = (await discoverSkills(scope.cwd, scope.workspace)).find((skill) => skill.id === body.skillId);
161
+ if (!target) return json({ error: 'Skill not found' }, { status: 404 });
162
+ const changed = await setSkillModelInvocation(target.filePath, body.disable === true);
163
+ if (changed) await reloadLiveSessions();
164
+ return json({ success: changed, skills: await listSkills(scope) });
165
+ }
125
166
 
126
- if (body.type === 'install_toggle') {
127
- const { skill, install } = body;
128
- let skills = await skillsStore.read(db);
129
-
130
- if (install) {
131
- // Catalog skill with a package tag installs via the real skills.sh CLI.
132
- if (typeof skill.repoTag === 'string' && /^[\w.\-]+\/[\w.\-@:]+$/.test(skill.repoTag)) {
133
- await installCatalogSkill(skill.repoTag);
134
- }
135
- if (!skills.some(s => s.name === skill.name)) {
136
- skills.push({
137
- id: `skill-${Date.now()}`,
138
- name: skill.name,
139
- description: skill.description,
140
- location: 'user',
141
- locationLabel: 'User / OMP agent',
142
- instructions: skill.instructions || `Autonomous guidelines for ${skill.name}. Adhere strictly to skill instructions.`,
143
- project: 'omp',
144
- isInstalledFromCatalog: true,
145
- catalogSource: skill.catalogSource ?? skill.sourceId,
146
- });
147
- }
148
- } else {
149
- skills = skills.filter(s => s.name !== skill.name);
150
- }
167
+ if (body.type === 'reload') {
168
+ if (mock) return json({ success: true, reloaded: [] });
169
+ const reloaded = await reloadLiveSessions();
170
+ return json({ success: true, reloaded });
171
+ }
151
172
 
152
- await skillsStore.write(db, skills);
153
- return json({ success: true, skills: isMockMode() ? skills : await mergeSkills(skills) });
154
- }
173
+ if (body.type === 'add_source') {
174
+ const sources = await readSettingsJson<typeof DEFAULT_CATALOG_SOURCES>(db, SOURCES_KEY, DEFAULT_CATALOG_SOURCES);
175
+ const updated = [...sources, body.source];
176
+ await writeSettingsJson(db, SOURCES_KEY, updated);
177
+ return json({ success: true, catalogSources: updated });
178
+ }
179
+
180
+ // Catalog install/uninstall goes through the real skills CLI, which is what
181
+ // actually puts the skill where omp reads it.
182
+ if (body.type === 'install_toggle') {
183
+ const { skill, install } = body;
184
+ if (mock) return json({ success: true, skills: DEFAULT_SKILLS });
155
185
 
156
- // Catalog skill POST installs via the skills.sh CLI: the component
157
- // echoes the package back in instructions (and may include repoTag).
158
- if (body.skill) {
159
- const pkg = typeof body.skill.repoTag === 'string' && /^[\w.\-]+\/[\w.\-@:]+$/.test(body.skill.repoTag)
160
- ? body.skill.repoTag
161
- : (typeof body.skill.instructions === 'string' && /^[\w.\-]+\/[\w.\-@:]+$/.test(body.skill.instructions) ? body.skill.instructions : null);
162
- if (!isMockMode() && pkg) await installCatalogSkill(pkg);
186
+ if (install) {
187
+ if (typeof skill.repoTag === 'string' && /^[\w.\-]+\/[\w.\-@:]+$/.test(skill.repoTag)) {
188
+ await installCatalogSkill(skill.repoTag, typeof skill.name === 'string' ? skill.name : null);
189
+ }
190
+ } else if (typeof skill.name === 'string' && skill.name.trim()) {
191
+ await uninstallCatalogSkill(skill.name.trim());
163
192
  }
193
+ await reloadLiveSessions();
194
+ return json({ success: true, skills: await listSkills(scope) });
195
+ }
164
196
 
165
- const updatedSkills = await skillsStore.upsert(db, body);
166
- await skillsStore.write(db, updatedSkills);
197
+ // Create or update a chamber-managed SKILL.md.
198
+ if (body.skill) {
199
+ if (mock) return json({ success: true, skills: DEFAULT_SKILLS });
200
+
201
+ const draft = body.skill as Partial<SkillItem>;
202
+ const target = draft.location === 'project' ? 'project' : 'user';
203
+ const existing = typeof draft.id === 'string'
204
+ ? (await discoverSkills(scope.cwd, scope.workspace)).find((skill) => skill.id === draft.id)
205
+ : undefined;
206
+ if (existing && !existing.managed) {
207
+ return json({ error: `"${existing.name}" is managed by ${existing.source} and cannot be edited here` }, { status: 403 });
208
+ }
167
209
 
168
- return json({ success: true, skills: isMockMode() ? updatedSkills : mergeSkills(updatedSkills) });
210
+ await writeSkillFile({
211
+ scope: target,
212
+ projectDir: scope.workspace,
213
+ previousDir: existing?.baseDir ?? null,
214
+ name: draft.name ?? '',
215
+ description: draft.description ?? '',
216
+ hidden: draft.hidden === true,
217
+ body: typeof draft.instructions === 'string' ? draft.instructions : '',
218
+ });
219
+ await reloadLiveSessions();
220
+ return json({ success: true, skills: await listSkills(scope) });
169
221
  }
170
222
 
171
- return methodNotAllowed({ request, params });
172
- } catch (error: any) {
173
- return json({ error: error.message }, { status: 500 });
223
+ return json({ error: 'Unsupported skill action' }, { status: 400 });
224
+ } catch (error) {
225
+ return json({ error: errorMessage(error) }, { status: 500 });
174
226
  }
175
227
  }
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { json, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
17
- import { listTerminals } from '@/server/lib/terminal/runtime.server';
17
+ import { listTerminals } from '@/server/lib/terminal/liveness.server';
18
18
 
19
19
  export async function loader({ request }: LoaderFunctionArgs) {
20
20
  const url = new URL(request.url);
@@ -1,25 +1,112 @@
1
- import { json } from '@/server/lib/remix-compat';
2
- import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
1
+ import { json, type ActionFunctionArgs } from '@/server/lib/remix-compat';
3
2
  import { methodNotAllowed } from '@/server/lib/route-adapter';
3
+ import { scheduleSelfRestart } from '@/server/lib/lifecycle/restart';
4
+ import { createSseStream } from '@/server/lib/sse';
4
5
  import { applyUpdate } from '@/server/lib/updates/apply';
6
+ import { runningUpdate, updateBusyMessage } from '@/server/lib/updates/single-flight';
7
+
8
+ /** Longest a buffered output chunk waits before it is flushed anyway. */
9
+ const FLUSH_DELAY_MS = 50;
5
10
 
6
11
  /**
7
12
  * POST /api/updates/apply — body `{ target: 'omp' | 'ompchamber' }`.
8
- * Unknown targets are a 400; unexpected failures are a 500.
13
+ *
14
+ * Answers with an SSE stream rather than one JSON body: the update runs for
15
+ * minutes and prints as it goes, and a buffered response showed the user a
16
+ * spinner and nothing else until it finished. Frames:
17
+ *
18
+ * `line` — raw command output
19
+ * `stage` — a step heading
20
+ * `result` — the UpdateApplyResult, last before the stream closes
21
+ *
22
+ * A non-SSE answer is still possible and still meaningful: an unknown target or
23
+ * a malformed body is a plain JSON error with its own status, because there is
24
+ * no run to report on.
9
25
  */
10
26
  export async function action({ request, params }: ActionFunctionArgs) {
11
- if (request.method !== 'POST') {
12
- return methodNotAllowed({ request, params });
13
- }
27
+ if (request.method !== 'POST') return methodNotAllowed({ request, params });
28
+
29
+ let target: unknown;
14
30
  try {
15
- const body = (await request.json()) as { target?: unknown };
16
- const target = body.target;
17
- if (target === 'omp' || target === 'ompchamber') {
18
- return json(await applyUpdate(target));
19
- }
31
+ target = ((await request.json()) as { target?: unknown }).target;
32
+ } catch {
33
+ return json({ error: 'Request body is not valid JSON' }, { status: 400 });
34
+ }
35
+ if (target !== 'omp' && target !== 'ompchamber') {
20
36
  return json({ error: 'Unknown update target' }, { status: 400 });
21
- } catch (error) {
22
- const message = error instanceof Error ? error.message : 'Failed to apply update';
23
- return json({ error: message }, { status: 500 });
24
37
  }
38
+
39
+ // Checked before the stream exists, because after it starts the status is
40
+ // already 200 and a refusal could only be reported as a failure frame. The
41
+ // slot itself is taken inside `applyUpdate` — this is the clean answer for
42
+ // the ordinary case (a reload, a second tab), not the guard.
43
+ const running = runningUpdate();
44
+ if (running) return json({ error: updateBusyMessage(running) }, { status: 409 });
45
+
46
+ const stream = createSseStream({
47
+ heartbeatMs: 15_000,
48
+ signal: request.signal,
49
+ onStart: async (handlers) => {
50
+ // Output arrives in arbitrary chunks, so a small buffer collapses a burst
51
+ // of writes into one frame without splitting a line in half.
52
+ let pending = '';
53
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
54
+
55
+ const flush = () => {
56
+ if (flushTimer !== null) {
57
+ clearTimeout(flushTimer);
58
+ flushTimer = null;
59
+ }
60
+ if (!pending || handlers.isClosed()) return;
61
+ const chunk = pending;
62
+ pending = '';
63
+ handlers.send('line', chunk);
64
+ };
65
+
66
+ try {
67
+ const { result, updated } = await applyUpdate(target, {
68
+ onLine: (chunk) => {
69
+ if (handlers.isClosed()) return;
70
+ pending += chunk;
71
+ if (flushTimer === null) flushTimer = setTimeout(flush, FLUSH_DELAY_MS);
72
+ },
73
+ onStage: (label) => {
74
+ if (handlers.isClosed()) return;
75
+ flush();
76
+ handlers.send('stage', label);
77
+ },
78
+ });
79
+
80
+ flush();
81
+ // A run that replaced files leaves this process serving the build it
82
+ // just replaced. `scheduleSelfRestart` ARMS the detached restart on its
83
+ // own grace timer (`RESTART_GRACE_MS`) — long enough for the frames
84
+ // below to reach the browser — and it is the only place that knows
85
+ // whether this instance is one the CLI can restart at all. Its plan is
86
+ // folded into the message before the result is sent: "updated" over a
87
+ // server still on the old build would mislead.
88
+ if (target === 'ompchamber' && result.success && updated) {
89
+ const plan = scheduleSelfRestart();
90
+ result.message = `${result.message} ${plan.message}`;
91
+ }
92
+ handlers.send('result', result);
93
+ } catch (err) {
94
+ flush();
95
+ handlers.send('result', {
96
+ success: false,
97
+ target,
98
+ manual: false,
99
+ message: err instanceof Error ? err.message : 'Failed to apply the update',
100
+ });
101
+ }
102
+
103
+ if (flushTimer !== null) {
104
+ clearTimeout(flushTimer);
105
+ flushTimer = null;
106
+ }
107
+ handlers.close();
108
+ },
109
+ });
110
+
111
+ return stream.response;
25
112
  }
@@ -3,9 +3,9 @@ import { CHAMBER_COMMANDS } from '@/shared/lib/chat/composer/trigger';
3
3
 
4
4
  const CACHE_TTL_MS = 300_000;
5
5
 
6
- /** Cache keys: `'agent'`, `'command'`, and `'file::<root>'` per workspace root. */
6
+ /** Cache keys: `'agent'`, `'command::<root>'`, and `'file::<root>'` per workspace root. */
7
7
  const AGENTS_KEY = 'agent';
8
- const COMMANDS_KEY = 'command';
8
+ const commandKey = (root: string): string => `command::${root}`;
9
9
  const fileKey = (root: string): string => `file::${root}`;
10
10
 
11
11
  interface CacheEntry {
@@ -161,34 +161,37 @@ async function loadFileItems(root: string | null): Promise<ComposerPickItem[]> {
161
161
  }
162
162
 
163
163
  /** Load command + skill items, deduping concurrent callers and caching 5 minutes. */
164
- async function loadCommandItems(): Promise<ComposerPickItem[]> {
165
- const cached = cache.get(COMMANDS_KEY);
164
+ async function loadCommandItems(root: string | null): Promise<ComposerPickItem[]> {
165
+ const key = commandKey(root ?? '');
166
+ const cached = cache.get(key);
166
167
  if (cached && cached.expiresAt > Date.now()) return cached.data;
167
168
 
168
- const existing = inFlight.get(COMMANDS_KEY);
169
+ const existing = inFlight.get(key);
169
170
  if (existing) return existing;
170
171
 
171
172
  const pending = (async () => {
172
173
  try {
174
+ const query = root ? `?root=${encodeURIComponent(root)}` : '';
173
175
  const [commands, skills] = await Promise.all([
174
- fetchJson<{ commands: CommandItem[] }>('/api/settings/commands'),
175
- fetchJson<{ skills: SkillItem[] }>('/api/settings/skills'),
176
+ fetchJson<{ commands: CommandItem[] }>(`/api/settings/commands${query}`),
177
+ fetchJson<{ skills: SkillItem[] }>(`/api/settings/skills${query}`),
176
178
  ]);
177
179
  const items = mergeCommandAndSkillItems(commands.commands, skills.skills);
178
- cache.set(COMMANDS_KEY, { data: items, expiresAt: Date.now() + CACHE_TTL_MS });
180
+ cache.set(key, { data: items, expiresAt: Date.now() + CACHE_TTL_MS });
179
181
  return items;
180
182
  } finally {
181
- inFlight.delete(COMMANDS_KEY);
183
+ inFlight.delete(key);
182
184
  }
183
185
  })();
184
186
 
185
- inFlight.set(COMMANDS_KEY, pending);
187
+ inFlight.set(key, pending);
186
188
  return pending;
187
189
  }
188
190
 
189
191
  /**
190
192
  * Load pick items for a trigger kind. `mention` merges agents (first) and
191
- * workspace files; `command` merges commands and skills.
193
+ * workspace files; `command` merges commands and skills, both scoped to `root`
194
+ * so a workspace's own `.omp/commands` and `.omp/skills` are offered.
192
195
  */
193
196
  export function loadComposerItems(
194
197
  kind: ComposerPickKind,
@@ -199,7 +202,7 @@ export function loadComposerItems(
199
202
  ([agents, files]) => [...agents, ...files],
200
203
  );
201
204
  }
202
- return loadCommandItems();
205
+ return loadCommandItems(root ?? null);
203
206
  }
204
207
 
205
208
  /** Load agent names via the shared agent cache. Never throws. */
@@ -212,13 +215,19 @@ export async function loadAgentNames(): Promise<string[]> {
212
215
  }
213
216
  }
214
217
 
215
- /** Clear the cache (and in-flight dedupe) for one key or all keys. */
218
+ /** Clear the cache (and in-flight dedupe) for one key or all keys. A key with
219
+ * per-root variants (`command::<root>`) is matched by its prefix, so a caller
220
+ * that names the logical kind clears every workspace's copy. */
216
221
  export function invalidateComposerCache(key?: string): void {
217
- if (key) {
218
- cache.delete(key);
219
- inFlight.delete(key);
222
+ if (!key) {
223
+ cache.clear();
224
+ inFlight.clear();
220
225
  return;
221
226
  }
222
- cache.clear();
223
- inFlight.clear();
227
+ for (const cacheKey of [...cache.keys()]) {
228
+ if (cacheKey === key || cacheKey.startsWith(`${key}::`)) cache.delete(cacheKey);
229
+ }
230
+ for (const inFlightKey of [...inFlight.keys()]) {
231
+ if (inFlightKey === key || inFlightKey.startsWith(`${key}::`)) inFlight.delete(inFlightKey);
232
+ }
224
233
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * The composer's command pool is WORKSPACE-scoped: omp resolves project
6
+ * commands and project skills from the process's cwd, so a request that omits
7
+ * the root answers with `$HOME`'s inventory and the popup silently misses the
8
+ * workspace's own `.omp/commands` and `.omp/skills` (measured: 7 skill entries
9
+ * for $HOME against 9 for this repository). The cache is keyed per root for the
10
+ * same reason — one shared `command` key served the previous workspace's list
11
+ * after a switch.
12
+ */
13
+
14
+ import { describe, expect, test, beforeEach } from 'bun:test';
15
+
16
+ import { invalidateComposerCache, loadComposerItems } from '@/shared/lib/chat/composer/client';
17
+
18
+ const realFetch = globalThis.fetch;
19
+ let requested: string[] = [];
20
+
21
+ function stubFetch(): void {
22
+ requested = [];
23
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
24
+ const url = typeof input === 'string' ? input : input.toString();
25
+ requested.push(url);
26
+ const body = url.includes('/commands') ? { commands: [] } : { skills: [] };
27
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
28
+ }) as typeof fetch;
29
+ }
30
+
31
+ beforeEach(() => {
32
+ invalidateComposerCache();
33
+ stubFetch();
34
+ });
35
+
36
+ describe('loadComposerItems workspace scope', () => {
37
+ test('scopes both command sources to the given root', async () => {
38
+ await loadComposerItems('command', '/ws/one');
39
+
40
+ expect(requested.some((url) => url === '/api/settings/commands?root=%2Fws%2Fone')).toBe(true);
41
+ expect(requested.some((url) => url === '/api/settings/skills?root=%2Fws%2Fone')).toBe(true);
42
+ });
43
+
44
+ test('omits the query when no root is known', async () => {
45
+ await loadComposerItems('command', null);
46
+
47
+ expect(requested).toContain('/api/settings/commands');
48
+ expect(requested).toContain('/api/settings/skills');
49
+ expect(requested.every((url) => !url.includes('root='))).toBe(true);
50
+ });
51
+
52
+ test('does not serve one workspace from another workspace cache entry', async () => {
53
+ await loadComposerItems('command', '/ws/one');
54
+ const afterFirst = requested.length;
55
+
56
+ await loadComposerItems('command', '/ws/two');
57
+
58
+ expect(requested.length).toBeGreaterThan(afterFirst);
59
+ expect(requested.some((url) => url === '/api/settings/commands?root=%2Fws%2Ftwo')).toBe(true);
60
+ });
61
+
62
+ test('a repeated read is served from the cache', async () => {
63
+ await loadComposerItems('command', '/ws/one');
64
+ const afterFirst = requested.length;
65
+
66
+ await loadComposerItems('command', '/ws/one');
67
+
68
+ expect(requested.length).toBe(afterFirst);
69
+ });
70
+
71
+ test('invalidating the logical kind clears every workspace entry', async () => {
72
+ await loadComposerItems('command', '/ws/one');
73
+ await loadComposerItems('command', '/ws/two');
74
+ const afterBoth = requested.length;
75
+
76
+ invalidateComposerCache('command');
77
+ await loadComposerItems('command', '/ws/one');
78
+ await loadComposerItems('command', '/ws/two');
79
+
80
+ expect(requested.length).toBeGreaterThan(afterBoth);
81
+ });
82
+ });
83
+
84
+ // Restore the real fetch so a later test file in the same process is unaffected.
85
+ process.once('exit', () => {
86
+ globalThis.fetch = realFetch;
87
+ });