ompchamber 0.6.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.
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
+ "license": "UNLICENSED",
6
+ "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
5
7
  "type": "module",
6
8
  "engines": {
7
9
  "bun": ">=1.4.0"
@@ -189,7 +189,7 @@ COMMANDS:
189
189
  serve Start the OMPChamber web server (default when no command is given)
190
190
  update Update OMPChamber to the latest GitHub release
191
191
  stop Stop running OMPChamber instances (every one, or --port <port>)
192
- restart Stop and then start again (every instance, or --port <port>)
192
+ restart Stop and start again (every CLI-started instance, or --port <port>)
193
193
  status Show running instances (every one, or --port <port>)
194
194
  logs Print or follow server logs (every instance, or --port <port>)
195
195
 
@@ -3,11 +3,17 @@
3
3
  // Scoping matches `status` and `stop`: without `--port` every live instance is
4
4
  // restarted on its own recorded port/host/mode, and `--port <port>` restricts
5
5
  // the restart to that one. With nothing running, the command is simply `serve`.
6
+ //
7
+ // Instances the CLI did not start are left alone, exactly as `ompchamber update`
8
+ // leaves them: `bun run dev` and `bun run start` own their own process, so
9
+ // replacing it would break a loop OMPChamber does not manage. `ompchamber stop`
10
+ // is still the deliberate way to end one.
6
11
 
7
12
  import { findLiveInstance, listLiveInstances, stopInstance } from '@/cli/lib/runtime.js';
8
13
  import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
9
14
  import { log, isJson, isQuiet } from '@/cli/lib/output.js';
10
15
  import { run as runServe } from '@/cli/lib/commands/serve.js';
16
+ import { isAutoRestartable, skipRestartNote } from '@/server/lib/lifecycle/restart';
11
17
 
12
18
  function explicitPort(options) {
13
19
  const raw = options?.port;
@@ -28,6 +34,11 @@ export async function run(options, ctx) {
28
34
  if (targets.length === 0) return runServe(options, ctx);
29
35
 
30
36
  for (const live of targets) {
37
+ if (!isAutoRestartable(live.launchMode)) {
38
+ if (!json && !quiet) log(`Skipping OMPChamber on port ${live.port}: ${skipRestartNote(live.launchMode)}.`);
39
+ continue;
40
+ }
41
+
31
42
  if (!json && !quiet) log(`Stopping OMPChamber on port ${live.port} (pid ${live.pid})...`);
32
43
  await stopInstance(live, { timeoutMs: STOP_TIMEOUT_MS });
33
44
 
@@ -2,19 +2,23 @@
2
2
  //
3
3
  // The installation mechanics live in the shared update engine so the CLI and
4
4
  // the console's "Update" button do the same thing; this command owns the
5
- // terminal presentation and restarts any instance still serving the previous
6
- // build.
5
+ // terminal presentation and restarts the instances still serving the previous
6
+ // build (the ones the CLI started — a server running from source is left to its
7
+ // own launcher).
7
8
 
8
9
  import { color, configure, error, log, ok, warn, printJson, isJson, isQuiet } from '@/cli/lib/output.js';
9
10
  import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
10
11
  import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
11
12
  import { run as runServe } from '@/cli/lib/commands/serve.js';
13
+ import { isAutoRestartable, skipRestartNote } from '@/server/lib/lifecycle/restart';
12
14
  import { resolveInstallContext, resolveOmpChamberVersion, updateOmpChamber } from '@/server/lib/updates/install';
13
15
 
14
16
  /**
15
17
  * Live instances to restart after an update: every one with `--all`, otherwise
16
18
  * the first. Their recorded port/host/mode is reused, so a server started on a
17
- * non-default port comes back on that same port.
19
+ * non-default port comes back on that same port. Instances started outside the
20
+ * CLI (`bun run dev`, `bun run start`, a supervisor) are reported and left
21
+ * running — their own launcher is what applies the new files.
18
22
  */
19
23
  async function liveInstances(options) {
20
24
  if (options?.all) return await listLiveInstances();
@@ -24,7 +28,14 @@ async function liveInstances(options) {
24
28
 
25
29
  async function restartInstances(options, ctx) {
26
30
  const restarted = [];
31
+ const skipped = [];
27
32
  for (const entry of await liveInstances(options)) {
33
+ if (!isAutoRestartable(entry.launchMode)) {
34
+ skipped.push({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode });
35
+ log(`Skipping OMPChamber on port ${entry.port}: ${skipRestartNote(entry.launchMode)}.`);
36
+ continue;
37
+ }
38
+
28
39
  log(`Restarting OMPChamber on port ${entry.port} (pid ${entry.pid})...`);
29
40
  await stopInstance(entry, { timeoutMs: STOP_TIMEOUT_MS });
30
41
  await runServe(
@@ -41,7 +52,7 @@ async function restartInstances(options, ctx) {
41
52
  );
42
53
  restarted.push(entry.port);
43
54
  }
44
- return restarted;
55
+ return { restarted, skipped };
45
56
  }
46
57
 
47
58
  async function runCheck(pkgRoot, json) {
@@ -91,8 +102,10 @@ export async function run(options, ctx) {
91
102
  // Restarting prints through the serve command; silence it so stdout stays
92
103
  // one JSON document.
93
104
  if (result.success && result.updated && shouldRestart) configure({ json: false, quiet: true });
94
- const restarted = result.success && result.updated && shouldRestart ? await restartInstances(options, ctx) : [];
95
- printJson({ ...result, restarted });
105
+ const { restarted, skipped } = result.success && result.updated && shouldRestart
106
+ ? await restartInstances(options, ctx)
107
+ : { restarted: [], skipped: [] };
108
+ printJson({ ...result, restarted, skipped });
96
109
  if (!result.success) process.exitCode = 1;
97
110
  return;
98
111
  }
@@ -111,7 +124,8 @@ export async function run(options, ctx) {
111
124
  return;
112
125
  }
113
126
 
114
- if ((await restartInstances(options, ctx)).length === 0) {
127
+ const { restarted, skipped } = await restartInstances(options, ctx);
128
+ if (restarted.length === 0 && skipped.length === 0) {
115
129
  log('No running instance to restart.');
116
130
  }
117
131
  }
@@ -13,6 +13,7 @@ import fs from 'node:fs';
13
13
  import { ensureDataDirs, getLogFilePath } from '@/cli/lib/paths.js';
14
14
  import { killChildTree, STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
15
15
  import { getProcessState, isProcessAlive } from '@/server/lib/lifecycle/identity';
16
+ import { launchModeArg } from '@/server/lib/lifecycle/launch-mode';
16
17
  import { listInstanceRecords, readInstanceRecord, removeInstanceRecord } from '@/server/lib/lifecycle/instance';
17
18
  import { fetchHealth, probeHost } from '@/server/lib/lifecycle/probe';
18
19
  import { joinPath, homeDir } from '@/cli/lib/path-utils.js';
@@ -134,7 +135,10 @@ export function resolveBunBin() {
134
135
  * the SSR shell either way. `--ompchamber-server` is an identity marker: it
135
136
  * makes this process recognizable in `ps` output, which is how a starting
136
137
  * server decides whether a busy port belongs to OMPChamber before signalling
137
- * anything.
138
+ * anything. The launch mode rides along as argv (`--launch-mode=`), never as an
139
+ * environment variable: env is inherited by descendants, so a `bun run dev`
140
+ * started from an OMPChamber shell would otherwise be labelled `daemon` and
141
+ * replaced by the next update.
138
142
  */
139
143
  export function buildServeInvocation({ pkgRoot, mode, port, host, launchMode = 'daemon' }) {
140
144
  const entry = joinPath(pkgRoot, 'src', 'server', 'index.ts');
@@ -146,13 +150,12 @@ export function buildServeInvocation({ pkgRoot, mode, port, host, launchMode = '
146
150
  }
147
151
  return {
148
152
  file: resolveBunBin(),
149
- args: [entry, '--ompchamber-server'],
153
+ args: [entry, '--ompchamber-server', launchModeArg(launchMode)],
150
154
  env: {
151
155
  ...Bun.env,
152
156
  NODE_ENV: mode === 'prod' ? 'production' : 'development',
153
157
  PORT: String(port),
154
158
  HOST: host,
155
- OMPCHAMBER_LAUNCH_MODE: launchMode,
156
159
  },
157
160
  };
158
161
  }
@@ -1,8 +1,8 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import type { ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
3
3
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
4
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
5
4
  import { type ToolResultRecord, useOmpAgentStream } from '@/client/hooks/chat/omp/stream';
5
+ import { useOmpPromptSender } from '@/client/hooks/chat/omp/prompt-send';
6
6
  import { stopAgentSession } from '@/shared/lib/chat/omp/abort';
7
7
 
8
8
  /**
@@ -98,114 +98,9 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
98
98
  };
99
99
  }, [sessionId, disconnect, connect]);
100
100
 
101
- /** Send a prompt to the omp session via the RPC bridge. */
102
- const sendPrompt = useCallback(async (
103
- message: string,
104
- images?: { data: string; mimeType: string }[],
105
- options?: { accessMode?: ApprovalMode },
106
- ) => {
107
- const sid = sessionIdRef.current;
108
- if (!sid) return false;
109
- setState((prev) => ({ ...prev, isGenerating: true, error: null }));
110
- try {
111
- // Mirror omp-web handleSend: warm the session process up with get_state
112
- // (spawns it on first use), then attach the event stream before sending
113
- // the prompt so no agent events are missed.
114
- const warmup = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
115
- method: 'POST',
116
- headers: { 'Content-Type': 'application/json' },
117
- // The mode rides the warmup too: this is the request that lazily spawns
118
- // an idle session, and omp only accepts --approval-mode at spawn time.
119
- body: JSON.stringify({
120
- type: 'get_state',
121
- ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
122
- }),
123
- });
124
- if (warmup.ok) connect(sid);
125
-
126
- const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
127
- method: 'POST',
128
- headers: { 'Content-Type': 'application/json' },
129
- body: JSON.stringify({
130
- type: 'prompt',
131
- message,
132
- ...(images?.length ? { images } : {}),
133
- ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
134
- }),
135
- });
136
- const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
137
- if (!res.ok || body.error) {
138
- // `session_busy`: the ack timed out behind a still-running turn, so the
139
- // prompt may already be accepted — never resend it automatically.
140
- setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
141
- return false;
142
- }
143
- return true;
144
- } catch (e) {
145
- setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
146
- return false;
147
- }
148
- }, [connect]);
149
-
150
- /** Spawn a brand-new omp session and send the first prompt: ensure_session
151
- * first (returns omp's real session id), attach the event stream, then send
152
- * the prompt through the existing session route so no agent events are
153
- * missed. Model/thinking picks ride the ensure_session body so omp applies
154
- * them BEFORE the first prompt (and their JSONL change entries are written
155
- * up front, not after the run starts). Returns the new session id on
156
- * success, or null on failure — the caller adopts the id as the active
157
- * session. */
158
- const sendNewPrompt = useCallback(async (
159
- message: string,
160
- cwd: string,
161
- images?: { data: string; mimeType: string }[],
162
- composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
163
- ): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
164
- setState((prev) => ({ ...prev, isGenerating: true, error: null }));
165
- try {
166
- const created = await fetch('/api/agent/new', {
167
- method: 'POST',
168
- headers: { 'Content-Type': 'application/json' },
169
- body: JSON.stringify({
170
- type: 'ensure_session',
171
- cwd,
172
- ...(composerOptions?.model ? composerOptions.model : {}),
173
- ...(composerOptions?.thinkingLevel ? { thinkingLevel: composerOptions.thinkingLevel } : {}),
174
- ...(composerOptions?.accessMode ? { accessMode: composerOptions.accessMode } : {}),
175
- }),
176
- });
177
- const createdBody = (await created.json().catch(() => ({}))) as {
178
- success?: boolean;
179
- sessionId?: string;
180
- model?: { provider: string; modelId: string } | null;
181
- error?: string;
182
- };
183
- if (!created.ok || !createdBody.sessionId) {
184
- setState((prev) => ({ ...prev, isGenerating: false, error: createdBody.error ?? `HTTP ${created.status}` }));
185
- return null;
186
- }
187
- const sid = createdBody.sessionId;
188
- connect(sid);
189
- const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
190
- method: 'POST',
191
- headers: { 'Content-Type': 'application/json' },
192
- body: JSON.stringify({
193
- type: 'prompt',
194
- message,
195
- ...(images?.length ? { images } : {}),
196
- }),
197
- });
198
- const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
199
- if (!res.ok || body.error) {
200
- setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
201
- return null;
202
- }
203
- return { sessionId: sid, model: createdBody.model ?? null };
204
- } catch (e) {
205
- setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
206
- return null;
207
- }
208
- }, [connect]);
101
+ // Prompt delivery (warm-up + stream attach + the dispatch-time sidebar
102
+ // signal) lives in its own module so this hook stays under the size ceiling.
103
+ const { sendPrompt, sendNewPrompt } = useOmpPromptSender({ sessionIdRef, connect, setState });
209
104
 
210
105
  /** Abort the running agent turn. */
211
106
  const abort = useCallback(async () => {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Prompt delivery for the live omp bridge: warm the session up, attach the
8
+ * event stream, POST the prompt, then signal the sidebars the moment it is
9
+ * dispatched.
10
+ *
11
+ * The signal is the point: the server writes the live `stream` row when it
12
+ * dispatches the prompt, so waiting for `agent_start` (spawn + ack round trip)
13
+ * would leave the sidebar spinner dark for seconds after the user hit send.
14
+ * Extracted from useOmpAgent so that hook stays under the repo's per-file size
15
+ * ceiling.
16
+ */
17
+
18
+ import { useCallback } from 'preact/hooks';
19
+ import type { Dispatch, SetStateAction } from 'preact/compat';
20
+ import type { OmpAgentState } from '@/shared/types';
21
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
22
+
23
+ export interface OmpPromptSenderDeps {
24
+ /** Live session id the prompt targets; the caller mirrors its prop into it. */
25
+ sessionIdRef: { current: string | null };
26
+ /** Attach the event stream to a spawned session. */
27
+ connect: (sessionId: string) => void;
28
+ setState: Dispatch<SetStateAction<OmpAgentState>>;
29
+ }
30
+
31
+ export interface OmpPromptSender {
32
+ sendPrompt: (message: string, images?: { data: string; mimeType: string }[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
33
+ sendNewPrompt: (
34
+ message: string,
35
+ cwd: string,
36
+ images?: { data: string; mimeType: string }[],
37
+ composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
38
+ ) => Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null>;
39
+ }
40
+
41
+ export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
42
+ const { sessionIdRef, connect, setState } = deps;
43
+
44
+ /** Send a prompt to the omp session via the RPC bridge. */
45
+ const sendPrompt = useCallback(async (
46
+ message: string,
47
+ images?: { data: string; mimeType: string }[],
48
+ options?: { accessMode?: ApprovalMode },
49
+ ) => {
50
+ const sid = sessionIdRef.current;
51
+ if (!sid) return false;
52
+ setState((prev) => ({ ...prev, isGenerating: true, error: null }));
53
+ try {
54
+ // Mirror omp-web handleSend: warm the session process up with get_state
55
+ // (spawns it on first use), then attach the event stream before sending
56
+ // the prompt so no agent events are missed.
57
+ const warmup = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ // The mode rides the warmup too: this is the request that lazily spawns
61
+ // an idle session, and omp only accepts --approval-mode at spawn time.
62
+ body: JSON.stringify({
63
+ type: 'get_state',
64
+ ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
65
+ }),
66
+ });
67
+ if (warmup.ok) connect(sid);
68
+
69
+ const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify({
73
+ type: 'prompt',
74
+ message,
75
+ ...(images?.length ? { images } : {}),
76
+ ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
77
+ }),
78
+ });
79
+ const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
80
+ if (!res.ok || body.error) {
81
+ // `session_busy`: the ack timed out behind a still-running turn, so the
82
+ // prompt may already be accepted — never resend it automatically.
83
+ setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
84
+ return false;
85
+ }
86
+ // Tell the sidebars to re-read the session list now — the server wrote
87
+ // this session's live `stream` row at dispatch. Revalidation is
88
+ // leading-edge throttled, so this lands immediately.
89
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
90
+ return true;
91
+ } catch (e) {
92
+ setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
93
+ return false;
94
+ }
95
+ }, [connect, sessionIdRef, setState]);
96
+
97
+ /** Spawn a brand-new omp session and send the first prompt: ensure_session
98
+ * first (returns omp's real session id), attach the event stream, then send
99
+ * the prompt through the existing session route so no agent events are
100
+ * missed. Model/thinking picks ride the ensure_session body so omp applies
101
+ * them BEFORE the first prompt (and their JSONL change entries are written
102
+ * up front, not after the run starts). Returns the new session id on
103
+ * success, or null on failure — the caller adopts the id as the active
104
+ * session. */
105
+ const sendNewPrompt = useCallback(async (
106
+ message: string,
107
+ cwd: string,
108
+ images?: { data: string; mimeType: string }[],
109
+ composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
110
+ ): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
111
+ setState((prev) => ({ ...prev, isGenerating: true, error: null }));
112
+ try {
113
+ const created = await fetch('/api/agent/new', {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({
117
+ type: 'ensure_session',
118
+ cwd,
119
+ ...(composerOptions?.model ? composerOptions.model : {}),
120
+ ...(composerOptions?.thinkingLevel ? { thinkingLevel: composerOptions.thinkingLevel } : {}),
121
+ ...(composerOptions?.accessMode ? { accessMode: composerOptions.accessMode } : {}),
122
+ }),
123
+ });
124
+ const createdBody = (await created.json().catch(() => ({}))) as {
125
+ success?: boolean;
126
+ sessionId?: string;
127
+ model?: { provider: string; modelId: string } | null;
128
+ error?: string;
129
+ };
130
+ if (!created.ok || !createdBody.sessionId) {
131
+ setState((prev) => ({ ...prev, isGenerating: false, error: createdBody.error ?? `HTTP ${created.status}` }));
132
+ return null;
133
+ }
134
+ const sid = createdBody.sessionId;
135
+ connect(sid);
136
+ const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({
140
+ type: 'prompt',
141
+ message,
142
+ ...(images?.length ? { images } : {}),
143
+ }),
144
+ });
145
+ const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
146
+ if (!res.ok || body.error) {
147
+ setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
148
+ return null;
149
+ }
150
+ // Same dispatch-time signal as sendPrompt: the new session's live badge
151
+ // must not wait for agent_start + the first JSONL write.
152
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
153
+ return { sessionId: sid, model: createdBody.model ?? null };
154
+ } catch (e) {
155
+ setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
156
+ return null;
157
+ }
158
+ }, [connect, setState]);
159
+
160
+ return { sendPrompt, sendNewPrompt };
161
+ }
@@ -304,8 +304,9 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
304
304
  next.set('sessionId', spawned.sessionId);
305
305
  return next;
306
306
  }, { replace: true });
