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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from 'preact/hooks';
|
|
2
2
|
import { ChevronDown, ChevronRight, Plus } from 'lucide-preact';
|
|
3
3
|
import type { SkillItem } from '@/shared/types';
|
|
4
|
-
import { ProjectSelectorDropdown } from '@/client/components/settings/ProjectSelectorDropdown';
|
|
4
|
+
import { ProjectSelectorDropdown, type ProjectOption } from '@/client/components/settings/ProjectSelectorDropdown';
|
|
5
5
|
|
|
6
6
|
interface SkillSidebarListProps {
|
|
7
7
|
skills: SkillItem[];
|
|
@@ -11,6 +11,8 @@ interface SkillSidebarListProps {
|
|
|
11
11
|
onAddNewSkill: () => void;
|
|
12
12
|
selectedProject: string;
|
|
13
13
|
onSelectProject: (project: string) => void;
|
|
14
|
+
/** Real workspace folders; defaults to the shared placeholder list. */
|
|
15
|
+
projectOptions?: ProjectOption[];
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function SkillSidebarList({
|
|
@@ -21,6 +23,7 @@ export function SkillSidebarList({
|
|
|
21
23
|
onAddNewSkill,
|
|
22
24
|
selectedProject,
|
|
23
25
|
onSelectProject,
|
|
26
|
+
projectOptions,
|
|
24
27
|
}: SkillSidebarListProps) {
|
|
25
28
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
|
26
29
|
|
|
@@ -55,8 +58,9 @@ export function SkillSidebarList({
|
|
|
55
58
|
variant="compact"
|
|
56
59
|
selectedProject={selectedProject}
|
|
57
60
|
onChangeProject={onSelectProject}
|
|
61
|
+
{...(projectOptions ? { options: projectOptions } : {})}
|
|
58
62
|
getTriggerLabel={(options, value) =>
|
|
59
|
-
options.find((option) => option.id === value)?.label || value
|
|
63
|
+
options.find((option) => option.id === value || option.value === value)?.label || value
|
|
60
64
|
}
|
|
61
65
|
/>
|
|
62
66
|
|
|
@@ -77,8 +81,10 @@ export function SkillSidebarList({
|
|
|
77
81
|
|
|
78
82
|
{/* Skills List */}
|
|
79
83
|
<div className="flex-1 scrollbar-overlay-container scrollbar-overlay-static p-1.5 space-y-2">
|
|
84
|
+
{/* The picker above names the scope; the list is exactly that scope's
|
|
85
|
+
skills, so a second scope claim here would only be able to lie. */}
|
|
80
86
|
<div className="px-2 pt-1 pb-0.5 text-[10px] font-bold text-ink/50 uppercase tracking-wider">
|
|
81
|
-
|
|
87
|
+
SKILLS
|
|
82
88
|
</div>
|
|
83
89
|
|
|
84
90
|
{/* Temporary "New Skill" indicator item */}
|
|
@@ -7,8 +7,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
7
7
|
name: 'init',
|
|
8
8
|
description: 'guided AGENTS.md setup',
|
|
9
9
|
scope: 'system',
|
|
10
|
-
overrideAgent: 'Not selected',
|
|
11
|
-
overrideModel: 'Not selected',
|
|
12
10
|
template: `Initialize AGENTS.md instructions for this repository following guided prompts. Context: $ARGUMENTS`,
|
|
13
11
|
isBuiltIn: true,
|
|
14
12
|
},
|
|
@@ -17,8 +15,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
17
15
|
name: 'review',
|
|
18
16
|
description: 'review changes [commit|branch|working]',
|
|
19
17
|
scope: 'system',
|
|
20
|
-
overrideAgent: 'code-reviewer',
|
|
21
|
-
overrideModel: 'Not selected',
|
|
22
18
|
template: `Review the current changes in $ARGUMENTS and summarize bugs, formatting issues, and test considerations.\n!\`git diff --stat\``,
|
|
23
19
|
isBuiltIn: true,
|
|
24
20
|
},
|
|
@@ -28,8 +24,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
28
24
|
name: 'debugging',
|
|
29
25
|
description: 'MUST USE for any real runtime debugging session',
|
|
30
26
|
scope: 'user',
|
|
31
|
-
overrideAgent: 'coder',
|
|
32
|
-
overrideModel: 'Not selected',
|
|
33
27
|
template: `Analyze the runtime error log and isolate the root failure:\n!\`bun run test\`\nFix the issue and verify: $ARGUMENTS`,
|
|
34
28
|
},
|
|
35
29
|
{
|
|
@@ -37,8 +31,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
37
31
|
name: 'frontend',
|
|
38
32
|
description: 'MUST USE for frontend/web UI/UX development',
|
|
39
33
|
scope: 'user',
|
|
40
|
-
overrideAgent: 'frontend-specialist',
|
|
41
|
-
overrideModel: 'Not selected',
|
|
42
34
|
template: `Implement clean responsive UI component following Tailwind and theme variable specifications.\nTarget file: @$ARGUMENTS`,
|
|
43
35
|
},
|
|
44
36
|
{
|
|
@@ -46,8 +38,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
46
38
|
name: 'git-master',
|
|
47
39
|
description: 'MUST USE for ANY git operations and commit conventions',
|
|
48
40
|
scope: 'user',
|
|
49
|
-
overrideAgent: 'git-sync',
|
|
50
|
-
overrideModel: 'Not selected',
|
|
51
41
|
template: `Inspect git status and generate clean conventional commit message:\n!\`git status -s\`\nDetails: $ARGUMENTS`,
|
|
52
42
|
},
|
|
53
43
|
{
|
|
@@ -55,8 +45,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
55
45
|
name: 'goal',
|
|
56
46
|
description: '(builtin) Set, show, pause, resume session goal',
|
|
57
47
|
scope: 'user',
|
|
58
|
-
overrideAgent: 'Sisyphus - ultraworker',
|
|
59
|
-
overrideModel: 'Not selected',
|
|
60
48
|
template: `Manage current goal state: $ARGUMENTS`,
|
|
61
49
|
},
|
|
62
50
|
{
|
|
@@ -64,8 +52,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
64
52
|
name: 'handoff',
|
|
65
53
|
description: '(builtin) Create a detailed context handoff report',
|
|
66
54
|
scope: 'user',
|
|
67
|
-
overrideAgent: 'general',
|
|
68
|
-
overrideModel: 'Not selected',
|
|
69
55
|
template: `Summarize work in progress, architectural decisions, and open items for handoff to next agent session.\n$ARGUMENTS`,
|
|
70
56
|
},
|
|
71
57
|
{
|
|
@@ -73,8 +59,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
73
59
|
name: 'hyperplan',
|
|
74
60
|
description: '(builtin) Adversarial multi-agent plan builder',
|
|
75
61
|
scope: 'user',
|
|
76
|
-
overrideAgent: 'Prometheus - Plan Builder',
|
|
77
|
-
overrideModel: 'Not selected',
|
|
78
62
|
template: `Construct a detailed plan with review gates and adversarial critiques for $ARGUMENTS.`,
|
|
79
63
|
},
|
|
80
64
|
{
|
|
@@ -82,8 +66,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
82
66
|
name: 'init-deep',
|
|
83
67
|
description: '(builtin) Initialize hierarchical AGENTS.md setup',
|
|
84
68
|
scope: 'user',
|
|
85
|
-
overrideAgent: 'architect',
|
|
86
|
-
overrideModel: 'Not selected',
|
|
87
69
|
template: `Analyze directory structure and construct domain-specific AGENTS.md files for subpackages: $ARGUMENTS`,
|
|
88
70
|
},
|
|
89
71
|
{
|
|
@@ -91,8 +73,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
91
73
|
name: 'playwright',
|
|
92
74
|
description: 'MUST USE for any browser-related testing',
|
|
93
75
|
scope: 'user',
|
|
94
|
-
overrideAgent: 'e2e-runner',
|
|
95
|
-
overrideModel: 'Not selected',
|
|
96
76
|
template: `Run playwright tests for scenario: $ARGUMENTS\n!\`bunx playwright test\``,
|
|
97
77
|
},
|
|
98
78
|
{
|
|
@@ -100,8 +80,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
100
80
|
name: 'pty-open-background-spy',
|
|
101
81
|
description: 'Open PTY Sessions Web Interface',
|
|
102
82
|
scope: 'user',
|
|
103
|
-
overrideAgent: 'devops-runner',
|
|
104
|
-
overrideModel: 'Not selected',
|
|
105
83
|
template: `Launch PTY background session monitor with target: $ARGUMENTS`,
|
|
106
84
|
},
|
|
107
85
|
{
|
|
@@ -109,8 +87,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
109
87
|
name: 'pty-show-server-url',
|
|
110
88
|
description: 'Show PTY Sessions Web Interface URL',
|
|
111
89
|
scope: 'user',
|
|
112
|
-
overrideAgent: 'devops-runner',
|
|
113
|
-
overrideModel: 'Not selected',
|
|
114
90
|
template: `Output the active PTY session web socket endpoint and UI URL.`,
|
|
115
91
|
},
|
|
116
92
|
{
|
|
@@ -118,8 +94,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
118
94
|
name: 'refactor',
|
|
119
95
|
description: '(builtin) Intelligent refactoring coordinator',
|
|
120
96
|
scope: 'user',
|
|
121
|
-
overrideAgent: 'refactor-pro',
|
|
122
|
-
overrideModel: 'Not selected',
|
|
123
97
|
template: `Refactor module @$ARGUMENTS into decoupled single-responsibility files without changing public behavior. Keep files under 350 lines.`,
|
|
124
98
|
},
|
|
125
99
|
{
|
|
@@ -127,8 +101,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
127
101
|
name: 'benchmark',
|
|
128
102
|
description: 'Run performance benchmark suites and record timings',
|
|
129
103
|
scope: 'user',
|
|
130
|
-
overrideAgent: 'performance-tuner',
|
|
131
|
-
overrideModel: 'Not selected',
|
|
132
104
|
template: `Execute benchmark suite on target: $ARGUMENTS`,
|
|
133
105
|
},
|
|
134
106
|
{
|
|
@@ -136,8 +108,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
136
108
|
name: 'bundle-analyze',
|
|
137
109
|
description: 'Inspect vendor bundle size and dynamic chunk splitting',
|
|
138
110
|
scope: 'user',
|
|
139
|
-
overrideAgent: 'bundle-optimizer',
|
|
140
|
-
overrideModel: 'Not selected',
|
|
141
111
|
template: `Run build analyzer and report chunk weight statistics: !\`bun run build\``,
|
|
142
112
|
},
|
|
143
113
|
{
|
|
@@ -145,8 +115,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
145
115
|
name: 'db-migrate',
|
|
146
116
|
description: 'Review and execute database migrations safely',
|
|
147
117
|
scope: 'user',
|
|
148
|
-
overrideAgent: 'database-analyzer',
|
|
149
|
-
overrideModel: 'Not selected',
|
|
150
118
|
template: `Review pending schema migrations and verify backward compatibility: $ARGUMENTS`,
|
|
151
119
|
},
|
|
152
120
|
{
|
|
@@ -154,8 +122,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
154
122
|
name: 'doc-generate',
|
|
155
123
|
description: 'Generate API documentation and usage guides',
|
|
156
124
|
scope: 'user',
|
|
157
|
-
overrideAgent: 'doc-writer',
|
|
158
|
-
overrideModel: 'Not selected',
|
|
159
125
|
template: `Draft comprehensive technical documentation for module @$ARGUMENTS`,
|
|
160
126
|
},
|
|
161
127
|
{
|
|
@@ -163,8 +129,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
163
129
|
name: 'security-scan',
|
|
164
130
|
description: 'Run static security analysis and check dependency vulnerabilities',
|
|
165
131
|
scope: 'user',
|
|
166
|
-
overrideAgent: 'security-auditor',
|
|
167
|
-
overrideModel: 'Not selected',
|
|
168
132
|
template: `Scan repository for leaked secrets and outdated dependencies: !\`npm audit\``,
|
|
169
133
|
},
|
|
170
134
|
{
|
|
@@ -172,8 +136,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
172
136
|
name: 'test-unit',
|
|
173
137
|
description: 'Run unit test suite with coverage report',
|
|
174
138
|
scope: 'user',
|
|
175
|
-
overrideAgent: 'test-engineer',
|
|
176
|
-
overrideModel: 'Not selected',
|
|
177
139
|
template: `Run test suite with coverage: !\`bun test\` - Focus: $ARGUMENTS`,
|
|
178
140
|
},
|
|
179
141
|
{
|
|
@@ -181,8 +143,6 @@ export const DEFAULT_COMMANDS_LIST: CommandItem[] = [
|
|
|
181
143
|
name: 'typecheck',
|
|
182
144
|
description: 'Run strict TypeScript compiler verification',
|
|
183
145
|
scope: 'user',
|
|
184
|
-
overrideAgent: 'lint-fixer',
|
|
185
|
-
overrideModel: 'Not selected',
|
|
186
146
|
template: `Run strict typecheck without emitting files: !\`bunx tsc --noEmit\``,
|
|
187
147
|
},
|
|
188
148
|
];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
7
|
+
import type { ProjectOption } from '@/client/components/settings/ProjectSelectorDropdown';
|
|
8
|
+
|
|
9
|
+
interface ProjectConfigItem {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
path?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WorkspaceRoots {
|
|
16
|
+
options: ProjectOption[];
|
|
17
|
+
/** Workspace root for a selected option id, or null for the user scope. */
|
|
18
|
+
rootFor: (optionId: string) => string | null;
|
|
19
|
+
/** Query string (with leading `?`) for a read scoped to this option. */
|
|
20
|
+
queryFor: (optionId: string) => string;
|
|
21
|
+
/** Extra body fields scoping a write to this option. */
|
|
22
|
+
scopeBodyFor: (optionId: string) => { root?: string; scope?: string };
|
|
23
|
+
/** True when this option is a real workspace (the project scope exists). */
|
|
24
|
+
isWorkspace: (optionId: string) => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Option id for the user-level (no workspace) scope. */
|
|
28
|
+
export const GLOBAL_SCOPE_ID = 'global';
|
|
29
|
+
|
|
30
|
+
const USER_OPTION: ProjectOption = {
|
|
31
|
+
id: GLOBAL_SCOPE_ID,
|
|
32
|
+
name: 'global',
|
|
33
|
+
label: 'User (all projects)',
|
|
34
|
+
value: '',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The workspace folders the skills/commands panels can be scoped to.
|
|
39
|
+
*
|
|
40
|
+
* These are the real registered folders (`/api/settings/projects`), not the
|
|
41
|
+
* hardcoded placeholder list the panels used to offer: a panel scoped to a
|
|
42
|
+
* made-up project name sent a root the server could not resolve, so the picker
|
|
43
|
+
* silently did nothing. The `value` of each option is the folder's project
|
|
44
|
+
* path, which is what the API takes as `?root=`.
|
|
45
|
+
*
|
|
46
|
+
* The USER scope is `?scope=user` rather than "no root": omp resolves
|
|
47
|
+
* project-scope skills and commands from its process cwd, so an unscoped read
|
|
48
|
+
* answered for whatever directory the server happened to run in. `scope=user`
|
|
49
|
+
* pins that read to the agent dir, which is the only cwd whose inventory is
|
|
50
|
+
* the user's own.
|
|
51
|
+
*/
|
|
52
|
+
export function useWorkspaceRoots(): WorkspaceRoots {
|
|
53
|
+
const [options, setOptions] = useState<ProjectOption[]>([USER_OPTION]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
let active = true;
|
|
57
|
+
fetch('/api/settings/projects')
|
|
58
|
+
.then((res) => res.json())
|
|
59
|
+
.then((data: { projects?: ProjectConfigItem[] }) => {
|
|
60
|
+
if (!active) return;
|
|
61
|
+
const folders = (data.projects ?? [])
|
|
62
|
+
.filter((project) => typeof project.path === 'string' && project.path)
|
|
63
|
+
.map((project) => ({
|
|
64
|
+
id: project.id,
|
|
65
|
+
name: project.name,
|
|
66
|
+
label: project.name,
|
|
67
|
+
value: project.path as string,
|
|
68
|
+
}));
|
|
69
|
+
setOptions([...folders, USER_OPTION]);
|
|
70
|
+
})
|
|
71
|
+
.catch((error) => console.error('Failed to load workspace folders:', error));
|
|
72
|
+
return () => {
|
|
73
|
+
active = false;
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// The dropdown reports the option's VALUE (a project path) while a panel may
|
|
78
|
+
// hold either that or the option id, so both resolve to the same root. An
|
|
79
|
+
// id-only lookup silently answered the user scope for every workspace.
|
|
80
|
+
const rootFor = (optionId: string): string | null => {
|
|
81
|
+
const option = options.find((candidate) => candidate.id === optionId || candidate.value === optionId);
|
|
82
|
+
const value = option?.value ?? '';
|
|
83
|
+
return value || null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const queryFor = (optionId: string): string => {
|
|
87
|
+
const root = rootFor(optionId);
|
|
88
|
+
return root ? `?root=${encodeURIComponent(root)}` : '?scope=user';
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const scopeBodyFor = (optionId: string): { root?: string; scope?: string } => {
|
|
92
|
+
const root = rootFor(optionId);
|
|
93
|
+
return root ? { root } : { scope: 'user' };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const isWorkspace = (optionId: string): boolean => rootFor(optionId) !== null;
|
|
97
|
+
|
|
98
|
+
return { options, rootFor, queryFor, scopeBodyFor, isWorkspace };
|
|
99
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live update state: the check that reports what is available, and the apply
|
|
3
|
+
* that runs it.
|
|
4
|
+
*
|
|
5
|
+
* The apply is a POST whose response is an SSE stream, not a JSON body. An
|
|
6
|
+
* `omp update` or an OMPChamber install runs for minutes; a buffered response
|
|
7
|
+
* left the console with a spinner and nothing else until it finished, and
|
|
8
|
+
* discarded the command output the server had already collected. Every frame
|
|
9
|
+
* the stream carries is folded into `progress`, which survives the modal being
|
|
10
|
+
* closed — the hook lives on the sidebar, so leaving the dialog no longer hides
|
|
11
|
+
* the run.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
15
|
+
import { readSseStream } from '@/shared/lib/chat/read-sse';
|
|
16
|
+
import { appendUpdateLog, type UpdateLogState } from '@/shared/lib/updates/progress';
|
|
17
|
+
import type { UpdateApplyResult, UpdateCheckResult, UpdateTarget } from '@/shared/types/updates';
|
|
18
|
+
|
|
19
|
+
export interface UseUpdatesResult {
|
|
20
|
+
info: UpdateCheckResult | null;
|
|
21
|
+
checking: boolean;
|
|
22
|
+
applying: UpdateTarget | null;
|
|
23
|
+
/** Log of the run in flight (or the last one), null before any apply. */
|
|
24
|
+
progress: UpdateLogState | null;
|
|
25
|
+
error: string | null;
|
|
26
|
+
hasUpdate: boolean;
|
|
27
|
+
check: () => Promise<void>;
|
|
28
|
+
apply: (target: UpdateTarget) => Promise<UpdateApplyResult | null>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function responseMessage(payload: unknown, fallback: string): string {
|
|
32
|
+
if (typeof payload !== 'object' || payload === null) return fallback;
|
|
33
|
+
|
|
34
|
+
if ('error' in payload && typeof payload.error === 'string') return payload.error;
|
|
35
|
+
if ('message' in payload && typeof payload.message === 'string') return payload.message;
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isUpdateApplyResult(payload: unknown): payload is UpdateApplyResult {
|
|
40
|
+
if (typeof payload !== 'object' || payload === null) return false;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
'success' in payload && typeof payload.success === 'boolean' &&
|
|
44
|
+
'target' in payload && (payload.target === 'ompchamber' || payload.target === 'omp') &&
|
|
45
|
+
'manual' in payload && typeof payload.manual === 'boolean' &&
|
|
46
|
+
'message' in payload && typeof payload.message === 'string'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useUpdates(): UseUpdatesResult {
|
|
51
|
+
const [info, setInfo] = useState<UpdateCheckResult | null>(null);
|
|
52
|
+
const [checking, setChecking] = useState(false);
|
|
53
|
+
const [applying, setApplying] = useState<UpdateTarget | null>(null);
|
|
54
|
+
const [progress, setProgress] = useState<UpdateLogState | null>(null);
|
|
55
|
+
const [error, setError] = useState<string | null>(null);
|
|
56
|
+
const inFlightCheckRef = useRef<Promise<void> | null>(null);
|
|
57
|
+
const didAutoCheckRef = useRef(false);
|
|
58
|
+
const applyAbortRef = useRef<AbortController | null>(null);
|
|
59
|
+
|
|
60
|
+
const check = useCallback(() => {
|
|
61
|
+
if (inFlightCheckRef.current) return inFlightCheckRef.current;
|
|
62
|
+
|
|
63
|
+
const request = (async () => {
|
|
64
|
+
setChecking(true);
|
|
65
|
+
setError(null);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch('/api/updates/check');
|
|
69
|
+
const payload: unknown = await response.json();
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error(responseMessage(payload, `Update check failed (HTTP ${response.status})`));
|
|
72
|
+
}
|
|
73
|
+
setInfo(payload as UpdateCheckResult);
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
setError(cause instanceof Error ? cause.message : 'Unable to check for updates.');
|
|
76
|
+
} finally {
|
|
77
|
+
setChecking(false);
|
|
78
|
+
inFlightCheckRef.current = null;
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
inFlightCheckRef.current = request;
|
|
83
|
+
return request;
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const apply = useCallback(async (target: UpdateTarget): Promise<UpdateApplyResult | null> => {
|
|
87
|
+
setApplying(target);
|
|
88
|
+
setProgress({ lines: [], pending: '', truncated: false });
|
|
89
|
+
setError(null);
|
|
90
|
+
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
applyAbortRef.current = controller;
|
|
93
|
+
// The fold is mirrored in a local so each frame is one setState, rather
|
|
94
|
+
// than a read-modify-write against the previous render's state.
|
|
95
|
+
let log: UpdateLogState = { lines: [], pending: '', truncated: false };
|
|
96
|
+
let result: UpdateApplyResult | null = null;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch('/api/updates/apply', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
102
|
+
body: JSON.stringify({ target }),
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!response.ok && !(response.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
|
107
|
+
const payload: unknown = await response.json().catch(() => null);
|
|
108
|
+
const message = responseMessage(payload, `Update failed (HTTP ${response.status})`);
|
|
109
|
+
// 409 is the server saying another run holds the slot. That is a state,
|
|
110
|
+
// not a failure of this request, so it is reported by toast alone: an
|
|
111
|
+
// error state would replace the update rows with a block the user
|
|
112
|
+
// cannot act on, and its Retry only re-checks.
|
|
113
|
+
if (response.status === 409) return { success: false, target, manual: false, message };
|
|
114
|
+
throw new Error(message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await readSseStream(
|
|
118
|
+
response,
|
|
119
|
+
(event, data) => {
|
|
120
|
+
if (event === 'line') {
|
|
121
|
+
log = appendUpdateLog(log, JSON.parse(data) as string);
|
|
122
|
+
setProgress(log);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (event === 'stage') {
|
|
126
|
+
// A stage is its own line: the updater's heading and the output
|
|
127
|
+
// under it must not be glued into one line.
|
|
128
|
+
log = appendUpdateLog(log, `${JSON.parse(data) as string}\n`);
|
|
129
|
+
setProgress(log);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (event === 'result') {
|
|
133
|
+
const payload: unknown = JSON.parse(data);
|
|
134
|
+
if (isUpdateApplyResult(payload)) result = payload;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
controller.signal,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (!controller.signal.aborted) await check();
|
|
141
|
+
return result;
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
if (controller.signal.aborted) return null;
|
|
144
|
+
const message = cause instanceof Error ? cause.message : 'Unable to apply the update.';
|
|
145
|
+
setError(message);
|
|
146
|
+
// Returned rather than swallowed: the caller's toast is the only surface
|
|
147
|
+
// the user sees, and a generic "could not be completed" would hide a
|
|
148
|
+
// refusal the server explained — an update already running, in the case
|
|
149
|
+
// that matters most, because a reload has just erased this panel's own
|
|
150
|
+
// record that one was.
|
|
151
|
+
return { success: false, target, manual: false, message };
|
|
152
|
+
} finally {
|
|
153
|
+
if (applyAbortRef.current === controller) {
|
|
154
|
+
applyAbortRef.current = null;
|
|
155
|
+
setApplying(null);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, [check]);
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (didAutoCheckRef.current) return;
|
|
162
|
+
didAutoCheckRef.current = true;
|
|
163
|
+
void check();
|
|
164
|
+
}, [check]);
|
|
165
|
+
|
|
166
|
+
useEffect(() => () => applyAbortRef.current?.abort(), []);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
info,
|
|
170
|
+
checking,
|
|
171
|
+
applying,
|
|
172
|
+
progress,
|
|
173
|
+
error,
|
|
174
|
+
hasUpdate: Boolean(info?.ompchamber.updateAvailable || info?.omp.updateAvailable),
|
|
175
|
+
check,
|
|
176
|
+
apply,
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/server/db.server.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* Database lifecycle for OMPChamber: resolve the SQLite path, open the
|
|
8
8
|
* `bun:sqlite` client once, bootstrap the schema, and seed demo data in mock
|
|
9
9
|
* mode. The DDL lives in `./db/schema`, the mock presets in `./db/seed` and
|
|
10
|
-
* the workspace discovery sync in `./db/workspace-sync`.
|
|
10
|
+
* the workspace discovery sync in `./db/workspace-sync`. The one-time overlay
|
|
11
|
+
* rewrite is NOT here: it is a server-lifecycle concern (`compact-chat-overlay`)
|
|
12
|
+
* and runs from the server bootstrap, so opening a handle can never trigger it.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import path from 'path';
|
|
@@ -21,10 +23,33 @@ import { isMockMode } from '@/server/mock.server';
|
|
|
21
23
|
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
22
24
|
import { migrateLegacyProjectSettings } from '@/shared/lib/workspace/project-settings-migration';
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The handle and its promise live on `globalThis`, not in module bindings.
|
|
28
|
+
*
|
|
29
|
+
* `bun run --hot` re-evaluates this module on every server-file edit while the
|
|
30
|
+
* process lives on, so a module-level cache is reset each time and the previous
|
|
31
|
+
* `Database` is dropped without ever being closed: measured on Bun 1.4.2 /
|
|
32
|
+
* macOS, each reload of this file left two more descriptors behind (`db.sqlite`
|
|
33
|
+
* and `db.sqlite-wal`), which is 2 per edit toward the 10,240-descriptor point
|
|
34
|
+
* where every `Bun.spawn` in the process starts failing with `EBADF`. The slot
|
|
35
|
+
* keeps ONE handle per process — the same reason the terminal registry, the
|
|
36
|
+
* flock host and the provider cache are anchored here.
|
|
37
|
+
*/
|
|
38
|
+
interface DbSlot {
|
|
39
|
+
/** In-flight open, so concurrent `getDb()` callers share one handle. */
|
|
40
|
+
promise: Promise<DbClient> | null;
|
|
41
|
+
/** Resolved handle for `getDbSync`; hot paths must not await. */
|
|
42
|
+
resolved: DbClient | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
declare global {
|
|
46
|
+
// eslint-disable-next-line no-var
|
|
47
|
+
var __ompChamberDb: DbSlot | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function slot(): DbSlot {
|
|
51
|
+
return (globalThis.__ompChamberDb ??= { promise: null, resolved: null });
|
|
52
|
+
}
|
|
28
53
|
|
|
29
54
|
export async function getDatabasePath(): Promise<string> {
|
|
30
55
|
if (isMockMode()) {
|
|
@@ -51,9 +76,13 @@ export async function getDatabasePath(): Promise<string> {
|
|
|
51
76
|
}
|
|
52
77
|
|
|
53
78
|
export async function getDb(): Promise<DbClient> {
|
|
54
|
-
|
|
79
|
+
const state = slot();
|
|
80
|
+
if (state.promise) return state.promise;
|
|
81
|
+
// A reload keeps the previous generation's handle: the schema was already
|
|
82
|
+
// initialized in this process, and re-opening would leak the old one.
|
|
83
|
+
if (state.resolved) return state.resolved;
|
|
55
84
|
|
|
56
|
-
|
|
85
|
+
state.promise = (async () => {
|
|
57
86
|
const dbPath = await getDatabasePath();
|
|
58
87
|
const db = createDb(dbPath);
|
|
59
88
|
|
|
@@ -72,11 +101,11 @@ export async function getDb(): Promise<DbClient> {
|
|
|
72
101
|
|
|
73
102
|
await migrateLegacyProjectSettings(db);
|
|
74
103
|
|
|
75
|
-
|
|
104
|
+
state.resolved = db;
|
|
76
105
|
return db;
|
|
77
106
|
})();
|
|
78
107
|
|
|
79
|
-
return
|
|
108
|
+
return state.promise;
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
/**
|
|
@@ -86,6 +115,7 @@ export async function getDb(): Promise<DbClient> {
|
|
|
86
115
|
* use `getDb()`.
|
|
87
116
|
*/
|
|
88
117
|
export function getDbSync(): DbClient {
|
|
89
|
-
|
|
90
|
-
|
|
118
|
+
const resolved = slot().resolved;
|
|
119
|
+
if (!resolved) throw new Error('Database not initialized — call getDb() first');
|
|
120
|
+
return resolved;
|
|
91
121
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -4,7 +4,8 @@ import shell from '@/../index.html';
|
|
|
4
4
|
import { apiRoutes } from '@/server/routes';
|
|
5
5
|
import { ssrRoutes } from '@/server/plugins/ssr';
|
|
6
6
|
import { setListener } from '@/server/lib/lifecycle/listener';
|
|
7
|
-
import { getDatabasePath } from '@/server/db.server';
|
|
7
|
+
import { getDatabasePath, getDb } from '@/server/db.server';
|
|
8
|
+
import { compactChatOverlayRows } from '@/server/lib/db/compact-chat-overlay';
|
|
8
9
|
import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
|
|
9
10
|
import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
|
|
10
11
|
import { readInstanceRecord, removeInstanceRecord, writeInstanceRecord } from '@/server/lib/lifecycle/instance';
|
|
@@ -106,6 +107,21 @@ await logDetectedRegistry();
|
|
|
106
107
|
|
|
107
108
|
console.log(`[ompchamber] listening on http://${host}:${port}`);
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* One-time rewrite of the overlay rows written before `overlayRowsForOmpSession`
|
|
112
|
+
* existed — see the module. Deliberately HERE, after the banner, and NOT inside
|
|
113
|
+
* `getDb()`: the test suite and the CLI reach `getDb()` in real mode too, and
|
|
114
|
+
* merely opening the database must not be able to launch a rewrite of every
|
|
115
|
+
* mirrored conversation. (It could: a `bun test` run compacted a live database.)
|
|
116
|
+
* The pass is marker-keyed, so from the second start it is one indexed SELECT,
|
|
117
|
+
* and it reports its own per-row failures rather than throwing.
|
|
118
|
+
*/
|
|
119
|
+
try {
|
|
120
|
+
await compactChatOverlayRows(await getDb());
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error('[chat-overlay] compaction skipped:', error);
|
|
123
|
+
}
|
|
124
|
+
|
|
109
125
|
/**
|
|
110
126
|
* What omp reports it can actually run. A live RPC round-trip: a cold utility
|
|
111
127
|
* process takes seconds, which is why it runs after the listener is up. Side
|
|
@@ -21,10 +21,27 @@ export interface DbClient {
|
|
|
21
21
|
* reusable, and the app issues thousands of small queries per request cycle
|
|
22
22
|
* (sidebar scans, telemetry probes, file listings).
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* How long a blocked write waits for another connection to release the lock.
|
|
26
|
+
*
|
|
27
|
+
* The chamber database is shared: a second chamber on another port writes the
|
|
28
|
+
* same file, the CLI writes it, and a maintenance `VACUUM` takes the exclusive
|
|
29
|
+
* lock outright. Without this, a write that meets any of them fails on the spot
|
|
30
|
+
* with `SQLITE_BUSY: database is locked` — which is exactly what a server log
|
|
31
|
+
* from 2026-09-25 is full of, with no writer at fault.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately modest: `bun:sqlite` is synchronous, so this wait blocks the
|
|
34
|
+
* whole event loop — every request, stream and terminal frame with it. Two
|
|
35
|
+
* seconds clears every realistic holder (a full `VACUUM` of this database
|
|
36
|
+
* measured 134 ms) without freezing the app for long when a lock is stuck.
|
|
37
|
+
*/
|
|
38
|
+
const BUSY_TIMEOUT_MS = 2000;
|
|
39
|
+
|
|
24
40
|
export function createDb(path: string): DbClient {
|
|
25
41
|
const raw = new Database(path, { create: true });
|
|
26
42
|
raw.exec('PRAGMA journal_mode = WAL');
|
|
27
43
|
raw.exec('PRAGMA foreign_keys = ON');
|
|
44
|
+
raw.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
28
45
|
|
|
29
46
|
const stmts = new Map<string, ReturnType<Database['query']>>();
|
|
30
47
|
const prep = (sql: string) => {
|