ompchamber 3.1.0 → 3.2.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 +6 -3
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +279 -280
- package/dist/client/static/js/index-01dv383b.css +1 -0
- package/dist/client/static/js/{index-8t70jhyj.js → index-1q6165r3.js} +641 -641
- package/package.json +1 -1
- package/src/cli/lib/args.js +4 -3
- package/src/cli/lib/commands/restart.js +7 -6
- package/src/cli/lib/commands/stop.js +38 -31
- package/src/cli/lib/commands/update.js +3 -2
- package/src/cli/lib/output.js +15 -8
- package/src/cli/lib/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +35 -6
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/server/lib/bundler/css.test.ts +87 -0
- package/src/server/lib/bundler/css.ts +17 -2
- package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
- package/src/server/lib/bundler/nested-at-rules.ts +146 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/lifecycle/restart.ts +9 -14
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/types/omp/subagent.ts +6 -0
- package/dist/client/static/js/index-n4wkqj7t.css +0 -1
- package/src/server/lib/lifecycle/restart.test.ts +0 -21
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
|
29
29
|
import type { FFIFunction, Library } from 'bun:ffi';
|
|
30
30
|
|
|
31
|
-
import type { ProcessProbe } from '@/server/lib/lifecycle/proc/types';
|
|
31
|
+
import type { ProcessLiveness, ProcessProbe } from '@/server/lib/lifecycle/proc/types';
|
|
32
32
|
|
|
33
33
|
/** `sysctl` MIB pieces. */
|
|
34
34
|
const CTL_KERN = 1;
|
|
@@ -45,8 +45,21 @@ const KINFO_PROC_BYTES = 648;
|
|
|
45
45
|
const P_STAT_OFFSET = 36;
|
|
46
46
|
/** Darwin `p_stat` values. */
|
|
47
47
|
const SZOMB = 5;
|
|
48
|
+
/** `p_stat` values that mean "exited but not yet reaped". */
|
|
48
49
|
const SZOMB_LIKE = new Set([SZOMB, 6 /* SDEAD */]);
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Scratch for the `sysctl(KERN_PROC_PID)` call below, at module scope.
|
|
53
|
+
*
|
|
54
|
+
* One `isProcessAlive` asks this question twice (liveness then zombie), and
|
|
55
|
+
* `getProcessState` asks it twice more — so the buffers are reused rather than
|
|
56
|
+
* reallocated per call, the same reasoning as `bsdInfo` further down. Safe
|
|
57
|
+
* because the whole probe is synchronous: no `await` can interleave a caller.
|
|
58
|
+
*/
|
|
59
|
+
const procMib = new Int32Array([CTL_KERN, KERN_PROC, KERN_PROC_PID, 0]);
|
|
60
|
+
const procSize = new BigUint64Array([BigInt(KINFO_PROC_BYTES)]);
|
|
61
|
+
const procBuffer = new Uint8Array(KINFO_PROC_BYTES);
|
|
62
|
+
|
|
50
63
|
/** Upper bound on a command line block; the kernel truncates instead. */
|
|
51
64
|
const ARGV_BUFFER_BYTES = 262_144;
|
|
52
65
|
const PATH_BUFFER_BYTES = 4096;
|
|
@@ -106,24 +119,27 @@ const BSDINFO_PID_OFFSET = 12;
|
|
|
106
119
|
const BSDINFO_TPGID_OFFSET = 112;
|
|
107
120
|
|
|
108
121
|
/**
|
|
109
|
-
* `p_stat` for a
|
|
122
|
+
* `p_stat` for a PID, `'absent'` when the kernel says no such process, or null
|
|
123
|
+
* when the call itself failed.
|
|
110
124
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
125
|
+
* The two failure modes are NOT the same question and callers must not merge
|
|
126
|
+
* them: `sysctl(KERN_PROC_PID)` answers rc=0 with a zero-length reply for a PID
|
|
127
|
+
* that does not exist (verified: 999999 → rc 0, size 0), while a sandbox denies
|
|
128
|
+
* the call with a non-zero rc. Reading the second as "dead" would let a sandbox
|
|
129
|
+
* report a live server as gone.
|
|
113
130
|
*/
|
|
114
|
-
function procStatus(pid: number): number | null {
|
|
131
|
+
function procStatus(pid: number): number | 'absent' | null {
|
|
115
132
|
const bound = bindLibs();
|
|
116
133
|
if (bound === null) return null;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const buffer = new Uint8Array(KINFO_PROC_BYTES);
|
|
134
|
+
procMib[3] = pid;
|
|
135
|
+
procSize[0] = BigInt(KINFO_PROC_BYTES);
|
|
120
136
|
try {
|
|
121
|
-
if (bound.libc.symbols.sysctl(ptr(
|
|
137
|
+
if (bound.libc.symbols.sysctl(ptr(procMib), 4, ptr(procBuffer), ptr(procSize), null, 0) !== 0) return null;
|
|
122
138
|
} catch {
|
|
123
139
|
return null;
|
|
124
140
|
}
|
|
125
|
-
if (Number(
|
|
126
|
-
return
|
|
141
|
+
if (Number(procSize[0]) === 0) return 'absent';
|
|
142
|
+
return procBuffer[P_STAT_OFFSET];
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
/**
|
|
@@ -197,21 +213,40 @@ function signalAlive(pid: number): boolean {
|
|
|
197
213
|
}
|
|
198
214
|
}
|
|
199
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Liveness in one lookup, for both the probe and its own `isAlive`/`isZombie`
|
|
218
|
+
* shims. A method calling `this` would break the moment a caller destructured
|
|
219
|
+
* the probe, and the session registry does pass probes around by reference.
|
|
220
|
+
*/
|
|
221
|
+
function darwinLiveness(pid: number): ProcessLiveness {
|
|
222
|
+
const status = procStatus(pid);
|
|
223
|
+
if (status === 'absent') return 'dead';
|
|
224
|
+
if (status !== null) return SZOMB_LIKE.has(status) ? 'zombie' : 'alive';
|
|
225
|
+
// The syscall itself failed (sandbox). A path or a signal still separates
|
|
226
|
+
// "exists" from "gone" — a path read answers for root-owned PIDs, and signal
|
|
227
|
+
// 0 answers for the rest — so only a PID that fails both is reported dead.
|
|
228
|
+
// Anything else is `unknown`, which callers treat as alive-but-unverified.
|
|
229
|
+
if (executablePath(pid) !== null || signalAlive(pid)) return 'alive';
|
|
230
|
+
return 'unknown';
|
|
231
|
+
}
|
|
232
|
+
|
|
200
233
|
export const darwinProbe: ProcessProbe = {
|
|
201
234
|
commandLine(pid) {
|
|
202
235
|
const args = argvOf(pid);
|
|
203
236
|
if (args && args.length > 0) return args.join(' ');
|
|
204
237
|
return executablePath(pid);
|
|
205
238
|
},
|
|
239
|
+
liveness: darwinLiveness,
|
|
206
240
|
isAlive(pid) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
241
|
+
// `procStatus` already reports a zombie as not-alive, so the old
|
|
242
|
+
// `isAlive && !isZombie` pair asked it twice for one answer.
|
|
243
|
+
const liveness = darwinLiveness(pid);
|
|
244
|
+
// `unknown` keeps the previous fallback's bias: a sandboxed host must not
|
|
245
|
+
// make a live server look dead.
|
|
246
|
+
return liveness === 'alive' || liveness === 'unknown';
|
|
211
247
|
},
|
|
212
248
|
isZombie(pid) {
|
|
213
|
-
|
|
214
|
-
return status !== null && SZOMB_LIKE.has(status);
|
|
249
|
+
return darwinLiveness(pid) === 'zombie';
|
|
215
250
|
},
|
|
216
251
|
/**
|
|
217
252
|
* `e_tpgid` — the field `ps -o tpgid=` prints. `proc_pidinfo` is the cheap
|
|
@@ -49,6 +49,20 @@ function statTail(pid: number): string[] | null {
|
|
|
49
49
|
*/
|
|
50
50
|
const TPGID_FIELD = 5;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* `{ state, tpgid }` from ONE `/proc/<pid>/stat` read.
|
|
54
|
+
*
|
|
55
|
+
* `isZombie` and `foregroundGroup` both need this file, and the terminal cap
|
|
56
|
+
* asks both about the same shell; reading it once per question instead of twice
|
|
57
|
+
* is why the two are resolved together.
|
|
58
|
+
*/
|
|
59
|
+
function procStat(pid: number): { state: string; tpgid: number } | null {
|
|
60
|
+
const fields = statTail(pid);
|
|
61
|
+
if (fields === null) return null;
|
|
62
|
+
const tpgid = Number(fields[TPGID_FIELD]);
|
|
63
|
+
return { state: fields[STATE_FIELD], tpgid: Number.isFinite(tpgid) ? tpgid : 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
export const linuxProbe: ProcessProbe = {
|
|
53
67
|
commandLine(pid) {
|
|
54
68
|
const raw = readFile(`/proc/${pid}/cmdline`);
|
|
@@ -59,22 +73,30 @@ export const linuxProbe: ProcessProbe = {
|
|
|
59
73
|
// Kernel threads have no argv — `comm` is all the identity there is.
|
|
60
74
|
return readFile(`/proc/${pid}/comm`)?.trim() || null;
|
|
61
75
|
},
|
|
76
|
+
liveness(pid) {
|
|
77
|
+
// A `/proc` entry exists for a zombie too, so the state field is what
|
|
78
|
+
// separates a corpse from a running process.
|
|
79
|
+
const stat = procStat(pid);
|
|
80
|
+
if (stat === null) {
|
|
81
|
+
// No `stat` file: either the PID is gone or the read failed. `existsSync`
|
|
82
|
+
// keeps the previous answer for a process with no readable stat.
|
|
83
|
+
return fs.existsSync(`/proc/${pid}`) ? 'unknown' : 'dead';
|
|
84
|
+
}
|
|
85
|
+
return ZOMBIE_STATES.has(stat.state) ? 'zombie' : 'alive';
|
|
86
|
+
},
|
|
62
87
|
isAlive(pid) {
|
|
63
88
|
// A `/proc` entry exists for a zombie too, so liveness is "the directory is
|
|
64
89
|
// there"; `isZombie` is what separates a corpse from a running process.
|
|
65
90
|
return fs.existsSync(`/proc/${pid}`);
|
|
66
91
|
},
|
|
67
92
|
isZombie(pid) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
return ZOMBIE_STATES.has(fields[STATE_FIELD]);
|
|
93
|
+
const stat = procStat(pid);
|
|
94
|
+
return stat !== null && ZOMBIE_STATES.has(stat.state);
|
|
71
95
|
},
|
|
72
96
|
foregroundGroup(pid) {
|
|
73
97
|
// Already read for `isZombie` on the same question, so this costs no extra
|
|
74
98
|
// syscall beyond the file read itself.
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const tpgid = Number(fields[TPGID_FIELD]);
|
|
78
|
-
return Number.isFinite(tpgid) ? tpgid : null;
|
|
99
|
+
const stat = procStat(pid);
|
|
100
|
+
return stat === null ? null : stat.tpgid;
|
|
79
101
|
},
|
|
80
102
|
};
|
|
@@ -67,6 +67,13 @@ export const psProbe: ProcessProbe = {
|
|
|
67
67
|
const row = processTable().find((entry) => entry.pid === pid);
|
|
68
68
|
return row && row.command.length > 0 ? row.command : null;
|
|
69
69
|
},
|
|
70
|
+
liveness(pid) {
|
|
71
|
+
// The table already carries the state column, so liveness and zombie-ness
|
|
72
|
+
// are one lookup; the signal check stays for the no-`ps` case.
|
|
73
|
+
const row = processTable().find((entry) => entry.pid === pid);
|
|
74
|
+
if (row) return row.state.startsWith('Z') ? 'zombie' : 'alive';
|
|
75
|
+
return signalAlive(pid) ? 'unknown' : 'dead';
|
|
76
|
+
},
|
|
70
77
|
isAlive(pid) {
|
|
71
78
|
if (!signalAlive(pid)) return false;
|
|
72
79
|
const row = processTable().find((entry) => entry.pid === pid);
|
|
@@ -11,9 +11,26 @@
|
|
|
11
11
|
* PID is never worth failing a request over, and a platform that cannot answer
|
|
12
12
|
* says so (`null` / `false`) so the caller can fall back.
|
|
13
13
|
*/
|
|
14
|
+
export type ProcessLiveness =
|
|
15
|
+
/** The PID exists and is running. */
|
|
16
|
+
| 'alive'
|
|
17
|
+
/** Exited, waiting for its parent to reap it: holds no port, never runs again. */
|
|
18
|
+
| 'zombie'
|
|
19
|
+
/** No such process. */
|
|
20
|
+
| 'dead'
|
|
21
|
+
/** The platform could not answer — callers must NOT read this as "dead". */
|
|
22
|
+
| 'unknown';
|
|
23
|
+
|
|
14
24
|
export interface ProcessProbe {
|
|
15
25
|
/** Command line of a live PID, or null when unreadable. */
|
|
16
26
|
commandLine(pid: number): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Liveness in ONE lookup. This is the question `identity.ts` actually asks,
|
|
29
|
+
* and asking it as `isAlive(pid) && !isZombie(pid)` made the macOS probe
|
|
30
|
+
* issue the identical `sysctl(KERN_PROC_PID)` twice for the same PID
|
|
31
|
+
* (measured 21.3 µs against 5.3 µs for the single read).
|
|
32
|
+
*/
|
|
33
|
+
liveness(pid: number): ProcessLiveness;
|
|
17
34
|
/** True when the PID exists. */
|
|
18
35
|
isAlive(pid: number): boolean;
|
|
19
36
|
/** True when the PID is defunct — exited but not yet reaped by its parent.
|
|
@@ -40,6 +40,11 @@ export const win32Probe: ProcessProbe = {
|
|
|
40
40
|
commandLine() {
|
|
41
41
|
return null;
|
|
42
42
|
},
|
|
43
|
+
liveness(pid) {
|
|
44
|
+
// No zombie state is observable on Windows (a process object lives until
|
|
45
|
+
// its last handle closes), so signal 0 is the whole answer.
|
|
46
|
+
return signalAlive(pid) ? 'alive' : 'dead';
|
|
47
|
+
},
|
|
43
48
|
isAlive: signalAlive,
|
|
44
49
|
isZombie() {
|
|
45
50
|
return false;
|
|
@@ -25,7 +25,7 @@ import { join as joinPath, resolve as resolvePath } from 'node:path';
|
|
|
25
25
|
|
|
26
26
|
import { resolveBunBin } from '@/server/lib/lifecycle/bun';
|
|
27
27
|
import { listInstanceRecords } from '@/server/lib/lifecycle/instance';
|
|
28
|
-
import { resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
28
|
+
import { isCliManaged, resolveLaunchMode, unmanagedReason } from '@/server/lib/lifecycle/launch-mode';
|
|
29
29
|
|
|
30
30
|
/** Package root of the running copy: src/server/lib/lifecycle -> four levels up. */
|
|
31
31
|
const PKG_ROOT = resolvePath(import.meta.dir, '..', '..', '..', '..');
|
|
@@ -45,20 +45,15 @@ export interface SelfRestartPlan {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* One-line reason an instance is left running, shared by the CLI and console.
|
|
49
|
+
* The clause comes from `unmanagedReason` so `stop`, `restart` and `update`
|
|
50
|
+
* cannot classify the same `launchMode` differently.
|
|
51
51
|
*/
|
|
52
|
-
export function isAutoRestartable(launchMode: string | null | undefined): boolean {
|
|
53
|
-
return launchMode === 'daemon' || launchMode === 'foreground';
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** One-line reason an instance is left running, shared by the CLI and console. */
|
|
57
52
|
export function skipRestartNote(launchMode: string | null | undefined): string {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
53
|
+
const nextStep = launchMode === 'direct'
|
|
54
|
+
? 'start it again to apply the update'
|
|
55
|
+
: 'restart it yourself to apply the update';
|
|
56
|
+
return `this instance ${unmanagedReason(launchMode)} — ${nextStep}`;
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
/** The record this process wrote for itself; falls back to the env it was launched with. */
|
|
@@ -89,7 +84,7 @@ export function scheduleSelfRestart(): SelfRestartPlan {
|
|
|
89
84
|
return { restarting: false, message: 'This instance could not be identified — restart it yourself to apply the update.' };
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
if (!
|
|
87
|
+
if (!isCliManaged(self.launchMode)) {
|
|
93
88
|
return { restarting: false, message: skipRestartNote(self.launchMode) };
|
|
94
89
|
}
|
|
95
90
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared lifecycle for the chamber's observer WebSocket streams (agent events,
|
|
8
|
+
* BTW frames). Both routes hand-rolled the same connection state — an
|
|
9
|
+
* unsubscribe slot, a ping/pong keepalive, a latest-wins coalescer, and a
|
|
10
|
+
* teardown that releases all three — so the policy lives here once, exactly as
|
|
11
|
+
* `createSseStream` and `createEventCoalescer` own the SSE side.
|
|
12
|
+
*
|
|
13
|
+
* **Per-connection state must live on `ws.data`, never in a `WeakMap` keyed by
|
|
14
|
+
* the `ws` object.** Elysia's Bun adapter constructs a NEW `ElysiaWS` wrapper
|
|
15
|
+
* for every callback and hands `pong` the RAW `ServerWebSocket` rather than a
|
|
16
|
+
* wrapper at all (verified on 1.4.30: `open` receives `ElysiaWS`, `pong`
|
|
17
|
+
* receives the raw socket, and a `WeakMap` lookup in either misses). A missed
|
|
18
|
+
* `pong` lookup never clears `awaitingPong`, so the second heartbeat tears down
|
|
19
|
+
* a socket the client is still reading — every observer stream died at 60s
|
|
20
|
+
* (measured: a fresh chamber instance closed a healthy socket at 60.005ms with
|
|
21
|
+
* `STREAM_HEARTBEAT_MS` = 30s). A missed `close` lookup leaks the interval, the
|
|
22
|
+
* coalescer and the session subscription for the process's lifetime.
|
|
23
|
+
*
|
|
24
|
+
* `ws.data` is one object shared by every callback, so it is the only stable
|
|
25
|
+
* key. The routes keep their own store object in it and pass it here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createEventCoalescer, type EventCoalescer } from '@/server/lib/sse';
|
|
29
|
+
import { STREAM_HEARTBEAT_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
30
|
+
|
|
31
|
+
/** Coalesce replaceable frames once this many bytes sit unsent. */
|
|
32
|
+
const HIGH_WATER_BYTES = 256 * 1024;
|
|
33
|
+
|
|
34
|
+
/** Longest a coalesced frame may wait before it is flushed anyway, so a burst
|
|
35
|
+
* that ends while the socket is still busy never strands the newest frame. */
|
|
36
|
+
const FLUSH_DELAY_MS = 50;
|
|
37
|
+
|
|
38
|
+
/** The Bun server socket, narrowed to the two members this module reads. */
|
|
39
|
+
interface ObserverRawSocket {
|
|
40
|
+
bufferedAmount?: number;
|
|
41
|
+
ping?: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The Elysia `ws` context, narrowed to the members this module uses. */
|
|
45
|
+
export interface ObserverSocket {
|
|
46
|
+
raw: unknown;
|
|
47
|
+
send: (data: string) => unknown;
|
|
48
|
+
close: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** State a route keeps on `ws.data`; `closed` is also read by its own guards. */
|
|
52
|
+
export interface ObserverConnectionState {
|
|
53
|
+
closed: boolean;
|
|
54
|
+
unsubscribe: (() => void) | null;
|
|
55
|
+
heartbeat: ReturnType<typeof setInterval> | null;
|
|
56
|
+
awaitingPong: boolean;
|
|
57
|
+
coalescer: EventCoalescer | null;
|
|
58
|
+
/** Idempotent release, installed by `attachObserverStream`. The route's
|
|
59
|
+
* `close` handler calls it; before attach it is a no-op. */
|
|
60
|
+
teardown: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A fresh state object for `ws.data`. */
|
|
64
|
+
export function createObserverState(): ObserverConnectionState {
|
|
65
|
+
return {
|
|
66
|
+
closed: false,
|
|
67
|
+
unsubscribe: null,
|
|
68
|
+
heartbeat: null,
|
|
69
|
+
awaitingPong: false,
|
|
70
|
+
coalescer: null,
|
|
71
|
+
teardown: () => {},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What an attached connection exposes back to its route. */
|
|
76
|
+
export interface ObserverStream {
|
|
77
|
+
/** Emit one frame through the coalescer (latest-wins while backpressured). */
|
|
78
|
+
push: (event: string, data: unknown) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Attach the coalescer + keepalive to one accepted connection. Teardown is
|
|
83
|
+
* installed on `state` (the route's `close` handler calls it) and is idempotent
|
|
84
|
+
* from any path: a heartbeat, a send failure, or the route's own exit.
|
|
85
|
+
*
|
|
86
|
+
* The first frame is the route's own `connected` greeting, pushed by the caller.
|
|
87
|
+
*/
|
|
88
|
+
export function attachObserverStream(state: ObserverConnectionState, ws: ObserverSocket): ObserverStream {
|
|
89
|
+
// Elysia exposes the server socket untyped. Both members are optional, so an
|
|
90
|
+
// absent one degrades (no byte count / no keepalive) instead of failing.
|
|
91
|
+
const raw = (ws.raw ?? {}) as ObserverRawSocket;
|
|
92
|
+
|
|
93
|
+
const teardown = (): void => {
|
|
94
|
+
if (state.closed) return;
|
|
95
|
+
state.closed = true;
|
|
96
|
+
state.coalescer?.dispose();
|
|
97
|
+
state.coalescer = null;
|
|
98
|
+
if (state.heartbeat !== null) {
|
|
99
|
+
clearInterval(state.heartbeat);
|
|
100
|
+
state.heartbeat = null;
|
|
101
|
+
}
|
|
102
|
+
if (state.unsubscribe) {
|
|
103
|
+
try {
|
|
104
|
+
state.unsubscribe();
|
|
105
|
+
} catch {
|
|
106
|
+
// Subscriber cleanup is best-effort.
|
|
107
|
+
}
|
|
108
|
+
state.unsubscribe = null;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
ws.close();
|
|
112
|
+
} catch {
|
|
113
|
+
// Already gone.
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
state.coalescer = createEventCoalescer({
|
|
118
|
+
send: (_event, data) => {
|
|
119
|
+
try {
|
|
120
|
+
ws.send(JSON.stringify(data));
|
|
121
|
+
} catch {
|
|
122
|
+
teardown();
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
isBackpressured: () => (raw.bufferedAmount ?? 0) > HIGH_WATER_BYTES,
|
|
126
|
+
flushDelayMs: FLUSH_DELAY_MS,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Protocol-level keepalive: a client that misses a full ping cycle is gone,
|
|
130
|
+
// and a live one answers without any application-level traffic. The pong
|
|
131
|
+
// arrives on the route's `pong` handler, which clears `awaitingPong` through
|
|
132
|
+
// the SAME state object.
|
|
133
|
+
state.heartbeat = setInterval(() => {
|
|
134
|
+
if (state.closed) return;
|
|
135
|
+
if (state.awaitingPong) {
|
|
136
|
+
teardown();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
state.awaitingPong = true;
|
|
140
|
+
if (!state.coalescer?.flush()) return;
|
|
141
|
+
try {
|
|
142
|
+
raw.ping?.();
|
|
143
|
+
} catch {
|
|
144
|
+
teardown();
|
|
145
|
+
}
|
|
146
|
+
}, STREAM_HEARTBEAT_MS);
|
|
147
|
+
|
|
148
|
+
state.teardown = teardown;
|
|
149
|
+
return { push: (event, data) => state.coalescer?.push(event, data) };
|
|
150
|
+
}
|
|
@@ -125,6 +125,15 @@ export const PASSTHROUGH_COMMANDS = new Set([
|
|
|
125
125
|
'login',
|
|
126
126
|
]);
|
|
127
127
|
|
|
128
|
+
// Commands that only READ live process state, with an RPC-free on-disk
|
|
129
|
+
// equivalent. A live session answers them on the fast path; for one the chamber
|
|
130
|
+
// does not manage, the spawn path must refuse them (409) rather than boot an
|
|
131
|
+
// omp child to answer a read. The sidebar renders a roster row for every
|
|
132
|
+
// session in the list, so an unconditional probe started a process per finished
|
|
133
|
+
// session merely looked at (measured: the omp process count grew on a session
|
|
134
|
+
// whose liveness probe had just reported `running: false`).
|
|
135
|
+
export const OBSERVER_ONLY_COMMANDS = new Set(['get_subagents', 'get_subagent_messages']);
|
|
136
|
+
|
|
128
137
|
// Commands that can carry user-attached images to the model. All of them must
|
|
129
138
|
// pass the same server-side per-image/count/aggregate validation before the
|
|
130
139
|
// payload reaches omp — a client is free to POST any of them directly.
|
|
@@ -21,22 +21,12 @@
|
|
|
21
21
|
* goes stale).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { parseSubagentLifecycle,
|
|
25
|
-
import { isRecord } from '@/shared/lib/util/guards';
|
|
26
|
-
import type { SubagentProgress } from '@/shared/types/omp/subagent';
|
|
24
|
+
import { parseSubagentLifecycle, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
|
|
27
25
|
|
|
28
26
|
/** Statuses that end a subagent. Mirrors omp's own registry, which deletes an
|
|
29
27
|
* entry the moment a non-`started` lifecycle arrives. */
|
|
30
28
|
const TERMINAL_STATUS: Record<string, true> = { completed: true, failed: true, aborted: true };
|
|
31
29
|
|
|
32
|
-
/** Progress frames arrive AgentProgress-shaped; some wrappers nest the snapshot. */
|
|
33
|
-
function readProgress(payload: unknown): SubagentProgress | undefined {
|
|
34
|
-
const direct = parseSubagentProgress(payload);
|
|
35
|
-
if (!isRecord(payload) || !isRecord(payload.progress)) return direct;
|
|
36
|
-
const nested = parseSubagentProgress(payload.progress);
|
|
37
|
-
return nested ? { ...nested, id: nested.id ?? direct?.id, index: nested.index ?? direct?.index } : direct;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
30
|
export class SubagentLiveness {
|
|
41
31
|
private readonly lastSeen = new Map<string, number>();
|
|
42
32
|
/** index → key, so an id-less progress frame lands on the entry its lifecycle
|
|
@@ -54,7 +44,7 @@ export class SubagentLiveness {
|
|
|
54
44
|
}
|
|
55
45
|
|
|
56
46
|
if (frame.type === 'subagent_progress') {
|
|
57
|
-
const progress =
|
|
47
|
+
const progress = readSubagentProgressFrame(frame.payload);
|
|
58
48
|
if (!progress) return;
|
|
59
49
|
const index = progress.index !== undefined && progress.index >= 0 ? progress.index : undefined;
|
|
60
50
|
const key = progress.id ?? (index !== undefined ? this.keyByIndex.get(index) ?? `idx:${index}` : undefined);
|
|
@@ -37,6 +37,12 @@ export interface SessionFilePrefix {
|
|
|
37
37
|
text: string;
|
|
38
38
|
size: number;
|
|
39
39
|
mtime: Date;
|
|
40
|
+
/**
|
|
41
|
+
* The same mtime as a raw `stat.mtimeMs`, for callers that key a cache on it.
|
|
42
|
+
* `Date.getTime()` truncates the fractional millisecond APFS reports, so a
|
|
43
|
+
* Date comparison never matches a fresh stat.
|
|
44
|
+
*/
|
|
45
|
+
mtimeMs: number;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export async function readTextPrefix(filePath: string, prefixBytes: number): Promise<SessionFilePrefix> {
|
|
@@ -44,7 +50,7 @@ export async function readTextPrefix(filePath: string, prefixBytes: number): Pro
|
|
|
44
50
|
const stat = await file.stat();
|
|
45
51
|
const prefixLength = Math.min(prefixBytes, stat.size);
|
|
46
52
|
const text = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
|
|
47
|
-
return { text, size: stat.size, mtime: new Date(stat.mtimeMs) };
|
|
53
|
+
return { text, size: stat.size, mtime: new Date(stat.mtimeMs), mtimeMs: stat.mtimeMs };
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/**
|