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/README.md +24 -8
- package/dist/client/index.html +1 -1
- package/dist/client/static/js/async/{g.8515cbc6f7.js → g.86e2256a70.js} +2 -2
- package/dist/client/static/js/{index.47d3b04d5f.js → index.eccad0b70a.js} +3 -3
- package/package.json +3 -1
- package/src/cli/lib/args.js +1 -1
- package/src/cli/lib/commands/restart.js +11 -0
- package/src/cli/lib/commands/update.js +21 -7
- package/src/cli/lib/runtime.js +6 -3
- package/src/client/hooks/chat/omp/index.ts +4 -109
- package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
- package/src/client/hooks/chat/timeline/send.ts +3 -2
- package/src/server/index.ts +4 -3
- package/src/server/lib/lifecycle/bun.ts +18 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
- package/src/server/lib/lifecycle/launch-mode.ts +40 -0
- package/src/server/lib/lifecycle/restart.test.ts +21 -0
- package/src/server/lib/lifecycle/restart.ts +111 -0
- package/src/server/lib/omp/rpc/session-commands.ts +15 -1
- package/src/server/lib/updates/apply.ts +8 -5
- package/src/server/lib/updates/install.ts +7 -4
- package/src/server/routes/health/probe.ts +2 -1
- package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
- /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → g.86e2256a70.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.47d3b04d5f.js.LICENSE.txt → index.eccad0b70a.js.LICENSE.txt} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "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"
|
package/src/cli/lib/args.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
95
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/lib/runtime.js
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
308
|
-
//
|
|
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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
});
|