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.
Files changed (124) hide show
  1. package/README.md +6 -3
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +279 -280
  4. package/dist/client/static/js/index-01dv383b.css +1 -0
  5. package/dist/client/static/js/{index-8t70jhyj.js → index-1q6165r3.js} +641 -641
  6. package/package.json +1 -1
  7. package/src/cli/lib/args.js +4 -3
  8. package/src/cli/lib/commands/restart.js +7 -6
  9. package/src/cli/lib/commands/stop.js +38 -31
  10. package/src/cli/lib/commands/update.js +3 -2
  11. package/src/cli/lib/output.js +15 -8
  12. package/src/cli/lib/stop-scope.js +23 -0
  13. package/src/cli/lib/stop-scope.test.js +41 -0
  14. package/src/client/components/common/Modal.tsx +14 -4
  15. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  16. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  17. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  18. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  19. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  20. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  21. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  22. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  23. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  24. package/src/client/components/settings/Modal.tsx +35 -6
  25. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  26. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  27. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  28. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  29. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  30. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  31. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  32. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  33. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  34. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  35. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  38. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  39. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  40. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  42. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  43. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  44. package/src/client/components/settings/master-detail/index.tsx +68 -0
  45. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  47. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  57. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  58. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  59. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  60. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  61. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  62. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  63. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  64. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  65. package/src/client/hooks/chat/subagent/index.ts +37 -11
  66. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  67. package/src/client/hooks/settings/master-detail.ts +33 -0
  68. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  69. package/src/server/lib/bundler/css.test.ts +87 -0
  70. package/src/server/lib/bundler/css.ts +17 -2
  71. package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
  72. package/src/server/lib/bundler/nested-at-rules.ts +146 -0
  73. package/src/server/lib/db/client.ts +5 -1
  74. package/src/server/lib/fs/git-diff.ts +34 -10
  75. package/src/server/lib/fs/git-repos.ts +81 -0
  76. package/src/server/lib/lifecycle/flock.ts +199 -0
  77. package/src/server/lib/lifecycle/identity.ts +5 -1
  78. package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
  79. package/src/server/lib/lifecycle/launch-mode.ts +26 -0
  80. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  81. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  82. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  83. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  84. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  85. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  86. package/src/server/lib/lifecycle/restart.ts +9 -14
  87. package/src/server/lib/observer-ws.ts +150 -0
  88. package/src/server/lib/omp/rpc/constants.ts +9 -0
  89. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  90. package/src/server/lib/omp/session/file-scan.ts +7 -1
  91. package/src/server/lib/omp/session/files.ts +95 -96
  92. package/src/server/lib/omp/session/locator.ts +18 -8
  93. package/src/server/lib/omp/session/messages.ts +7 -7
  94. package/src/server/lib/omp/session/scan.ts +108 -0
  95. package/src/server/lib/omp/session/stats.ts +50 -50
  96. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  97. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  98. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  99. package/src/server/lib/terminal/runtime.server.ts +3 -0
  100. package/src/server/lib/terminal/session.server.ts +30 -3
  101. package/src/server/routes/agent/command.test.ts +69 -0
  102. package/src/server/routes/agent/command.ts +14 -0
  103. package/src/server/routes/agent/ws.test.ts +159 -0
  104. package/src/server/routes/agent/ws.ts +22 -97
  105. package/src/server/routes/btw/ws.ts +22 -95
  106. package/src/server/routes/fs/git.ts +61 -70
  107. package/src/server/routes/omp/session-stats.ts +1 -1
  108. package/src/server/routes/telemetry/tokens.ts +6 -4
  109. package/src/server/routes/terminal/run.ts +4 -8
  110. package/src/server/routes/terminal/ws.ts +24 -2
  111. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  112. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  113. package/src/shared/lib/code/language.ts +146 -0
  114. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  115. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  116. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  117. package/src/shared/lib/fs/diff-parser.ts +67 -42
  118. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  119. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  120. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  121. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  122. package/src/shared/types/omp/subagent.ts +6 -0
  123. package/dist/client/static/js/index-n4wkqj7t.css +0 -1
  124. 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 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;
@@ -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
- * True when the CLI owns this instance's lifecycle and may replace it. `direct`
49
- * is every server OMPChamber did not start: `bun run dev`, `bun run start`, a
50
- * manual `bun src/server/index.ts`, or an external supervisor.
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
- if (launchMode === 'direct') {
59
- return 'this instance runs from source (`bun run dev` / `bun run start`) — start it again to apply the update';
60
- }
61
- return 'this instance is not managed by the ompchamber CLI — restart it yourself to apply the update';
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 (!isAutoRestartable(self.launchMode)) {
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, parseSubagentProgress } from '@/shared/lib/omp/subagent/parse';
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 = readProgress(frame.payload);
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
  /**