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
@@ -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
- USER SKILLS
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
+ }
@@ -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
- let dbPromise: Promise<DbClient> | null = null;
25
- /** Resolved handle cached by getDb(); promise callbacks never run sync, so
26
- * hot paths unwrap through `getDbSync` instead of awaiting. */
27
- let dbResolved: DbClient | null = null;
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
- if (dbPromise) return dbPromise;
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
- dbPromise = (async () => {
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
- dbResolved = db;
104
+ state.resolved = db;
76
105
  return db;
77
106
  })();
78
107
 
79
- return dbPromise;
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
- if (!dbResolved) throw new Error('Database not initialized — call getDb() first');
90
- return dbResolved;
118
+ const resolved = slot().resolved;
119
+ if (!resolved) throw new Error('Database not initialized — call getDb() first');
120
+ return resolved;
91
121
  }
@@ -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) => {