ompchamber 0.5.0 → 0.7.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 (89) hide show
  1. package/README.md +67 -6
  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/g.86e2256a70.js +2 -0
  9. package/dist/client/static/js/async/hn.b645041e00.js +1 -0
  10. package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
  11. package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
  12. package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
  13. package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
  14. package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
  15. package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
  16. package/dist/client/static/js/async/t.cda4891526.js +1 -0
  17. package/dist/client/static/js/async/tr.e878658140.js +309 -0
  18. package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
  19. package/dist/client/static/js/index.eccad0b70a.js +33 -0
  20. package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
  21. package/package.json +15 -5
  22. package/src/cli/lib/args.js +6 -6
  23. package/src/cli/lib/commands/logs.js +76 -34
  24. package/src/cli/lib/commands/restart.js +40 -8
  25. package/src/cli/lib/commands/serve.js +12 -4
  26. package/src/cli/lib/commands/status.js +40 -18
  27. package/src/cli/lib/commands/stop.js +12 -7
  28. package/src/cli/lib/commands/update.js +23 -12
  29. package/src/cli/lib/paths.js +8 -28
  30. package/src/cli/lib/runtime.js +121 -108
  31. package/src/client/components/common/code-editor/index.tsx +5 -1
  32. package/src/client/components/common/code-surface/index.tsx +49 -1
  33. package/src/client/components/common/code-surface/measure.ts +99 -0
  34. package/src/client/components/layout/RightActivityBar.tsx +42 -69
  35. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
  36. package/src/client/components/layout/desktop-layout/index.tsx +11 -9
  37. package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
  38. package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
  39. package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
  40. package/src/client/components/mobile/RightSidebar.tsx +42 -119
  41. package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
  42. package/src/client/components/workspace/editor/index.tsx +4 -1
  43. package/src/client/hooks/chat/omp/index.ts +4 -109
  44. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  45. package/src/client/hooks/chat/timeline/send.ts +3 -2
  46. package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
  47. package/src/client/hooks/editor/use-file-editor.ts +35 -19
  48. package/src/server/index.ts +52 -1
  49. package/src/server/lib/lifecycle/bun.ts +18 -0
  50. package/src/server/lib/lifecycle/identity.ts +108 -0
  51. package/src/server/lib/lifecycle/instance.ts +102 -0
  52. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  53. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  54. package/src/server/lib/lifecycle/occupant.ts +101 -0
  55. package/src/server/lib/lifecycle/paths.ts +50 -0
  56. package/src/server/lib/lifecycle/port-guard.ts +170 -0
  57. package/src/server/lib/lifecycle/probe.ts +121 -0
  58. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  59. package/src/server/lib/lifecycle/restart.ts +111 -0
  60. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  61. package/src/server/lib/updates/apply.ts +8 -5
  62. package/src/server/lib/updates/install.ts +7 -4
  63. package/src/server/plugins/static.test.ts +63 -0
  64. package/src/server/plugins/static.ts +28 -4
  65. package/src/server/routes/health/probe.ts +9 -0
  66. package/src/shared/lib/markdown/mermaid.ts +8 -0
  67. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  68. package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
  69. package/src/shared/lib/workspace/panel-widths.ts +23 -10
  70. package/src/shared/lib/workspace/right-panels.ts +10 -4
  71. package/dist/client/static/js/async/73.50280ce58d.js +0 -1
  72. package/dist/client/static/js/async/a.e05da304c8.js +0 -10
  73. package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
  74. package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
  75. package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
  76. package/dist/client/static/js/async/l.102a97fce6.js +0 -104
  77. package/dist/client/static/js/async/l6.c56640c205.js +0 -1
  78. package/dist/client/static/js/async/nk.6de011832e.js +0 -1
  79. package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
  80. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  81. package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
  82. package/dist/client/static/js/index.0ac93ee878.js +0 -33
  83. /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
  84. /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.86e2256a70.js.LICENSE.txt} +0 -0
  85. /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
  86. /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
  87. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
  88. /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.eccad0b70a.js.LICENSE.txt} +0 -0
  89. /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
