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.
Files changed (151) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +306 -298
  3. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/cli/lib/output.js +15 -8
  8. package/src/client/components/common/Modal.tsx +14 -4
  9. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  10. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  11. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  12. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  13. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  14. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  15. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  16. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
  17. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  18. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  20. package/src/client/components/settings/Modal.tsx +45 -23
  21. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  22. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  23. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  24. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  25. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  26. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  27. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  28. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  29. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  30. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  31. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  32. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  33. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  35. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  36. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  38. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  39. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  40. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  42. package/src/client/components/settings/master-detail/index.tsx +68 -0
  43. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  45. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
  46. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  55. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  56. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  57. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  58. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  59. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  60. package/src/client/components/workspace/editor/index.tsx +15 -7
  61. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  62. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  63. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  64. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  65. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  66. package/src/client/data/theme/terminal.ts +25 -8
  67. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  68. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  69. package/src/client/hooks/chat/subagent/index.ts +37 -11
  70. package/src/client/hooks/editor/font.ts +53 -0
  71. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  72. package/src/client/hooks/settings/master-detail.ts +33 -0
  73. package/src/client/hooks/ui/clipboard.ts +17 -0
  74. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  75. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  76. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  77. package/src/client/hooks/ui/text-overflow.ts +78 -0
  78. package/src/client/hooks/ui/touch-device.ts +46 -0
  79. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  80. package/src/client/main.tsx +1 -0
  81. package/src/server/index.ts +4 -3
  82. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  83. package/src/server/lib/assets/conditional.server.ts +54 -0
  84. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  85. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  86. package/src/server/lib/assets/fonts.server.ts +9 -4
  87. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  88. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  89. package/src/server/lib/bundler/dependencies.test.ts +134 -0
  90. package/src/server/lib/db/client.ts +5 -1
  91. package/src/server/lib/fs/git-diff.ts +34 -10
  92. package/src/server/lib/fs/git-repos.ts +81 -0
  93. package/src/server/lib/lifecycle/flock.ts +199 -0
  94. package/src/server/lib/lifecycle/identity.ts +5 -1
  95. package/src/server/lib/lifecycle/listener.ts +29 -0
  96. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  97. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  98. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  99. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  100. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  101. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  102. package/src/server/lib/observer-ws.ts +150 -0
  103. package/src/server/lib/omp/rpc/constants.ts +9 -0
  104. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  105. package/src/server/lib/omp/session/file-scan.ts +7 -1
  106. package/src/server/lib/omp/session/files.ts +95 -96
  107. package/src/server/lib/omp/session/locator.ts +18 -8
  108. package/src/server/lib/omp/session/messages.ts +7 -7
  109. package/src/server/lib/omp/session/scan.ts +108 -0
  110. package/src/server/lib/omp/session/stats.ts +50 -50
  111. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  112. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  113. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  114. package/src/server/lib/terminal/runtime.server.ts +3 -0
  115. package/src/server/lib/terminal/session.server.ts +30 -3
  116. package/src/server/plugins/shell.server.ts +52 -13
  117. package/src/server/plugins/ssr.ts +43 -7
  118. package/src/server/plugins/static.ts +51 -18
  119. package/src/server/routes/agent/command.test.ts +69 -0
  120. package/src/server/routes/agent/command.ts +14 -0
  121. package/src/server/routes/agent/ws.test.ts +159 -0
  122. package/src/server/routes/agent/ws.ts +22 -97
  123. package/src/server/routes/btw/ws.ts +22 -95
  124. package/src/server/routes/fs/git.ts +61 -70
  125. package/src/server/routes/omp/session-stats.ts +1 -1
  126. package/src/server/routes/telemetry/tokens.ts +6 -4
  127. package/src/server/routes/terminal/run.ts +4 -8
  128. package/src/server/routes/terminal/ws.ts +24 -2
  129. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  130. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  131. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  132. package/src/shared/lib/code/editor/typography.ts +86 -0
  133. package/src/shared/lib/code/language.ts +146 -0
  134. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  135. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  136. package/src/shared/lib/fonts/NOTICE.md +44 -0
  137. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  138. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  139. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  140. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  141. package/src/shared/lib/fs/diff-parser.ts +67 -42
  142. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  143. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  144. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  145. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  146. package/src/shared/lib/settings/diff.test.ts +1 -1
  147. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  148. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  149. package/src/shared/types/omp/subagent.ts +6 -0
  150. package/src/shared/types/settings/state.ts +7 -16
  151. 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
- return processProbe.isAlive(pid) && !processProbe.isZombie(pid);
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. A lock whose
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
- * Returns null when another starter holds the lock through the wait window.
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 live PID, or null when the PID does not exist.
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
- * A zero-length result is the kernel saying "no such process"; the call itself
112
- * failing (rc !== 0) is reported as null too, and callers then fall back.
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
- const mib = new Int32Array([CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]);
118
- const size = new BigUint64Array([BigInt(KINFO_PROC_BYTES)]);
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(mib), 4, ptr(buffer), ptr(size), null, 0) !== 0) return null;
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(size[0]) === 0) return null;
126
- return buffer[P_STAT_OFFSET];
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
- const status = procStatus(pid);
208
- if (status !== null) return !SZOMB_LIKE.has(status);
209
- // The syscall itself failed (sandbox) — liveness alone is all that is left.
210
- return executablePath(pid) !== null || signalAlive(pid);
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
- const status = procStatus(pid);
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 fields = statTail(pid);
69
- if (fields === null) return false;
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 fields = statTail(pid);
76
- if (fields === null) return null;
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.