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
|
@@ -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 {
|
|
9
|
-
import {
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
25
|
-
*
|
|
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
|
|
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
|
|
34
|
-
instructions
|
|
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
|
-
/**
|
|
40
|
-
async function
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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 (
|
|
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
|
|
101
|
-
return json({
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
} catch (error
|
|
173
|
-
return json({ error: error
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
|
|
13
|
-
|
|
27
|
+
if (request.method !== 'POST') return methodNotAllowed({ request, params });
|
|
28
|
+
|
|
29
|
+
let target: unknown;
|
|
14
30
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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(
|
|
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[] }>(
|
|
175
|
-
fetchJson<{ skills: SkillItem[] }>(
|
|
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(
|
|
180
|
+
cache.set(key, { data: items, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
179
181
|
return items;
|
|
180
182
|
} finally {
|
|
181
|
-
inFlight.delete(
|
|
183
|
+
inFlight.delete(key);
|
|
182
184
|
}
|
|
183
185
|
})();
|
|
184
186
|
|
|
185
|
-
inFlight.set(
|
|
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.
|
|
219
|
-
inFlight.
|
|
222
|
+
if (!key) {
|
|
223
|
+
cache.clear();
|
|
224
|
+
inFlight.clear();
|
|
220
225
|
return;
|
|
221
226
|
}
|
|
222
|
-
cache.
|
|
223
|
-
|
|
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
|
+
});
|