4
+
5
+ const ENTRY = '/repo/src/server/index.ts';
6
+
7
+ describe('resolveLaunchMode', () => {
8
+ test('reads the flag the CLI appends to the servers it spawns', () => {
9
+ expect(resolveLaunchMode(['bun', ENTRY, '--ompchamber-server', launchModeArg('daemon')])).toBe('daemon');
10
+ expect(resolveLaunchMode(['bun', ENTRY, '--ompchamber-server', launchModeArg('foreground')])).toBe('foreground');
11
+ });
12
+
13
+ test('treats a server started without the flag as direct', () => {
14
+ // `bun run dev`, `bun run start`, a manual run, an external supervisor.
15
+ expect(resolveLaunchMode(['bun', 'run', '--hot', ENTRY, '--ompchamber-server'])).toBe('direct');
16
+ expect(resolveLaunchMode(['bun', ENTRY, '--ompchamber-server', '--launch-mode=sideways'])).toBe('direct');
17
+ });
18
+
19
+ test('ignores an inherited environment marker', () => {
20
+ // The chamber's terminal panel runs commands with the server's own env, so
21
+ // a `bun run dev` started there inherits `daemon` — argv is the only
22
+ // trustworthy signal, and this test fails if env is ever read again.
23
+ const previous = Bun.env.OMPCHAMBER_LAUNCH_MODE;
24
+ Bun.env.OMPCHAMBER_LAUNCH_MODE = 'daemon';
25
+ try {
26
+ expect(resolveLaunchMode(['bun', ENTRY, '--ompchamber-server'])).toBe('direct');
27
+ } finally {
28
+ if (previous === undefined) delete Bun.env.OMPCHAMBER_LAUNCH_MODE;
29
+ else Bun.env.OMPCHAMBER_LAUNCH_MODE = previous;
30
+ }
31
+ });
32
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * How this server was launched — the fact that decides whether an update may
8
+ * replace it.
9
+ *
10
+ * The marker travels as an argv flag, never as an environment variable. Env is
11
+ * inherited by every descendant, so a server started from a shell *inside* an
12
+ * OMPChamber tree — the chamber's own terminal panel runs commands with the
13
+ * server's environment — would inherit `daemon` and then be swapped out by the
14
+ * next update, killing a `bun run dev` loop OMPChamber does not own. argv
15
+ * belongs to the process alone.
16
+ */
17
+
18
+ import type { InstanceLaunchMode } from '@/server/lib/lifecycle/instance';
19
+
20
+ /** The argv flag the CLI appends to every server it spawns. */
21
+ const LAUNCH_MODE_FLAG = '--launch-mode=';
22
+
23
+ /**
24
+ * The argv entry for `mode`. Paired with `resolveLaunchMode`, so the flag
25
+ * spelling exists in exactly one place for the writer and the reader.
26
+ */
27
+ export function launchModeArg(mode: InstanceLaunchMode): string {
28
+ return `${LAUNCH_MODE_FLAG}${mode}`;
29
+ }
30
+
31
+ /**
32
+ * Launch mode of a server invocation. Only the CLI passes the flag, so a
33
+ * `bun run dev`, a `bun run start`, a manual run and an externally supervised
34
+ * process all resolve to `direct` — the mode that is left alone.
35
+ */
36
+ export function resolveLaunchMode(argv: readonly string[] = Bun.argv): InstanceLaunchMode {
37
+ const flag = argv.find((arg) => arg.startsWith(LAUNCH_MODE_FLAG));
38
+ const value = flag?.slice(LAUNCH_MODE_FLAG.length);
39
+ return value === 'daemon' || value === 'foreground' ? value : 'direct';
40
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Who is serving a port right now.
8
+ *
9
+ * Two independent sources answer that question, and they are deliberately
10
+ * combined: the instance record (written by the server itself) and the live
11
+ * `/api/health` probe. The record is the only source for a port whose server is
12
+ * deaf (hung, suspended, still starting), while the probe is the only source
13
+ * for a server started outside the CLI — a `bun run dev` server must be
14
+ * discoverable, and before this module it was invisible to every CLI command.
15
+ */
16
+
17
+ import { getProcessState, isProcessAlive } from '@/server/lib/lifecycle/identity';
18
+ import { readInstanceRecord } from '@/server/lib/lifecycle/instance';
19
+ import { fetchHealth } from '@/server/lib/lifecycle/probe';
20
+
21
+ export interface Occupant {
22
+ pid: number;
23
+ port: number;
24
+ host?: string;
25
+ version?: string;
26
+ /** `dev` | `prod` as reported by the live server, else from the record. */
27
+ mode?: string;
28
+ launchMode?: string;
29
+ startedAt?: string;
30
+ uptime?: number;
31
+ source: 'registry+probe' | 'probe' | 'registry';
32
+ /** True when the occupant answered `/api/health` as OMPChamber itself. */
33
+ confirmedByHealth: boolean;
34
+ }
35
+
36
+ /** The OMPChamber instance on `port`, or null when the port is free/foreign. */
37
+ export async function findOccupant(port: number, host?: string | null): Promise<Occupant | null> {
38
+ const record = readInstanceRecord(port);
39
+ const recordIsLive = record !== null
40
+ && isProcessAlive(record.pid)
41
+ && getProcessState(record.pid) !== 'mismatched';
42
+
43
+ const health = await fetchHealth(port, record?.host ?? host);
44
+ const healthPid = health?.pid;
45
+ if (health && typeof healthPid === 'number' && isProcessAlive(healthPid)) {
46
+ return {
47
+ pid: healthPid,
48
+ port,
49
+ host: record?.host ?? host ?? undefined,
50
+ version: health.version ?? record?.version,
51
+ mode: health.mode ?? record?.mode,
52
+ launchMode: record?.launchMode,
53
+ startedAt: health.startedAt ?? record?.startedAt,
54
+ uptime: health.uptime,
55
+ source: recordIsLive && record.pid === healthPid ? 'registry+probe' : 'probe',
56
+ confirmedByHealth: true,
57
+ };
58
+ }
59
+
60
+ if (record && recordIsLive) {
61
+ return {
62
+ pid: record.pid,
63
+ port,
64
+ host: record.host,
65
+ version: record.version,
66
+ mode: record.mode,
67
+ launchMode: record.launchMode,
68
+ startedAt: record.startedAt,
69
+ source: 'registry',
70
+ confirmedByHealth: false,
71
+ };
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+ /** One-line identity for logs and errors: `pid 1234 (dev, direct, v0.5.0, up 2m)`. */
78
+ export function describeOccupant(occupant: Occupant): string {
79
+ const details: string[] = [];
80
+ if (occupant.mode) details.push(occupant.mode);
81
+ if (occupant.launchMode) details.push(occupant.launchMode);
82
+ if (occupant.version) details.push(`v${occupant.version}`);
83
+ const age = occupantAge(occupant);
84
+ if (age) details.push(`up ${age}`);
85
+ if (!occupant.confirmedByHealth) details.push('unconfirmed');
86
+ const suffix = details.length > 0 ? ` (${details.join(', ')})` : '';
87
+ return `pid ${occupant.pid}${suffix}`;
88
+ }
89
+
90
+ function occupantAge(occupant: Occupant): string | null {
91
+ const seconds = typeof occupant.uptime === 'number'
92
+ ? Math.round(occupant.uptime)
93
+ : occupant.startedAt
94
+ ? Math.round((Date.now() - Date.parse(occupant.startedAt)) / 1000)
95
+ : Number.NaN;
96
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
97
+ if (seconds < 60) return `${seconds}s`;
98
+ const minutes = Math.floor(seconds / 60);
99
+ if (minutes < 60) return `${minutes}m`;
100
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
101
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Where OMPChamber keeps per-port runtime state.
8
+ *
9
+ * Both the server and the CLI need these paths, and they must agree: the server
10
+ * owns the record for the port it listens on, and the CLI reads that same record
11
+ * to answer `status` / `stop` / `logs`. One module is what makes the agreement
12
+ * structural — the previous split (CLI wrote, server never did) is why a server
13
+ * started outside the CLI was invisible to every CLI command.
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import os from 'os';
18
+ import path from 'path';
19
+
20
+ /**
21
+ * Root of OMPChamber's own state (~/.ompchamber, or OMPCHAMBER_DATA_DIR).
22
+ *
23
+ * `os.homedir()` rather than HOME: a process started without a shell (cron,
24
+ * launchd, a service) has no HOME, and an empty value would silently move the
25
+ * data directory to a cwd-relative path.
26
+ */
27
+ export function getDataDir(): string {
28
+ const override = (Bun.env.OMPCHAMBER_DATA_DIR ?? '').trim();
29
+ if (override) {
30
+ return path.resolve(override.startsWith('~') ? path.join(os.homedir(), override.slice(1)) : override);
31
+ }
32
+ return path.join(os.homedir(), '.ompchamber');
33
+ }
34
+
35
+ /** Directory holding per-instance records and handover locks (mode 0700). */
36
+ export function getRunDir(): string {
37
+ const dir = path.join(getDataDir(), 'run');
38
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ return dir;
40
+ }
41
+
42
+ /** `<data>/run/<port>.json` — the record written by the server serving `port`. */
43
+ export function getInstancePath(port: number | string): string {
44
+ return path.join(getRunDir(), `${port}.json`);
45
+ }
46
+
47
+ /** `<data>/run/<port>.lock` — held only while a port handover is in progress. */
48
+ export function getLockPath(port: number | string): string {
49
+ return path.join(getRunDir(), `${port}.lock`);
50
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Port guarding for a starting OMPChamber server.
8
+ *
9
+ * One port is served by exactly one process, and **a starting instance never
10
+ * stops another one** — not a CLI server, not a dev run. An occupant that
11
+ * identifies itself as OMPChamber makes the newcomer exit with the ways to pick
12
+ * a different port; freeing a port is what `ompchamber stop --port <port>` is
13
+ * for, done deliberately and per port.
14
+ *
15
+ * The refusal is load-bearing rather than defensive: a second bind is *not*
16
+ * refused by the runtime (Elysia's Bun adapter hardcodes `reusePort: true`), so
17
+ * two servers would coexist on one port and only the first-bound socket would
18
+ * receive connections. The newcomer would look healthy in its own log while the
19
+ * browser kept talking to the old build; the guard stops that state from being
20
+ * created in the first place.
21
+ */
22
+
23
+ import fs from 'fs';
24
+
25
+ import { getProcessState } from '@/server/lib/lifecycle/identity';
26
+ import { describeOccupant, findOccupant } from '@/server/lib/lifecycle/occupant';
27
+ import { getLockPath } from '@/server/lib/lifecycle/paths';
28
+ import { isPortAvailable } from '@/server/lib/lifecycle/probe';
29
+
30
+ const LOCK_WAIT_MS = 3_000;
31
+ /** Grace period for a port that is settling (a server that just exited, or one that is starting). */
32
+ const SETTLE_WAIT_MS = 3_000;
33
+
34
+ export class PortInUseError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = 'PortInUseError';
38
+ }
39
+ }
40
+
41
+ export interface PortLock {
42
+ release: () => void;
43
+ }
44
+
45
+ /**
46
+ * Exclusive per-port startup lock.
47
+ *
48
+ * Two starters racing on one free port would each probe it as free and then
49
+ * fight over the bind, with the loser reporting a bare `EADDRINUSE` against a
50
+ * process it cannot identify yet. Holding the lock across probe-and-bind makes
51
+ * the loser fail with a message that names the actual situation. A lock whose
52
+ * holder is dead (or is not OMPChamber, i.e. a recycled PID) is reclaimed.
53
+ *
54
+ * Returns null when another starter holds the lock through the wait window.
55
+ */
56
+ export async function acquirePortLock(port: number, waitMs = LOCK_WAIT_MS): Promise<PortLock | null> {
57
+ const lockPath = getLockPath(port);
58
+ const deadline = Date.now() + waitMs;
59
+ for (;;) {
60
+ try {
61
+ fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx', mode: 0o600 });
62
+ return { release: () => releasePortLock(lockPath) };
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') return null;
65
+ }
66
+
67
+ let holder = Number.NaN;
68
+ try {
69
+ holder = Number(fs.readFileSync(lockPath, 'utf8').trim());
70
+ } catch {
71
+ continue;
72
+ }
73
+
74
+ const holderState = Number.isFinite(holder) && holder > 0 ? getProcessState(holder) : 'dead';
75
+ if (holderState === 'dead' || holderState === 'mismatched') {
76
+ fs.rmSync(lockPath, { force: true });
77
+ continue;
78
+ }
79
+ if (holder === process.pid) return { release: () => releasePortLock(lockPath) };
80
+ if (Date.now() >= deadline) return null;
81
+ await Bun.sleep(150);
82
+ }
83
+ }
84
+
85
+ function releasePortLock(lockPath: string): void {
86
+ try {
87
+ if (fs.readFileSync(lockPath, 'utf8').trim() === String(process.pid)) fs.rmSync(lockPath, { force: true });
88
+ } catch {
89
+ // Already gone, or never ours to remove.
90
+ }
91
+ }
92
+
93
+ export interface ClaimPortOptions {
94
+ port: number;
95
+ host: string;
96
+ /**
97
+ * Binds the port; must throw when the OS refuses. Retried while the port
98
+ * looks occupied by something that is not an identified OMPChamber instance.
99
+ */
100
+ listen: () => Promise<void>;
101
+ }
102
+
103
+ /**
104
+ * Bind `port`, or fail with an actionable error.
105
+ *
106
+ * The bind is the authoritative question ("can this process listen here") and
107
+ * `listen` is what answers it; the probe only short-circuits the common case.
108
+ * When either says the port is taken, the occupant is identified so the error
109
+ * can name it — and then the process exits, leaving every other instance
110
+ * running.
111
+ */
112
+ export async function claimPort({ port, host, listen }: ClaimPortOptions): Promise<void> {
113
+ const deadline = Date.now() + SETTLE_WAIT_MS;
114
+ let bindError: unknown = null;
115
+
116
+ for (;;) {
117
+ if (await isPortAvailable(port, host)) {
118
+ try {
119
+ await listen();
120
+ return;
121
+ } catch (error) {
122
+ bindError = error;
123
+ }
124
+ }
125
+
126
+ const occupant = await findOccupant(port, host);
127
+
128
+ // `bun run --hot` re-evaluates this entry inside the running process, so the
129
+ // port is held by *us* and Bun swaps the fetch handler on the next `listen`.
130
+ // Treating that as a conflict would kill the dev loop on every save.
131
+ if (occupant && occupant.pid === process.pid) {
132
+ await listen();
133
+ return;
134
+ }
135
+
136
+ if (occupant) throw await buildPortInUseError(port, host, bindError);
137
+
138
+ if (Date.now() >= deadline) throw await buildPortInUseError(port, host, bindError);
139
+ await Bun.sleep(200);
140
+ }
141
+ }
142
+
143
+ /** Every way to point this server at another port. */
144
+ function portGuidance(): string {
145
+ return [
146
+ ' ompchamber serve --port 3001',
147
+ ' OMPCHAMBER_PORT=3001 ompchamber serve',
148
+ ' PORT=3001 bun run dev # or: PORT=3001 bun run start',
149
+ ].join('\n');
150
+ }
151
+
152
+ async function buildPortInUseError(port: number, host: string, bindError: unknown): Promise<PortInUseError> {
153
+ const occupant = await findOccupant(port, host);
154
+ const bindDetail = bindError instanceof Error ? `\n Bind error: ${bindError.message}` : '';
155
+
156
+ if (occupant) {
157
+ return new PortInUseError(
158
+ `Port ${port} is already served by OMPChamber ${describeOccupant(occupant)}.\n`
159
+ + ` Nothing was stopped — a starting instance never stops another one.\n`
160
+ + ` Stop it first: ompchamber stop --port ${port}\n`
161
+ + ` Or start on another port:\n${portGuidance()}`,
162
+ );
163
+ }
164
+
165
+ return new PortInUseError(
166
+ `Port ${port} is in use by another process (no OMPChamber instance answered on it).${bindDetail}\n`
167
+ + ` Find it with: lsof -nP -iTCP:${port} -sTCP:LISTEN\n`
168
+ + ` Start on another port:\n${portGuidance()}`,
169
+ );
170
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Port occupancy probing.
8
+ *
9
+ * Availability is decided by *attempting a bind*, not by inspecting a PID file.
10
+ * That matters on Bun: `Bun.serve` — and therefore Elysia's `listen`, which
11
+ * hardcodes `reusePort: true` — lets a second process bind a port already in
12
+ * use, so the only reliable question a starter can ask the OS is "can I bind
13
+ * this?" (`net.createServer` never sets SO_REUSEPORT, so a second bind fails as
14
+ * it should, regardless of who is already listening).
15
+ */
16
+
17
+ import net from 'net';
18
+
19
+ const HEALTH_TIMEOUT_MS = 1500;
20
+
21
+ /**
22
+ * Address a local probe should use for a bind host.
23
+ *
24
+ * `0.0.0.0`/`::` are bind-any addresses and cannot be connected to; loopback is
25
+ * the address that reaches the same listener.
26
+ */
27
+ export function probeHost(host?: string | null): string {
28
+ const value = (host ?? '').trim();
29
+ if (value.length === 0 || value === '0.0.0.0' || value === '::' || value === '[::]') return '127.0.0.1';
30
+ return value;
31
+ }
32
+
33
+ /**
34
+ * True when `port` can still be bound on `host`.
35
+ *
36
+ * The probe binds *the same address the server will bind* — a wildcard server
37
+ * is probed with a wildcard bind. Probing loopback instead would ask a
38
+ * different question: `net` sets SO_REUSEADDR, so a loopback bind can succeed
39
+ * while `0.0.0.0:<port>` is already taken, and the port would look free right
40
+ * up until the real `listen` failed.
41
+ */
42
+ export async function isPortAvailable(port: number, host?: string | null): Promise<boolean> {
43
+ if (!Number.isFinite(port) || port <= 0) return false;
44
+ const bindHost = (host ?? '').trim() || 'localhost';
45
+ const { promise, resolve } = Promise.withResolvers<boolean>();
46
+ const probe = net.createServer();
47
+ probe.unref();
48
+ probe.once('error', () => resolve(false));
49
+ probe.listen({ port, host: bindHost }, () => {
50
+ probe.close(() => resolve(true));
51
+ });
52
+ return await promise;
53
+ }
54
+
55
+ /** Poll until the port can be bound, or the deadline passes. */
56
+ export async function waitForPortFree(
57
+ port: number,
58
+ host: string | null | undefined,
59
+ timeoutMs: number,
60
+ intervalMs = 150,
61
+ ): Promise<boolean> {
62
+ const deadline = Date.now() + Math.max(0, timeoutMs);
63
+ for (;;) {
64
+ if (await isPortAvailable(port, host)) return true;
65
+ if (Date.now() >= deadline) return false;
66
+ await Bun.sleep(intervalMs);
67
+ }
68
+ }
69
+
70
+ export interface HealthPayload {
71
+ service: string;
72
+ pid?: number;
73
+ version?: string;
74
+ startedAt?: string;
75
+ uptime?: number;
76
+ mock?: boolean;
77
+ runtime?: string;
78
+ mode?: string;
79
+ port?: number;
80
+ host?: string;
81
+ }
82
+
83
+ /**
84
+ * `GET /api/health` for an OMPChamber listener, or null.
85
+ *
86
+ * Only payloads that identify themselves as OMPChamber are returned — every
87
+ * caller here is asking "is an OMPChamber server on this port", so a foreign
88
+ * HTTP service answers null rather than a half-trusted object.
89
+ */
90
+ export async function fetchHealth(
91
+ port: number,
92
+ host?: string | null,
93
+ timeoutMs = HEALTH_TIMEOUT_MS,
94
+ ): Promise<HealthPayload | null> {
95
+ if (!Number.isFinite(port) || port <= 0) return null;
96
+ try {
97
+ const response = await fetch(`http://${probeHost(host)}:${port}/api/health`, {
98
+ headers: { accept: 'application/json' },
99
+ signal: AbortSignal.timeout(timeoutMs),
100
+ });
101
+ if (!response.ok) return null;
102
+ const body: unknown = await response.json();
103
+ if (!body || typeof body !== 'object') return null;
104
+ const payload = body as Record<string, unknown>;
105
+ if (payload.service !== 'ompchamber') return null;
106
+ return {
107
+ service: 'ompchamber',
108
+ pid: typeof payload.pid === 'number' ? payload.pid : undefined,
109
+ version: typeof payload.version === 'string' ? payload.version : undefined,
110
+ startedAt: typeof payload.startedAt === 'string' ? payload.startedAt : undefined,
111
+ uptime: typeof payload.uptime === 'number' ? payload.uptime : undefined,
112
+ mock: typeof payload.mock === 'boolean' ? payload.mock : undefined,
113
+ runtime: typeof payload.runtime === 'string' ? payload.runtime : undefined,
114
+ mode: typeof payload.mode === 'string' ? payload.mode : undefined,
115
+ port: typeof payload.port === 'number' ? payload.port : undefined,
116
+ host: typeof payload.host === 'string' ? payload.host : undefined,
117
+ };
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { isAutoRestartable } from '@/server/lib/lifecycle/restart';
4
+
5
+ // The modal update and `ompchamber update` both decide with this predicate:
6
+ // guessing wrong here kills a `bun run dev` the user owns, or leaves a daemon
7
+ // serving the build that was just replaced.
8
+ describe('isAutoRestartable', () => {
9
+ test('restarts the instances the CLI started', () => {
10
+ expect(isAutoRestartable('daemon')).toBe(true);
11
+ expect(isAutoRestartable('foreground')).toBe(true);
12
+ });
13
+
14
+ test('leaves every instance OMPChamber did not start alone', () => {
15
+ // `bun run dev`, `bun run start`, a manual run, a systemd/pm2 unit.
16
+ expect(isAutoRestartable('direct')).toBe(false);
17
+ // A probed or unreadable record never authorises a kill either.
18
+ expect(isAutoRestartable('unknown')).toBe(false);
19
+ expect(isAutoRestartable(undefined)).toBe(false);
20
+ });
21
+ });