ompchamber 3.1.1 → 3.3.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/dist/client/index.html +1 -1
- package/dist/client/index.js +306 -298
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/cli/lib/output.js +15 -8
- 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-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +45 -23
- 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/AppearanceSettings.tsx +3 -0
- 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/appearance-settings/EditorFontSection.tsx +78 -0
- 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 +19 -12
- 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/editor/index.tsx +15 -7
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- 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/font.ts +53 -0
- 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/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +134 -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/listener.ts +29 -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/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/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- 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/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -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/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- 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/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/omp/subagent.ts +6 -0
- package/src/shared/types/settings/state.ts +7 -16
- package/dist/client/static/js/index-5yfre1kw.css +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `flock(2)` through `bun:ffi`, for the port-handover lock.
|
|
8
|
+
*
|
|
9
|
+
* The kernel releases a flock when the holding process dies, which is exactly
|
|
10
|
+
* the property a startup lock wants — no PID readback, no stale-holder
|
|
11
|
+
* classification, and no polling loop to detect a dead holder. The previous
|
|
12
|
+
* PID-file scheme needed all three: it wrote a PID, re-read it on every retry,
|
|
13
|
+
* asked the OS whether that PID was still OMPChamber (four `sysctl` calls per
|
|
14
|
+
* question on macOS), and deleted the file to release.
|
|
15
|
+
*
|
|
16
|
+
* `flock` is exported by `libc.dylib` / `libSystem.B.dylib` / `libc.so.6`, so it
|
|
17
|
+
* is reachable the same way `lib/lifecycle/proc/darwin.ts` reaches `sysctl`.
|
|
18
|
+
* Windows has no `flock`, and a host can refuse `dlopen` or answer `ENOLCK`
|
|
19
|
+
* (some NFS mounts) — `flockAvailable()` is a real capability probe, and every
|
|
20
|
+
* caller keeps its own fallback for the false case.
|
|
21
|
+
*
|
|
22
|
+
* **The lock file is never unlinked while it may be held.** Removing it is the
|
|
23
|
+
* classic flock race: another process that already opened the path keeps a
|
|
24
|
+
* handle on the unlinked inode and acquires it successfully while a third
|
|
25
|
+
* process creates a fresh file at the same path and also succeeds. Leaving the
|
|
26
|
+
* file in place makes the inode the lock, which is what flock is for; the file
|
|
27
|
+
* is an empty marker, one per port.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from 'fs';
|
|
31
|
+
import { dlopen, FFIType } from 'bun:ffi';
|
|
32
|
+
import type { FFIFunction, Library } from 'bun:ffi';
|
|
33
|
+
|
|
34
|
+
const LOCK_EX = 2;
|
|
35
|
+
/** Non-blocking: fail immediately rather than waiting inside the syscall. */
|
|
36
|
+
const LOCK_NB = 4;
|
|
37
|
+
|
|
38
|
+
const FLOCK_SYMBOLS = {
|
|
39
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
40
|
+
} satisfies Record<string, FFIFunction>;
|
|
41
|
+
|
|
42
|
+
/** Library names to try, in the order each platform resolves them. */
|
|
43
|
+
const LIBC_NAMES = ['libc.dylib', 'libSystem.B.dylib', 'libc.so.6'] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Held leases and the probe result, on `globalThis` rather than in module
|
|
47
|
+
* bindings.
|
|
48
|
+
*
|
|
49
|
+
* A `bun --hot` reload re-evaluates this module while the process (and its open
|
|
50
|
+
* descriptors) lives on. A fresh module-level Map would forget that this PID
|
|
51
|
+
* already holds the port's lock, so the reloaded server would open a second fd,
|
|
52
|
+
* find the lock taken by its own previous fd, and refuse to start.
|
|
53
|
+
*/
|
|
54
|
+
interface FlockHost {
|
|
55
|
+
/** `lockPath` → the fd holding it and how many acquires it backs. */
|
|
56
|
+
held: Map<string, { fd: number; refs: number }>;
|
|
57
|
+
/** Tri-state capability probe: undefined until asked, then lib-or-null. */
|
|
58
|
+
available: boolean | undefined;
|
|
59
|
+
/** The opened library, shared across reloads. */
|
|
60
|
+
lib: Library<typeof FLOCK_SYMBOLS> | null | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
declare global {
|
|
64
|
+
// eslint-disable-next-line no-var
|
|
65
|
+
var __ompChamberFlock: FlockHost | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function host(): FlockHost {
|
|
69
|
+
return (globalThis.__ompChamberFlock ??= { held: new Map(), available: undefined, lib: undefined });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** libc with `flock`, or null when this host has none. Cached for the process. */
|
|
73
|
+
function libc(): Library<typeof FLOCK_SYMBOLS> | null {
|
|
74
|
+
const state = host();
|
|
75
|
+
if (state.lib !== undefined) return state.lib;
|
|
76
|
+
state.lib = null;
|
|
77
|
+
if (process.platform === 'win32') return state.lib;
|
|
78
|
+
for (const name of LIBC_NAMES) {
|
|
79
|
+
try {
|
|
80
|
+
state.lib = dlopen(name, FLOCK_SYMBOLS);
|
|
81
|
+
return state.lib;
|
|
82
|
+
} catch {
|
|
83
|
+
// Try the next name; a host without any of them keeps the null answer.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return state.lib;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* True when `flock(2)` actually works here — the symbol resolving is not
|
|
91
|
+
* enough, because a filesystem can answer `ENOLCK`. Probed once, on a
|
|
92
|
+
* throwaway file, so a caller can pick its mechanism up front instead of
|
|
93
|
+
* discovering mid-wait that the syscall never works.
|
|
94
|
+
*/
|
|
95
|
+
export function flockAvailable(): boolean {
|
|
96
|
+
const state = host();
|
|
97
|
+
if (state.available !== undefined) return state.available;
|
|
98
|
+
state.available = false;
|
|
99
|
+
const symbols = libc();
|
|
100
|
+
if (!symbols) return false;
|
|
101
|
+
const probePath = `${process.env.TMPDIR ?? '/tmp'}/ompchamber-flock-${process.pid}`;
|
|
102
|
+
let fd: number | null = null;
|
|
103
|
+
try {
|
|
104
|
+
fd = fs.openSync(probePath, 'a');
|
|
105
|
+
const ok = symbols.symbols.flock(fd, LOCK_EX | LOCK_NB) === 0;
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
fd = null;
|
|
108
|
+
fs.rmSync(probePath, { force: true });
|
|
109
|
+
if (!ok) {
|
|
110
|
+
// The probe file was somehow already locked — treat the capability as
|
|
111
|
+
// unproven rather than reporting a lock we do not hold.
|
|
112
|
+
state.lib = null;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
state.available = true;
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
state.lib = null;
|
|
119
|
+
return false;
|
|
120
|
+
} finally {
|
|
121
|
+
if (fd !== null) {
|
|
122
|
+
try {
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
} catch {
|
|
125
|
+
// Already closed.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface FlockHandle {
|
|
132
|
+
/** Release the lease. Idempotent. */
|
|
133
|
+
release: () => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Take `LOCK_EX | LOCK_NB` on `lockPath`, or null when another process holds
|
|
138
|
+
* it. Returns null too when `flock` is unavailable — callers must then use
|
|
139
|
+
* their own mechanism.
|
|
140
|
+
*
|
|
141
|
+
* The fd is kept open for as long as the lease lives: closing it is what
|
|
142
|
+
* releases the lock, and the kernel does the same on process death.
|
|
143
|
+
*/
|
|
144
|
+
export function tryFlock(lockPath: string): FlockHandle | null {
|
|
145
|
+
const state = host();
|
|
146
|
+
const existing = state.held.get(lockPath);
|
|
147
|
+
if (existing) {
|
|
148
|
+
// Re-entrant within one process: flock is per open-file-description, so a
|
|
149
|
+
// second fd here would block against our own lock.
|
|
150
|
+
existing.refs += 1;
|
|
151
|
+
return { release: () => releaseFlock(lockPath) };
|
|
152
|
+
}
|
|
153
|
+
const symbols = libc();
|
|
154
|
+
if (!symbols) return null;
|
|
155
|
+
let fd: number;
|
|
156
|
+
try {
|
|
157
|
+
// `a` creates it when absent and never truncates; the file's content is
|
|
158
|
+
// irrelevant (the inode is the lock), but the PID is useful when a human
|
|
159
|
+
// looks at the directory.
|
|
160
|
+
fd = fs.openSync(lockPath, 'a');
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
let acquired = false;
|
|
165
|
+
try {
|
|
166
|
+
acquired = symbols.symbols.flock(fd, LOCK_EX | LOCK_NB) === 0;
|
|
167
|
+
} catch {
|
|
168
|
+
acquired = false;
|
|
169
|
+
}
|
|
170
|
+
if (!acquired) {
|
|
171
|
+
try {
|
|
172
|
+
fs.closeSync(fd);
|
|
173
|
+
} catch {
|
|
174
|
+
// Already closed.
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(fd, String(process.pid));
|
|
180
|
+
} catch {
|
|
181
|
+
// Informational only.
|
|
182
|
+
}
|
|
183
|
+
state.held.set(lockPath, { fd, refs: 1 });
|
|
184
|
+
return { release: () => releaseFlock(lockPath) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function releaseFlock(lockPath: string): void {
|
|
188
|
+
const state = host();
|
|
189
|
+
const entry = state.held.get(lockPath);
|
|
190
|
+
if (!entry) return;
|
|
191
|
+
entry.refs -= 1;
|
|
192
|
+
if (entry.refs > 0) return;
|
|
193
|
+
state.held.delete(lockPath);
|
|
194
|
+
try {
|
|
195
|
+
fs.closeSync(entry.fd);
|
|
196
|
+
} catch {
|
|
197
|
+
// Already closed; the kernel released the lock either way.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -37,7 +37,11 @@ export type ProcessState = 'dead' | 'matched' | 'mismatched' | 'unknown';
|
|
|
37
37
|
*/
|
|
38
38
|
export function isProcessAlive(pid: number): boolean {
|
|
39
39
|
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
40
|
-
|
|
40
|
+
const liveness = processProbe.liveness(pid);
|
|
41
|
+
// A zombie is NOT alive: it holds no port and will never run again. An
|
|
42
|
+
// `unknown` (a probe that could not answer) keeps the previous bias and
|
|
43
|
+
// counts as alive, so a sandboxed host cannot make a live server look dead.
|
|
44
|
+
return liveness === 'alive' || liveness === 'unknown';
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The HTTP listener this process is serving on.
|
|
8
|
+
*
|
|
9
|
+
* Two consumers need it and neither can import it: the shell renderer, which
|
|
10
|
+
* asks the listener for its own `/_shell` route (Bun renders an HTML route only
|
|
11
|
+
* while serving), and the dev asset proxy, which asks it for the assets Bun's
|
|
12
|
+
* own routing table owns. The reference is injected rather than imported because
|
|
13
|
+
* `index.ts` owns the listener and imports both of them — a direct import would
|
|
14
|
+
* be a cycle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Listener = { url: URL } | null;
|
|
18
|
+
|
|
19
|
+
let listener: Listener = null;
|
|
20
|
+
|
|
21
|
+
/** Called by the server entry once its listener is up. */
|
|
22
|
+
export function setListener(server: { url: URL }): void {
|
|
23
|
+
listener = server;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The listener's base URL, or null before it is up. */
|
|
27
|
+
export function listenerUrl(): URL | null {
|
|
28
|
+
return listener?.url ?? null;
|
|
29
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import fs from 'fs';
|
|
24
24
|
|
|
25
|
+
import { flockAvailable, tryFlock } from '@/server/lib/lifecycle/flock';
|
|
25
26
|
import { getProcessState } from '@/server/lib/lifecycle/identity';
|
|
26
27
|
import { describeOccupant, findOccupant } from '@/server/lib/lifecycle/occupant';
|
|
27
28
|
import { getLockPath } from '@/server/lib/lifecycle/paths';
|
|
@@ -48,13 +49,27 @@ export interface PortLock {
|
|
|
48
49
|
* Two starters racing on one free port would each probe it as free and then
|
|
49
50
|
* fight over the bind, with the loser reporting a bare `EADDRINUSE` against a
|
|
50
51
|
* process it cannot identify yet. Holding the lock across probe-and-bind makes
|
|
51
|
-
* the loser fail with a message that names the actual situation.
|
|
52
|
-
* holder is dead (or is not OMPChamber, i.e. a recycled PID) is reclaimed.
|
|
52
|
+
* the loser fail with a message that names the actual situation.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
54
|
+
* The mechanism is `flock(2)` when the host has it (see `flock.ts`): the kernel
|
|
55
|
+
* releases it on process death, so there is no stale-holder case to detect and
|
|
56
|
+
* no polling loop. The PID-file path below stays as the fallback for a host
|
|
57
|
+
* without `flock` (Windows, a filesystem answering `ENOLCK`); it needs the
|
|
58
|
+
* extra machinery — PID readback, `getProcessState` classification, and
|
|
59
|
+
* reclaiming a lock whose holder died or whose PID was recycled.
|
|
55
60
|
*/
|
|
56
61
|
export async function acquirePortLock(port: number, waitMs = LOCK_WAIT_MS): Promise<PortLock | null> {
|
|
57
62
|
const lockPath = getLockPath(port);
|
|
63
|
+
if (flockAvailable()) {
|
|
64
|
+
const deadline = Date.now() + waitMs;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const handle = tryFlock(lockPath);
|
|
67
|
+
if (handle) return handle;
|
|
68
|
+
if (Date.now() >= deadline) return null;
|
|
69
|
+
await Bun.sleep(150);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
const deadline = Date.now() + waitMs;
|
|
59
74
|
for (;;) {
|
|
60
75
|
try {
|
|
@@ -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;
|
|
@@ -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.
|