307
- // The agent_start event refreshes the session metadata/sidebar once
308
- // the JSONL has the user turn; no immediate refresh is needed here.
307
+ // The sidebar already re-read at prompt dispatch (sendNewPrompt
308
+ // signals it), so the new session shows its live badge without
309
+ // waiting for the JSONL to carry the user turn.
309
310
  return;
310
311
  }
311
312
  }
@@ -6,6 +6,7 @@ import { getDatabasePath } from '@/server/db.server';
6
6
  import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
7
7
  import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
8
8
  import { readInstanceRecord, removeInstanceRecord, writeInstanceRecord } from '@/server/lib/lifecycle/instance';
9
+ import { resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
9
10
  import { acquirePortLock, claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
10
11
  import { isMockMode } from '@/server/mock.server';
11
12
 
@@ -28,9 +29,9 @@ console.log(`[ompchamber] db: ${await getDatabasePath()}`);
28
29
  const port = Number(Bun.env.PORT) || 3000;
29
30
  const host = Bun.env.HOST || 'localhost';
30
31
  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';
32
+ // argv, not env: see launch-mode.ts — an inherited `OMPCHAMBER_LAUNCH_MODE`
33
+ // would label a `bun run dev` started inside an OMPChamber shell as `daemon`.
34
+ const launchMode = resolveLaunchMode();
34
35
 
35
36
  const lock = await acquirePortLock(port);
36
37
  if (!lock) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The Bun binary to spawn child processes with.
8
+ *
9
+ * The server is usually itself Bun, so `process.execPath` is the most
10
+ * trustworthy answer — it is the exact runtime this install was started with.
11
+ * A host that is not Bun falls back to a PATH lookup, and finally to the
12
+ * literal `bun`, which lets the child resolve it from its own environment.
13
+ */
14
+ export function resolveBunBin(): string {
15
+ const execPath = process.execPath;
16
+ if (/(^|[\\/])bun$/.test(execPath)) return execPath;
17
+ return Bun.which('bun') ?? 'bun';
18
+ }
@@ -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,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
+ });