ompchamber 0.4.0 → 0.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 (94) hide show
  1. package/README.md +56 -7
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/{index.5291f58223.css → index.51f68210a5.css} +1 -1
  4. package/dist/client/static/js/async/{3y.ededeebde8.js → 3y.4c2596f64c.js} +3 -3
  5. package/dist/client/static/js/async/73.308dff4da8.js +1 -0
  6. package/dist/client/static/js/async/{ty.3d93cf5f5f.js → 7g.a7814350c3.js} +1 -1
  7. package/dist/client/static/js/async/a.bce4542392.js +1 -0
  8. package/dist/client/static/js/async/dc.6dc4b0a04b.js +2 -0
  9. package/dist/client/static/js/async/g.8515cbc6f7.js +2 -0
  10. package/dist/client/static/js/async/hn.b645041e00.js +1 -0
  11. package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
  12. package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
  13. package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
  14. package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
  15. package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
  16. package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
  17. package/dist/client/static/js/async/t.cda4891526.js +1 -0
  18. package/dist/client/static/js/async/tr.e878658140.js +309 -0
  19. package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
  20. package/dist/client/static/js/{index.bf1b1fe988.js → index.47d3b04d5f.js} +7 -7
  21. package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
  22. package/package.json +13 -6
  23. package/src/cli/lib/args.js +7 -6
  24. package/src/cli/lib/commands/logs.js +76 -34
  25. package/src/cli/lib/commands/restart.js +30 -9
  26. package/src/cli/lib/commands/serve.js +19 -4
  27. package/src/cli/lib/commands/status.js +40 -18
  28. package/src/cli/lib/commands/stop.js +12 -7
  29. package/src/cli/lib/commands/update.js +2 -5
  30. package/src/cli/lib/paths.js +8 -28
  31. package/src/cli/lib/runtime.js +118 -108
  32. package/src/client/components/common/code-editor/index.tsx +5 -1
  33. package/src/client/components/common/code-surface/index.tsx +49 -1
  34. package/src/client/components/common/code-surface/measure.ts +99 -0
  35. package/src/client/components/layout/RightActivityBar.tsx +42 -69
  36. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
  37. package/src/client/components/layout/desktop-layout/index.tsx +11 -9
  38. package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
  39. package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
  40. package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
  41. package/src/client/components/mobile/RightSidebar.tsx +42 -119
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
  43. package/src/client/components/workspace/editor/index.tsx +4 -1
  44. package/src/client/components/workspace/file-explorer/TreeItem.tsx +8 -2
  45. package/src/client/components/workspace/file-explorer/index.tsx +6 -0
  46. package/src/client/data/models/catalog.ts +8 -0
  47. package/src/client/data/samples/dialogue-session.ts +1 -1
  48. package/src/client/data/samples/sample-tools/other.ts +1 -1
  49. package/src/client/hooks/chat/timeline/send.ts +1 -1
  50. package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
  51. package/src/client/hooks/editor/use-file-editor.ts +35 -19
  52. package/src/server/index.ts +104 -1
  53. package/src/server/lib/lifecycle/identity.ts +108 -0
  54. package/src/server/lib/lifecycle/instance.ts +102 -0
  55. package/src/server/lib/lifecycle/occupant.ts +101 -0
  56. package/src/server/lib/lifecycle/paths.ts +50 -0
  57. package/src/server/lib/lifecycle/port-guard.ts +170 -0
  58. package/src/server/lib/lifecycle/probe.ts +121 -0
  59. package/src/server/lib/models/provider-registry.server.ts +44 -20
  60. package/src/server/lib/omp/core/cli.ts +3 -2
  61. package/src/server/lib/omp/core/startup.ts +64 -0
  62. package/src/server/lib/omp/rpc/process.ts +1 -1
  63. package/src/server/mock.server.ts +6 -12
  64. package/src/server/plugins/static.test.ts +63 -0
  65. package/src/server/plugins/static.ts +28 -4
  66. package/src/server/routes/chat/stream.ts +13 -30
  67. package/src/server/routes/health/probe.ts +8 -0
  68. package/src/server/routes/models/root.ts +5 -1
  69. package/src/shared/lib/chat/stream-service.ts +0 -76
  70. package/src/shared/lib/fs/paths.ts +19 -0
  71. package/src/shared/lib/markdown/mermaid.ts +8 -0
  72. package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
  73. package/src/shared/lib/workspace/panel-widths.ts +23 -10
  74. package/src/shared/lib/workspace/right-panels.ts +10 -4
  75. package/dist/client/static/js/async/73.50280ce58d.js +0 -1
  76. package/dist/client/static/js/async/a.e05da304c8.js +0 -10
  77. package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
  78. package/dist/client/static/js/async/art.62139ed479.js +0 -2
  79. package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
  80. package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
  81. package/dist/client/static/js/async/l.102a97fce6.js +0 -104
  82. package/dist/client/static/js/async/l6.c56640c205.js +0 -1
  83. package/dist/client/static/js/async/nk.6de011832e.js +0 -1
  84. package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
  85. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  86. package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
  87. /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
  88. /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → dc.6dc4b0a04b.js.LICENSE.txt} +0 -0
  89. /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.8515cbc6f7.js.LICENSE.txt} +0 -0
  90. /package/dist/client/static/js/async/{art.62139ed479.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
  91. /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
  92. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
  93. /package/dist/client/static/js/{index.bf1b1fe988.js.LICENSE.txt → index.47d3b04d5f.js.LICENSE.txt} +0 -0
  94. /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useFileEditor` revalidation contract.
8
+ *
9
+ * A landed write bumps the workspace refreshKey, which calls `reset()`. Reset
10
+ * used to drop every cached buffer, so the very next render painted an empty
11
+ * editor — and because the read effect only re-runs on a key/identity change,
12
+ * the file never came back without a full page reload. The same window let a
13
+ * read response that was already in flight land on top of keystrokes typed
14
+ * after it was requested.
15
+ *
16
+ * These run against a real DOM (happy-dom) because the hook is a Preact hook
17
+ * driving fetches and effects: the modules below are imported inside
18
+ * `beforeAll`, after those globals exist, because Preact and the highlighter
19
+ * bind their environment at evaluation time.
20
+ */
21
+
22
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
23
+ import { Window } from 'happy-dom';
24
+ import type { h as PreactH, render as PreactRender } from 'preact';
25
+ import type { act as PreactAct } from 'preact/test-utils';
26
+ import type { UseFileEditorResult, useFileEditor as UseFileEditorHook } from '@/client/hooks/editor/use-file-editor';
27
+
28
+ const DISK = 'const a = 1;\nconst b = 2;\n';
29
+ /** Stable identity: the read effect re-runs when its target object changes. */
30
+ const TARGET = { id: 1, name: 'a.js', path: 'a.js' };
31
+
32
+ let useFileEditor: typeof UseFileEditorHook;
33
+ let h: typeof PreactH;
34
+ let render: typeof PreactRender;
35
+ let act: typeof PreactAct;
36
+
37
+ /** Latest render's result — re-read after every settle, never held across one. */
38
+ let mounted: UseFileEditorResult | null = null;
39
+ let container: HTMLElement;
40
+
41
+ interface FsState {
42
+ reads: number;
43
+ writes: number;
44
+ body: string;
45
+ }
46
+
47
+ /** Serves `state.body` for reads and counts writes, without touching the network. */
48
+ function stubFetch(state: FsState) {
49
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
50
+ if (String(input).startsWith('/api/fs/read')) {
51
+ state.reads += 1;
52
+ return { ok: true, json: async () => ({ content: state.body }) };
53
+ }
54
+ state.writes += 1;
55
+ state.body = String((init?.body as FormData).get('content'));
56
+ return { ok: true, json: async () => ({ success: true }) };
57
+ }) as unknown as typeof fetch;
58
+ }
59
+
60
+ /**
61
+ * Lets the stub's promise chain run and the render it queues land. The chain
62
+ * needs no timers (nothing I/O bound), and Preact's `act` only flushes queued
63
+ * renders on its boundary, so both get a pass.
64
+ */
65
+ async function settleEditor() {
66
+ for (let i = 0; i < 20; i++) await Promise.resolve();
67
+ await act(async () => {
68
+ for (let i = 0; i < 20; i++) await Promise.resolve();
69
+ });
70
+ }
71
+
72
+ async function mountEditor() {
73
+ const Probe = () => {
74
+ mounted = useFileEditor(TARGET);
75
+ return null;
76
+ };
77
+ await act(async () => {
78
+ render(h(Probe, {}), container);
79
+ });
80
+ await settleEditor();
81
+ }
82
+
83
+ /** The DOM surface this file installs for the hook and Preact to render into. */
84
+ function domGlobals(win: Window): Record<string, unknown> {
85
+ return {
86
+ window: win,
87
+ document: win.document,
88
+ navigator: win.navigator,
89
+ Node: win.Node,
90
+ Element: win.Element,
91
+ HTMLElement: win.HTMLElement,
92
+ };
93
+ }
94
+
95
+ const installed: Record<string, unknown> = {};
96
+ const displaced: Record<string, unknown> = {};
97
+
98
+ beforeAll(async () => {
99
+ const win = new Window({ url: 'http://localhost' });
100
+ for (const [key, value] of Object.entries(domGlobals(win))) {
101
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
102
+ installed[key] = value;
103
+ (globalThis as Record<string, unknown>)[key] = value;
104
+ }
105
+ ({ useFileEditor } = await import('@/client/hooks/editor/use-file-editor'));
106
+ ({ h, render } = await import('preact'));
107
+ ({ act } = await import('preact/test-utils'));
108
+ });
109
+
110
+ // Every test file shares one process, so the globals go back the way they were:
111
+ // code that branches on `typeof window` (the agent-event fold, for one) would
112
+ // otherwise take its browser path for the rest of the run.
113
+ afterAll(() => {
114
+ for (const key of Object.keys(installed)) {
115
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
116
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
117
+ }
118
+ });
119
+
120
+ beforeEach(() => {
121
+ mounted = null;
122
+ container = document.createElement('div');
123
+ document.body.appendChild(container);
124
+ });
125
+
126
+ describe('useFileEditor revalidation', () => {
127
+ test('reset re-reads from disk without blanking the open file', async () => {
128
+ const state: FsState = { reads: 0, writes: 0, body: DISK };
129
+ stubFetch(state);
130
+ await mountEditor();
131
+ expect(mounted?.content).toBe(DISK);
132
+
133
+ // The refresh a landed write triggers, with newer bytes now on disk.
134
+ state.body = `${DISK}const c = 3;\n`;
135
+ await act(async () => {
136
+ mounted?.reset();
137
+ });
138
+ await settleEditor();
139
+
140
+ expect(state.reads).toBe(2);
141
+ expect(mounted?.content).toBe(state.body);
142
+ });
143
+
144
+ test('a read that lands mid-typing does not replace the buffer', async () => {
145
+ const state: FsState = { reads: 0, writes: 0, body: DISK };
146
+ stubFetch(state);
147
+ await mountEditor();
148
+
149
+ await act(async () => {
150
+ mounted?.onChange('edited by hand');
151
+ mounted?.reset();
152
+ });
153
+ await settleEditor();
154
+
155
+ expect(state.reads).toBe(2);
156
+ expect(mounted?.content).toBe('edited by hand');
157
+ });
158
+ });
@@ -40,9 +40,11 @@ export interface UseFileEditorResult {
40
40
  saveNow: () => void;
41
41
  copy: () => void;
42
42
  download: () => void;
43
- /** Forget cached content for the active file and read it again. */
44
- reload: () => void;
45
- /** Forget all cached content (e.g. a workspace refresh). */
43
+ /**
44
+ * Re-read every cached file from disk (e.g. a workspace refresh). Content
45
+ * already on screen stays until the fresh bytes land, and a file with
46
+ * unsaved edits is left alone.
47
+ */
46
48
  reset: () => void;
47
49
  /** Drop cached content for files no longer open. */
48
50
  retain: (ids: Array<number | string>) => void;
@@ -77,6 +79,8 @@ export function useFileEditor(
77
79
  const [loadError, setLoadError] = useState<string | null>(null);
78
80
  const [saveStatus, setSaveStatus] = useState<FileEditorSaveStatus>('idle');
79
81
  const [copied, setCopied] = useState(false);
82
+ /** Bumped by `reset()` to re-run the read effect for the active file. */
83
+ const [revision, setRevision] = useState(0);
80
84
 
81
85
  const key = target ? targetKey(target) : '';
82
86
  const content = target ? (contents[key] ?? target.content ?? '') : '';
@@ -87,6 +91,14 @@ export function useFileEditor(
87
91
  const loadedRef = useRef<Set<string>>(new Set());
88
92
  const pendingRef = useRef<{ timer: number; file: FileEditorTarget; content: string } | null>(null);
89
93
 
94
+ // Live mirrors of the content maps: a read response resolves long after it
95
+ // was requested, and it has to know whether the buffer it read is still the
96
+ // one on screen.
97
+ const contentsRef = useRef(contents);
98
+ contentsRef.current = contents;
99
+ const baselinesRef = useRef(baselines);
100
+ baselinesRef.current = baselines;
101
+
90
102
  const remember = useCallback((fileKey: string, text: string) => {
91
103
  setContents((prev) => ({ ...prev, [fileKey]: text }));
92
104
  setBaselines((prev) => ({ ...prev, [fileKey]: text }));
@@ -145,8 +157,11 @@ export function useFileEditor(
145
157
  return;
146
158
  }
147
159
 
160
+ // Only a key with nothing on screen yet shows the loading state: a
161
+ // re-read (refreshKey / reset) must not flash a spinner over live content.
162
+ const firstLoad = contentsRef.current[fileKey] === undefined;
148
163
  let cancelled = false;
149
- setIsLoading(true);
164
+ if (firstLoad) setIsLoading(true);
150
165
  setLoadError(null);
151
166
  const params = new URLSearchParams({ path: target.path });
152
167
  if (target.root) params.set('root', target.root);
@@ -164,10 +179,20 @@ export function useFileEditor(
164
179
  })
165
180
  .then((text) => {
166
181
  if (cancelled) return;
182
+ // These bytes were read before the response arrived, so anything typed
183
+ // since is newer. Applying them would replace those keystrokes (and,
184
+ // once a refresh is triggered by a save, wipe the editor back to an
185
+ // empty buffer) — instead let the queued write own the key.
186
+ const typed = contentsRef.current[fileKey];
187
+ if (typed !== undefined && typed !== baselinesRef.current[fileKey]) return;
188
+ const queued = pendingRef.current;
189
+ if (queued && targetKey(queued.file) === fileKey) return;
167
190
  remember(fileKey, text ?? (fallback ? fallback(target.name) : ''));
168
191
  })
169
192
  .catch((err: unknown) => {
170
193
  if (cancelled) return;
194
+ // A failed re-read keeps what is on screen; only a first load reports.
195
+ if (!firstLoad) return;
171
196
  if (reportLoadError) {
172
197
  remember(fileKey, '');
173
198
  setLoadError(err instanceof Error ? err.message : 'Failed to read file');
@@ -182,7 +207,7 @@ export function useFileEditor(
182
207
  return () => {
183
208
  cancelled = true;
184
209
  };
185
- }, [key, target, fallback, reportLoadError, remember]);
210
+ }, [key, target, fallback, reportLoadError, remember, revision]);
186
211
 
187
212
  useEffect(() => {
188
213
  if (saveStatus !== 'saved') return;
@@ -239,21 +264,13 @@ export function useFileEditor(
239
264
  URL.revokeObjectURL(url);
240
265
  }, [target, contents, downloadMimeType]);
241
266
 
242
- const reload = useCallback(() => {
243
- if (!key) return;
244
- loadedRef.current.delete(key);
245
- setContents((prev) => {
246
- if (prev[key] === undefined) return prev;
247
- const next = { ...prev };
248
- delete next[key];
249
- return next;
250
- });
251
- }, [key]);
252
-
253
267
  const reset = useCallback(() => {
268
+ // Invalidate every key, then let the read effect refetch the active one.
269
+ // Content is deliberately NOT cleared: it stays on screen until the fresh
270
+ // bytes land, so a refresh can never blank the editor (or drop the file
271
+ // that is open) while the request is in flight.
254
272
  loadedRef.current.clear();
255
- setContents({});
256
- setBaselines({});
273
+ setRevision((prev) => prev + 1);
257
274
  }, []);
258
275
 
259
276
  const retain = useCallback((ids: Array<number | string>) => {
@@ -295,7 +312,6 @@ export function useFileEditor(
295
312
  saveNow,
296
313
  copy,
297
314
  download,
298
- reload,
299
315
  reset,
300
316
  retain,
301
317
  };
@@ -1,16 +1,119 @@
1
1
  import { Elysia } from 'elysia';
2
+ import pkg from '@/../package.json';
2
3
  import { apiRoutes } from '@/server/routes';
3
4
  import { ssrRoutes } from '@/server/plugins/ssr';
5
+ import { getDatabasePath } from '@/server/db.server';
6
+ import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
7
+ import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
8
+ import { readInstanceRecord, removeInstanceRecord, writeInstanceRecord } from '@/server/lib/lifecycle/instance';
9
+ import { acquirePortLock, claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
10
+ import { isMockMode } from '@/server/mock.server';
11
+
12
+ // Refuse to run without a resolvable omp binary — before the listener opens and
13
+ // before the first request can reach a route that shells out to it. The database
14
+ // is opened lazily by getDb(), so this exits without touching it.
15
+ const startupError = ompStartupError();
16
+ if (startupError) {
17
+ console.error(startupError);
18
+ process.exit(1);
19
+ }
20
+
21
+ // Which omp the chamber drives, which ~/.omp tree it reads and which SQLite file
22
+ // it writes — the paths every session/agent diagnostic traces back to. The data
23
+ // mode comes first because it changes what every later line means.
24
+ console.log(`[ompchamber] mock mode: ${isMockMode() ? 'true (demo presets)' : 'false (real omp data)'}`);
25
+ for (const line of ompStartupLogLines()) console.log(`[ompchamber] ${line}`);
26
+ console.log(`[ompchamber] db: ${await getDatabasePath()}`);
4
27
 
5
28
  const port = Number(Bun.env.PORT) || 3000;
6
29
  const host = Bun.env.HOST || 'localhost';
30
+ const mode = Bun.env.NODE_ENV === 'production' ? 'prod' : 'dev';
31
+ const launchMode = Bun.env.OMPCHAMBER_LAUNCH_MODE === 'daemon' || Bun.env.OMPCHAMBER_LAUNCH_MODE === 'foreground'
32
+ ? Bun.env.OMPCHAMBER_LAUNCH_MODE
33
+ : 'direct';
34
+
35
+ const lock = await acquirePortLock(port);
36
+ if (!lock) {
37
+ console.error(`[ompchamber] another OMPChamber instance is starting on port ${port} right now — retry in a moment.`);
38
+ process.exit(1);
39
+ }
7
40
 
8
41
  const app = new Elysia().use(apiRoutes).use(ssrRoutes);
9
42
 
10
- await app.listen({ port, hostname: host });
43
+ try {
44
+ await claimPort({
45
+ port,
46
+ host,
47
+ listen: async () => {
48
+ // `reusePort: false` is load-bearing: Elysia's Bun adapter hardcodes
49
+ // `reusePort: true`, which lets a second server bind a port already in
50
+ // use and then sit invisible while the first-bound socket takes every
51
+ // connection.
52
+ await app.listen({ port, hostname: host, reusePort: false });
53
+ },
54
+ });
55
+ } catch (error) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ console.error(error instanceof PortInUseError ? `[ompchamber] ${message}` : `[ompchamber] could not listen on ${host}:${port} — ${message}`);
58
+ process.exit(1);
59
+ } finally {
60
+ lock.release();
61
+ }
62
+
63
+ // The record is written by the process that owns the port, so `status`, `stop`
64
+ // and the next `serve` see dev, foreground and daemon servers alike. A hot
65
+ // reload re-runs this entry in the same process: keeping the first `startedAt`
66
+ // for a PID that already recorded this port is what makes `status` report real
67
+ // uptime instead of resetting it on every save.
68
+ const previousRecord = readInstanceRecord(port);
69
+ if (!writeInstanceRecord({
70
+ pid: process.pid,
71
+ port,
72
+ host,
73
+ mode,
74
+ launchMode,
75
+ startedAt: previousRecord?.pid === process.pid ? previousRecord.startedAt : new Date().toISOString(),
76
+ version: pkg.version,
77
+ })) {
78
+ console.log('[ompchamber] instance record not written (data directory not writable) — `ompchamber status` will fall back to probing this port');
79
+ }
80
+ process.on('exit', () => removeInstanceRecord(port));
81
+
82
+ // The listener is already accepting requests here, so the probe only postpones
83
+ // this last banner line, never startup. Printing it after the probe keeps the
84
+ // one line that matters — the URL — at the bottom of the log.
85
+ await logDetectedRegistry();
11
86
 
12
87
  console.log(`[ompchamber] listening on http://${host}:${port}`);
13
88
 
89
+ /**
90
+ * What omp reports it can actually run. A live RPC round-trip: a cold utility
91
+ * process takes seconds, which is why it runs after the listener is up. Side
92
+ * benefit — it warms the shared utility process the model picker drives, so the
93
+ * first /api/models call does not pay the spawn.
94
+ */
95
+ async function logDetectedRegistry(): Promise<void> {
96
+ if (isMockMode()) {
97
+ console.log('[ompchamber] providers: n/a (MOCK mode)');
98
+ console.log('[ompchamber] models: n/a (MOCK mode)');
99
+ return;
100
+ }
101
+ try {
102
+ const { providers, models } = await fetchOmpRegistrySnapshot();
103
+ // Count providers that actually serve a model — the distinct `provider`
104
+ // values in the model list, i.e. the same set GET /api/models groups by.
105
+ // omp's login catalog lists every sign-in offer it supports (75 here, 73 of
106
+ // them without credentials); counting those would report a provider set the
107
+ // chamber cannot use. The catalog size is kept as context.
108
+ const servingProviders = new Set(models.map((model) => model.provider)).size;
109
+ console.log(`[ompchamber] providers: ${servingProviders} detected (${providers.length} known to omp)`);
110
+ console.log(`[ompchamber] models: ${models.length} detected`);
111
+ } catch (error) {
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ console.log(`[ompchamber] providers/models: detection failed — ${message}`);
114
+ }
115
+ }
116
+
14
117
  for (const signal of ['SIGTERM', 'SIGINT'] as const) {
15
118
  process.once(signal, () => {
16
119
  void app.stop();
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Process identity for a PID recorded for a port.
8
+ *
9
+ * Liveness alone cannot answer "is this PID still an OMPChamber server": after
10
+ * an ungraceful exit the PID can be recycled to an unrelated process, and a
11
+ * liveness-only check then reports a live instance nobody is serving — which
12
+ * either blocks startup on a free port or, worse, lets a stale record name a
13
+ * stranger. Identity therefore comes from the OS command line, so a recycled
14
+ * PID reads back as `mismatched`.
15
+ *
16
+ * Where `ps` is unavailable (minimal containers) the on-disk `/proc` entry
17
+ * still answers the identity question on Linux; on Windows, where neither is
18
+ * cheap, identity is reported as `unknown` and callers fall back to liveness.
19
+ */
20
+
21
+ import fs from 'fs';
22
+
23
+ export type ProcessState = 'dead' | 'matched' | 'mismatched' | 'unknown';
24
+
25
+ interface ProcessRow {
26
+ pid: number;
27
+ /** `ps` state column — `Z…` means defunct (exited, awaiting reaping). */
28
+ state: string;
29
+ command: string;
30
+ }
31
+
32
+ let cachedTable: { at: number; rows: ProcessRow[] } | null = null;
33
+
34
+ /**
35
+ * One snapshot of the process table, memoized for a startup decision.
36
+ *
37
+ * Every question in a port check happens within milliseconds, so a briefly
38
+ * cached view is strictly better than spawning `ps` per question.
39
+ */
40
+ function readProcessTable(maxAgeMs = 250): ProcessRow[] {
41
+ if (cachedTable && Date.now() - cachedTable.at < maxAgeMs) return cachedTable.rows;
42
+ const rows: ProcessRow[] = [];
43
+ try {
44
+ const snapshot = Bun.spawnSync({
45
+ cmd: ['ps', '-ax', '-o', 'pid=,stat=,command='],
46
+ stdout: 'pipe',
47
+ stderr: 'ignore',
48
+ });
49
+ if (snapshot.success) {
50
+ for (const line of snapshot.stdout.toString().split('\n')) {
51
+ const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line);
52
+ if (!match) continue;
53
+ rows.push({ pid: Number(match[1]), state: match[2], command: match[3].trim() });
54
+ }
55
+ }
56
+ } catch {
57
+ // No `ps` on PATH: identity falls back to /proc, liveness to signal 0.
58
+ }
59
+ cachedTable = { at: Date.now(), rows };
60
+ return rows;
61
+ }
62
+
63
+ /**
64
+ * Liveness check. Never throws.
65
+ *
66
+ * A defunct (zombie) PID still answers signal 0 — the process has exited and is
67
+ * only waiting for its parent to reap it — so a zombie counts as not alive: it
68
+ * holds no port and will never run again. Without this, a stop that worked
69
+ * looks like a process that "ignored SIGTERM", and stop-waits spin until their
70
+ * timeout.
71
+ */
72
+ export function isProcessAlive(pid: number): boolean {
73
+ if (!Number.isFinite(pid) || pid <= 0) return false;
74
+ try {
75
+ process.kill(pid, 0);
76
+ } catch {
77
+ return false;
78
+ }
79
+ const row = readProcessTable().find((entry) => entry.pid === pid);
80
+ return row ? !row.state.startsWith('Z') : true;
81
+ }
82
+
83
+ /** Command line for a live PID, or null when this platform cannot report one. */
84
+ function readProcessCommandLine(pid: number): string | null {
85
+ const row = readProcessTable().find((entry) => entry.pid === pid);
86
+ if (row) return row.command.length > 0 ? row.command : null;
87
+ if (process.platform === 'linux') {
88
+ try {
89
+ return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim() || null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * `dead` | `matched` | `mismatched` | `unknown` for a PID.
99
+ *
100
+ * `unknown` means identity could not be read; callers treat it as "alive but
101
+ * unverified" — never as confirmation that the PID is ours.
102
+ */
103
+ export function getProcessState(pid: number): ProcessState {
104
+ if (!isProcessAlive(pid)) return 'dead';
105
+ const command = readProcessCommandLine(pid);
106
+ if (command === null) return 'unknown';
107
+ return command.toLowerCase().includes('ompchamber') ? 'matched' : 'mismatched';
108
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The per-port instance record: who is serving this port, in what mode, since
8
+ * when.
9
+ *
10
+ * Written by the server that owns the port (not by the CLI that happened to
11
+ * spawn it), so a server started by `bun run dev`, by `--foreground`, or by the
12
+ * daemon path is discoverable the same way. Writes are best-effort: a read-only
13
+ * data directory must degrade `status` to a probe, never take the server down.
14
+ */
15
+
16
+ import fs from 'fs';
17
+
18
+ import { getInstancePath, getRunDir } from '@/server/lib/lifecycle/paths';
19
+
20
+ export type InstanceMode = 'dev' | 'prod';
21
+ export type InstanceLaunchMode = 'daemon' | 'foreground' | 'direct';
22
+
23
+ export interface InstanceRecord {
24
+ pid: number;
25
+ port: number;
26
+ host: string;
27
+ mode: InstanceMode;
28
+ launchMode: InstanceLaunchMode;
29
+ startedAt: string;
30
+ version: string;
31
+ }
32
+
33
+ function isLaunchMode(value: unknown): value is InstanceLaunchMode {
34
+ return value === 'daemon' || value === 'foreground' || value === 'direct';
35
+ }
36
+
37
+ /** Record for `port`, or null when absent or unreadable. */
38
+ export function readInstanceRecord(port: number | string): InstanceRecord | null {
39
+ try {
40
+ const parsed: unknown = JSON.parse(fs.readFileSync(getInstancePath(port), 'utf8'));
41
+ if (!parsed || typeof parsed !== 'object') return null;
42
+ const record = parsed as Record<string, unknown>;
43
+ if (typeof record.pid !== 'number' || typeof record.port !== 'number') return null;
44
+ return {
45
+ pid: record.pid,
46
+ port: record.port,
47
+ host: typeof record.host === 'string' ? record.host : 'localhost',
48
+ mode: record.mode === 'prod' ? 'prod' : 'dev',
49
+ launchMode: isLaunchMode(record.launchMode) ? record.launchMode : 'direct',
50
+ startedAt: typeof record.startedAt === 'string' ? record.startedAt : '',
51
+ version: typeof record.version === 'string' ? record.version : '',
52
+ };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Every readable record in the run directory. */
59
+ export function listInstanceRecords(): InstanceRecord[] {
60
+ const records: InstanceRecord[] = [];
61
+ try {
62
+ for (const file of fs.readdirSync(getRunDir())) {
63
+ const match = /^(\d+)\.json$/.exec(file);
64
+ if (!match) continue;
65
+ const record = readInstanceRecord(match[1]);
66
+ if (record) records.push(record);
67
+ }
68
+ } catch {
69
+ // No run directory yet: nothing has ever been served.
70
+ }
71
+ return records;
72
+ }
73
+
74
+ /**
75
+ * Persist the record for its port (sibling temp file + rename, mode 0600).
76
+ * Returns false instead of throwing when the data directory is not writable.
77
+ */
78
+ export function writeInstanceRecord(record: InstanceRecord): boolean {
79
+ const target = getInstancePath(record.port);
80
+ const temp = `${target}.${process.pid}.tmp`;
81
+ try {
82
+ fs.writeFileSync(temp, JSON.stringify(record, null, 2), { mode: 0o600 });
83
+ fs.renameSync(temp, target);
84
+ return true;
85
+ } catch {
86
+ try {
87
+ fs.rmSync(temp, { force: true });
88
+ } catch {
89
+ // Best-effort cleanup only.
90
+ }
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /** Drop the record for `port`. Never throws. */
96
+ export function removeInstanceRecord(port: number | string): void {
97
+ try {
98
+ fs.rmSync(getInstancePath(port), { force: true });
99
+ } catch {
100
+ // Best-effort: a stale record is detected by PID identity on the next read.
101
+ }
102
+ }