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
@@ -7,6 +7,10 @@
7
7
  * Server-side pagination for the raw messages panel. Scans the session JSONL
8
8
  * like ./telemetry.ts but materializes at most one page of `RawMessageItem`s,
9
9
  * so the payload-heavy entries never leave the server in bulk.
10
+ *
11
+ * The sliding window keeps MATCHES, and only the page that is returned is
12
+ * built — building every match to discard all but one page cost 48.9 ms on an
13
+ * 11 MB session against 1.9 ms for the page itself.
10
14
  */
11
15
 
12
16
  import type { RawMessageItem } from '@/shared/types/context';
@@ -33,14 +37,20 @@ export async function computeRawMessagesPage(
33
37
  role: RawMessageRole = 'all',
34
38
  ): Promise<RawMessagesPage> {
35
39
  const windowSize = Math.max(1, page) * pageSize;
36
- const window: RawMessageItem[] = [];
40
+ // The window holds the last `windowSize` MATCHES, not built items. Building
41
+ // every match and discarding all but one page was the whole cost of this
42
+ // endpoint: on an 11 MB session, `buildRawItem` over all 1017 matches took
43
+ // 48.9 ms while building only the 50 that are returned took 1.9 ms — the scan
44
+ // that collects them is 0.2 ms.
45
+ const window: { entry: OmpMessageEntry; msg: OmpMessage; index: number }[] = [];
46
+ let head = 0;
37
47
  let total = 0;
38
48
  let filteredTotal = 0;
39
- let header: OmpMessageEntry | undefined;
49
+ let cwd = '';
40
50
 
41
51
  await scanSessionEntries(filePath, (entry, index) => {
42
- if (entry.type === 'session' && !header) {
43
- header = entry;
52
+ if (entry.type === 'session') {
53
+ if (!cwd) cwd = entry.cwd ?? '';
44
54
  return;
45
55
  }
46
56
  if (entry.type !== 'message' || !entry.message) return;
@@ -54,8 +64,8 @@ export async function computeRawMessagesPage(
54
64
  if (role !== 'all' && msgRole !== role) return;
55
65
  filteredTotal++;
56
66
 
57
- if (window.length >= windowSize) window.shift();
58
- window.push(buildRawItem(entry, msg, header?.cwd ?? '', index));
67
+ window.push({ entry, msg, index });
68
+ if (window.length - head > windowSize) head++;
59
69
  });
60
70
 
61
71
  // `window` holds the last `windowSize` matches, oldest→newest, where
@@ -63,9 +73,15 @@ export async function computeRawMessagesPage(
63
73
  // rows: skip the (page-1) newest chunks, take one, flip for display. Both
64
74
  // bounds clamp at 0 so out-of-range pages return an empty list.
65
75
  const skipNewest = (Math.max(1, page) - 1) * pageSize;
66
- const end = Math.max(0, window.length - skipNewest);
76
+ const live = window.length - head;
77
+ const end = Math.max(0, live - skipNewest);
67
78
  const start = Math.max(0, end - pageSize);
68
- const items = window.slice(start, end).reverse();
79
+ const items: RawMessageItem[] = [];
80
+ for (let i = head + start; i < head + end; i++) {
81
+ const match = window[i];
82
+ items.push(buildRawItem(match.entry, match.msg, cwd, match.index));
83
+ }
84
+ items.reverse();
69
85
  return { items, total, filteredTotal };
70
86
  }
71
87
 
@@ -23,7 +23,10 @@ export type UsageWindow =
23
23
  | { kind: 'custom'; from: string; to: string }
24
24
  | { kind: 'all' };
25
25
 
26
- const PRESET_DAYS: Record<Exclude<TimeRangeType, 'custom' | 'all'>, number> = { today: 1, '7d': 7, '30d': 30, '90d': 90 };
26
+ const PRESET_DAYS: Record<Exclude<TimeRangeType, 'custom' | 'all' | 'today'>, number> = { '7d': 7, '30d': 30, '90d': 90 };
27
+
28
+ /** Every range the tokens endpoint reports, in display order. */
29
+ export const PRESET_RANGES: Exclude<TimeRangeType, 'custom'>[] = ['today', '7d', '30d', '90d', 'all'];
27
30
 
28
31
  const TTL_MS = 60_000;
29
32
  const cache = new Map<string, { at: number; data: UsageAggregate }>();
@@ -75,49 +78,65 @@ function bump(map: Map<string, { tokens: number; cost: number }>, key: string, t
75
78
  map.set(key, current);
76
79
  }
77
80
 
78
- function inRange(timestampIso: string | undefined, window: UsageWindow, now: number): boolean {
79
- if (window.kind === 'all') return true;
80
- if (!timestampIso) return true; // keep entries without timestamps (conservative)
81
- const ts = Date.parse(timestampIso);
82
- if (Number.isNaN(ts)) return true;
83
- if (window.kind === 'custom') {
84
- const from = Date.parse(`${window.from}T00:00:00`);
85
- const to = Date.parse(`${window.to}T23:59:59.999`);
86
- if (Number.isNaN(from) || Number.isNaN(to) || from > to) return true;
87
- return ts >= from && ts <= to;
88
- }
89
- if (window.range === 'today') {
90
- const d = new Date(ts);
91
- const n = new Date(now);
92
- return d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate();
93
- }
94
- if (window.range === 'all') return true;
95
- return now - ts <= PRESET_DAYS[window.range] * 24 * 60 * 60 * 1000;
96
- }
97
-
98
81
  function cacheKey(window: UsageWindow): string {
99
82
  if (window.kind === 'all') return 'all';
100
83
  if (window.kind === 'custom') return `custom:${window.from}:${window.to}`;
101
84
  return window.range;
102
85
  }
103
86
 
104
- /** Aggregate all omp session files within a time window. Cached for 60s. */
105
- export async function aggregateUsage(window: UsageWindow): Promise<UsageAggregate> {
106
- const key = cacheKey(window);
107
- const cached = cache.get(key);
108
- if (cached && Date.now() - cached.at < TTL_MS) return cached.data;
87
+ /**
88
+ * One usage record, with everything a window needs to decide whether it counts.
89
+ *
90
+ * The scan that produces these is the expensive part — measured 795 ms over 455
91
+ * transcripts / 327 MB — so it runs ONCE and every window is derived from the
92
+ * same array. Aggregating per window re-read the whole tree for each of the five
93
+ * presets plus the active window, which is why `/api/telemetry/tokens` took
94
+ * 3.3 s cold.
95
+ */
96
+ interface UsageRecord {
97
+ /** Epoch ms of the entry's timestamp, or undefined when it has none. */
98
+ at: number | undefined;
99
+ /** `YYYY-MM-DD` of `at`, or undefined. */
100
+ day: string | undefined;
101
+ model: string;
102
+ project: string;
103
+ cost: number;
104
+ costInput: number;
105
+ costOutput: number;
106
+ costCacheRead: number;
107
+ costCacheWrite: number;
108
+ input: number;
109
+ output: number;
110
+ cacheRead: number;
111
+ cacheWrite: number;
112
+ reasoning: number;
113
+ total: number;
114
+ }
115
+
116
+ interface UsageScan {
117
+ at: number;
118
+ records: UsageRecord[];
119
+ /** Transcripts visited, counted once for every window. */
120
+ scannedTranscripts: number;
121
+ }
122
+
123
+ let scanCache: UsageScan | null = null;
124
+
125
+ /** Every usage record in the sessions tree, read at most once per TTL. */
126
+ async function scanUsage(): Promise<UsageScan> {
127
+ if (scanCache && Date.now() - scanCache.at < TTL_MS) return scanCache;
109
128
 
110
129
  const sessionsRoot = getSessionsDir();
111
- const aggregate = emptyAggregate();
112
- const now = Date.now();
130
+ const records: UsageRecord[] = [];
131
+ let scannedTranscripts = 0;
113
132
  let projectDirs: string[] = [];
114
133
  try {
115
134
  projectDirs = (await fs.promises.readdir(sessionsRoot, { withFileTypes: true }))
116
135
  .filter((d) => d.isDirectory() || d.isFile())
117
136
  .map((d) => d.name);
118
137
  } catch {
119
- cache.set(key, { at: Date.now(), data: aggregate });
120
- return aggregate;
138
+ scanCache = { at: Date.now(), records, scannedTranscripts };
139
+ return scanCache;
121
140
  }
122
141
 
123
142
  for (const project of projectDirs) {
@@ -136,37 +155,33 @@ export async function aggregateUsage(window: UsageWindow): Promise<UsageAggregat
136
155
  }
137
156
 
138
157
  for (const file of files) {
139
- aggregate.scannedTranscripts++;
158
+ scannedTranscripts++;
140
159
  try {
141
160
  const ufile = Bun.file(file);
142
161
  if ((await ufile.stat()).size > 64 * 1024 * 1024) continue;
143
162
  const entries = parseJsonlLenient<OmpMessageEntry>(await ufile.text());
144
163
  for (const entry of entries) {
145
164
  if (entry.type !== 'message' || !entry.message?.usage) continue;
146
- const ts = entry.timestamp;
147
- if (!inRange(ts, window, now)) continue;
148
165
  const usage = entry.message.usage;
149
- const cost = usage.cost?.total ?? 0;
150
- const total = usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
151
- aggregate.usageRecords++;
152
- aggregate.costTotal += cost;
153
- aggregate.costInput += usage.cost?.input ?? 0;
154
- aggregate.costOutput += usage.cost?.output ?? 0;
155
- aggregate.costCacheRead += usage.cost?.cacheRead ?? 0;
156
- aggregate.costCacheWrite += usage.cost?.cacheWrite ?? 0;
157
- aggregate.inputTokens += usage.input ?? 0;
158
- aggregate.outputTokens += usage.output ?? 0;
159
- aggregate.cacheReadTokens += usage.cacheRead ?? 0;
160
- aggregate.cacheWriteTokens += usage.cacheWrite ?? 0;
161
- aggregate.reasoningTokens += usage.reasoningTokens ?? 0;
162
- if (ts) {
163
- const day = ts.slice(0, 10);
164
- aggregate.activeDays.add(day);
165
- bump(aggregate.byDay, day, total, cost);
166
- }
167
- const model = entry.message.model || 'unknown';
168
- bump(aggregate.byModel, model, total, cost);
169
- bump(aggregate.byProject, project, total, cost);
166
+ const ts = entry.timestamp;
167
+ const parsed = ts ? Date.parse(ts) : Number.NaN;
168
+ records.push({
169
+ at: Number.isNaN(parsed) ? undefined : parsed,
170
+ day: ts ? ts.slice(0, 10) : undefined,
171
+ model: entry.message.model || 'unknown',
172
+ project,
173
+ cost: usage.cost?.total ?? 0,
174
+ costInput: usage.cost?.input ?? 0,
175
+ costOutput: usage.cost?.output ?? 0,
176
+ costCacheRead: usage.cost?.cacheRead ?? 0,
177
+ costCacheWrite: usage.cost?.cacheWrite ?? 0,
178
+ input: usage.input ?? 0,
179
+ output: usage.output ?? 0,
180
+ cacheRead: usage.cacheRead ?? 0,
181
+ cacheWrite: usage.cacheWrite ?? 0,
182
+ reasoning: usage.reasoningTokens ?? 0,
183
+ total: usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0),
184
+ });
170
185
  }
171
186
  } catch {
172
187
  // Unreadable transcript — skip rather than failing the whole report.
@@ -174,8 +189,108 @@ export async function aggregateUsage(window: UsageWindow): Promise<UsageAggregat
174
189
  }
175
190
  }
176
191
 
177
- const data = aggregate;
192
+ scanCache = { at: Date.now(), records, scannedTranscripts };
193
+ return scanCache;
194
+ }
195
+
196
+ /** Fold the scanned records that fall inside `window` into an aggregate. */
197
+ function foldWindow(records: readonly UsageRecord[], window: UsageWindow, now: number, scannedTranscripts: number): UsageAggregate {
198
+ const aggregate = emptyAggregate();
199
+ aggregate.scannedTranscripts = scannedTranscripts;
200
+ const bounds = windowBounds(window, now);
201
+ for (const record of records) {
202
+ // An entry with no usable timestamp is kept by EVERY window — a missing
203
+ // date is not evidence of age, and this matches the previous per-record
204
+ // behaviour.
205
+ if (record.at !== undefined && (record.at < bounds.from || record.at > bounds.to)) continue;
206
+ aggregate.usageRecords++;
207
+ aggregate.costTotal += record.cost;
208
+ aggregate.costInput += record.costInput;
209
+ aggregate.costOutput += record.costOutput;
210
+ aggregate.costCacheRead += record.costCacheRead;
211
+ aggregate.costCacheWrite += record.costCacheWrite;
212
+ aggregate.inputTokens += record.input;
213
+ aggregate.outputTokens += record.output;
214
+ aggregate.cacheReadTokens += record.cacheRead;
215
+ aggregate.cacheWriteTokens += record.cacheWrite;
216
+ aggregate.reasoningTokens += record.reasoning;
217
+ if (record.day) {
218
+ aggregate.activeDays.add(record.day);
219
+ bump(aggregate.byDay, record.day, record.total, record.cost);
220
+ }
221
+ bump(aggregate.byModel, record.model, record.total, record.cost);
222
+ bump(aggregate.byProject, record.project, record.total, record.cost);
223
+ }
224
+ return aggregate;
225
+ }
226
+
227
+ interface WindowBounds {
228
+ /** Inclusive epoch-ms bounds; `-Infinity`/`Infinity` for an unbounded side. */
229
+ from: number;
230
+ to: number;
231
+ }
232
+
233
+ /**
234
+ * Epoch-ms bounds for a window, computed once per fold.
235
+ *
236
+ * `today` is a local CALENDAR day (`setHours(0,0,0,0)`), not "the last 24h":
237
+ * the panel's "today" tab counts the day the user is in, and the previous
238
+ * implementation compared year/month/date for the same reason.
239
+ */
240
+ function windowBounds(window: UsageWindow, now: number): WindowBounds {
241
+ if (window.kind === 'custom') {
242
+ const from = Date.parse(`${window.from}T00:00:00`);
243
+ const to = Date.parse(`${window.to}T23:59:59.999`);
244
+ if (Number.isNaN(from) || Number.isNaN(to) || from > to) return { from: -Infinity, to: Infinity };
245
+ return { from, to };
246
+ }
247
+ if (window.kind === 'all') return { from: -Infinity, to: Infinity };
248
+ switch (window.range) {
249
+ case 'all':
250
+ return { from: -Infinity, to: Infinity };
251
+ case 'today': {
252
+ const start = new Date(now);
253
+ start.setHours(0, 0, 0, 0);
254
+ const end = new Date(start);
255
+ end.setDate(end.getDate() + 1);
256
+ return { from: start.getTime(), to: end.getTime() - 1 };
257
+ }
258
+ default:
259
+ return { from: now - PRESET_DAYS[window.range] * 24 * 60 * 60 * 1000, to: Infinity };
260
+ }
261
+ }
262
+
263
+ /** Aggregate all omp session files within a time window. Cached for 60s. */
264
+ export async function aggregateUsage(window: UsageWindow): Promise<UsageAggregate> {
265
+ const key = cacheKey(window);
266
+ const cached = cache.get(key);
267
+ if (cached && Date.now() - cached.at < TTL_MS) return cached.data;
268
+
269
+ const scan = await scanUsage();
270
+ const data = foldWindow(scan.records, window, Date.now(), scan.scannedTranscripts);
178
271
  if (cache.size > 20) cache.clear();
179
272
  cache.set(key, { at: Date.now(), data });
180
273
  return data;
181
274
  }
275
+
276
+ /**
277
+ * Aggregate EVERY preset range plus `window` from one scan.
278
+ *
279
+ * The tokens endpoint needs all six, and folding them from one scan costs the
280
+ * fold (linear in records) rather than six more tree reads.
281
+ */
282
+ export async function aggregateUsageWindows(
283
+ window: UsageWindow,
284
+ ): Promise<{ ranges: Record<Exclude<TimeRangeType, 'custom'>, UsageAggregate>; active: UsageAggregate }> {
285
+ const scan = await scanUsage();
286
+ const now = Date.now();
287
+ const ranges = {} as Record<Exclude<TimeRangeType, 'custom'>, UsageAggregate>;
288
+ for (const range of PRESET_RANGES) {
289
+ ranges[range] = foldWindow(scan.records, { kind: 'preset', range }, now, scan.scannedTranscripts);
290
+ }
291
+ const active = foldWindow(scan.records, window, now, scan.scannedTranscripts);
292
+ // Cache each fold under its own key so a later single-window call reuses it.
293
+ for (const range of PRESET_RANGES) cache.set(range, { at: now, data: ranges[range] });
294
+ cache.set(cacheKey(window), { at: now, data: active });
295
+ return { ranges, active };
296
+ }
@@ -273,6 +273,9 @@ export function forgetTerminal(id: string): void {
273
273
  if (!session) return;
274
274
  sessions.delete(id);
275
275
  clearPendingResize(id);
276
+ // Close each viewer's socket, not just forget it: the record is going away,
277
+ // so a socket left open would sit on a terminal that can never answer it.
278
+ for (const viewer of session.viewers) viewer.drop();
276
279
  session.viewers.clear();
277
280
  if (session.status === 'running') signalGroup(session, 'SIGKILL');
278
281
  closeTerminalStream(session);
@@ -41,7 +41,15 @@ export const TERMINATION_GRACE_MS = 1000;
41
41
 
42
42
  /** A socket attached to a terminal. Output is raw PTY bytes; control is JSON. */
43
43
  export interface TerminalViewer {
44
- send(frame: string | Uint8Array): void;
44
+ /**
45
+ * Deliver one frame. Returns false when the viewer could not accept it —
46
+ * the socket's send buffer is over its high-water mark — which the session
47
+ * treats as "this viewer is gone": a PTY producer cannot be paused, so the
48
+ * alternative is buffering unbounded output in a socket nobody is reading.
49
+ */
50
+ send(frame: string | Uint8Array): boolean;
51
+ /** Detach this viewer. Called when `send` reports it cannot keep up. */
52
+ drop(): void;
45
53
  }
46
54
 
47
55
  export interface TerminalSession {
@@ -63,6 +71,25 @@ export interface TerminalSession {
63
71
  lastActivity: number;
64
72
  }
65
73
 
74
+ /**
75
+ * Deliver `frame` to every viewer, dropping the ones that cannot keep up.
76
+ *
77
+ * `Bun.ServerWebSocket.send` returns the bytes queued, or `-1` when the socket
78
+ * is over its high-water mark and the frame was NOT sent (verified on 1.4.2:
79
+ * 40 sends of 1 MiB to a socket nobody reads all return `-1`). A viewer that
80
+ * reports `-1` is closed rather than skipped — skipping would leave xterm's
81
+ * parser mid-sequence, so the honest outcome is a reconnect that replays the
82
+ * scrollback. The PTY cannot be paused, so this is the only backpressure a
83
+ * terminal has.
84
+ */
85
+ export function fanOut(session: TerminalSession, frame: string | Uint8Array): void {
86
+ for (const viewer of session.viewers) {
87
+ if (viewer.send(frame)) continue;
88
+ session.viewers.delete(viewer);
89
+ viewer.drop();
90
+ }
91
+ }
92
+
66
93
  /** A rejected attach, carrying the code the client shows to the user. */
67
94
  export class TerminalRuntimeError extends Error {
68
95
  constructor(
@@ -118,7 +145,7 @@ export async function createSession(request: AttachRequest): Promise<TerminalSes
118
145
  data(_term, bytes) {
119
146
  session.history.append(bytes);
120
147
  session.lastActivity = Date.now();
121
- for (const viewer of session.viewers) viewer.send(bytes);
148
+ fanOut(session, bytes);
122
149
  },
123
150
  });
124
151
  session.term = term;
@@ -145,7 +172,7 @@ export async function createSession(request: AttachRequest): Promise<TerminalSes
145
172
  session.signal = proc.signalCode ?? null;
146
173
  session.lastActivity = Date.now();
147
174
  const frame = JSON.stringify({ t: 'exit', exitCode: session.exitCode, signal: session.signal });
148
- for (const viewer of session.viewers) viewer.send(frame);
175
+ fanOut(session, frame);
149
176
  });
150
177
 
151
178
  return session;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The spawn guard on POST /api/agent/:sessionId.
8
+ *
9
+ * This route is the chamber's ONLY lazy-spawn path: a command posted for a
10
+ * session it does not manage starts an `omp` child for it. That is right for a
11
+ * prompt — the user asked for work — and wrong for a read. The sidebar renders
12
+ * a subagent roster row for every session in the list, and the roster probes
13
+ * `get_subagents` to seed itself, so an unguarded route booted a whole omp
14
+ * process per finished session merely LOOKED at (measured: the process count
15
+ * grew on a session whose liveness probe had just answered `running: false`).
16
+ *
17
+ * Both observer-only commands have RPC-free on-disk equivalents, which is what
18
+ * a finished session is served from, so refusing them for a dead session costs
19
+ * the client nothing.
20
+ *
21
+ * No module is mocked. The assertions are about which response comes back
22
+ * BEFORE the spawn path is reached, so the real registry (empty for these ids)
23
+ * and the real on-disk resolver are exactly the inputs that matter. The
24
+ * "prompt" case proves the guard is not a blanket refusal: it reaches the
25
+ * resolver and 404s for an id with no session file — a spawn was attempted.
26
+ */
27
+
28
+ import { describe, expect, test } from 'bun:test';
29
+ import { sendCommand } from '@/server/routes/agent/command';
30
+
31
+ /** Ids that no chamber instance manages and no session file backs. */
32
+ const DEAD = 'spawn-guard-dead-session';
33
+ const UNKNOWN = 'spawn-guard-unknown-session';
34
+
35
+ function post(sessionId: string, body: Record<string, unknown>): Promise<Response> {
36
+ return sendCommand({
37
+ params: { sessionId },
38
+ request: new Request(`http://localhost/api/agent/${sessionId}`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify(body),
42
+ }),
43
+ } as never) as Promise<Response>;
44
+ }
45
+
46
+ async function payload(response: Response): Promise<Record<string, unknown>> {
47
+ return (await response.json()) as Record<string, unknown>;
48
+ }
49
+
50
+ describe('agent command spawn guard', () => {
51
+ test('refuses a roster snapshot for a session that is not running', async () => {
52
+ const response = await post(DEAD, { type: 'get_subagents' });
53
+ expect(response.status).toBe(409);
54
+ expect(await payload(response)).toMatchObject({ code: 'session_not_running' });
55
+ });
56
+
57
+ test('refuses a transcript page for a session that is not running', async () => {
58
+ const response = await post(DEAD, { type: 'get_subagent_messages', subagentId: 'child' });
59
+ expect(response.status).toBe(409);
60
+ expect(await payload(response)).toMatchObject({ code: 'session_not_running' });
61
+ });
62
+
63
+ test('does not refuse a command that asks for work', async () => {
64
+ const response = await post(UNKNOWN, { type: 'prompt', message: 'hello' });
65
+ // It reached the spawn path and failed there (no session file on disk),
66
+ // which is the opposite of the guard's 409.
67
+ expect(response.status).not.toBe(409);
68
+ });
69
+ });
@@ -5,6 +5,7 @@ import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession, type Agen
5
5
  import { getSpawnApprovalMode, reconcileSpawnApprovalMode } from '@/server/lib/omp/rpc/session-registry';
6
6
  import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
7
7
  import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
8
+ import { OBSERVER_ONLY_COMMANDS } from '@/server/lib/omp/rpc/constants';
8
9
  import { rpcErrorResponse } from '@/server/lib/omp/rpc/errors';
9
10
 
10
11
  // POST /api/agent/:sessionId — send a command to an existing session (or spawn
@@ -39,6 +40,19 @@ export async function sendCommand({ params, request }: ActionFunctionArgs) {
39
40
  }
40
41
  }
41
42
 
43
+ // Observer-only reads must never boot an omp child. This route is the spawn
44
+ // path, so a roster snapshot (`get_subagents`) or a transcript page
45
+ // (`get_subagent_messages`) posted for a session the chamber is not
46
+ // managing would start a whole process just to answer a read — measured:
47
+ // opening a FINISHED session's roster row grew the omp process count, and
48
+ // the sidebar renders those rows for every session in the list. Both reads
49
+ // have RPC-free on-disk equivalents (`GET /api/sessions/:id/subagents[/:sub]`),
50
+ // which is what a dead session is served from; the live registry is only
51
+ // consulted while a process exists to answer for.
52
+ if (OBSERVER_ONLY_COMMANDS.has(body.type)) {
53
+ return json({ error: 'Session is not managed by the chamber', code: 'session_not_running' }, { status: 409 });
54
+ }
55
+
42
56
  const resolved = await resolveSessionPathOr404(sessionId);
43
57
  if ('response' in resolved) return resolved.response;
44
58
  const { filePath, recordedCwd } = resolved;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Regression test for the observer WebSocket routes' connection state.
8
+ *
9
+ * The bug this pins: `agent/ws.ts` and `btw/ws.ts` kept per-connection state in
10
+ * a `WeakMap` keyed by the `ws` object. Elysia's Bun adapter builds a NEW
11
+ * `ElysiaWS` wrapper for every callback and hands `pong` the RAW socket, so
12
+ * every lookup missed — the pong never cleared `awaitingPong`, the second
13
+ * heartbeat tore down a socket the client was still reading, and `close` leaked
14
+ * the interval and the session subscription for the life of the process.
15
+ *
16
+ * These assertions run through a real listener on purpose: the failure lived in
17
+ * the framework's callback identity, which a unit-level fake of `ws` cannot
18
+ * reproduce. That also makes the heartbeat a REAL timer here — the property
19
+ * under test is "the socket outlives N keepalive cycles", so the clock is the
20
+ * thing being measured. The interval is shrunk to 60ms to keep it cheap.
21
+ */
22
+
23
+ import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test';
24
+ import { Elysia } from 'elysia';
25
+
26
+ /** Heartbeat for the test — several beats must fit inside a test timeout. */
27
+ const HEARTBEAT_MS = 60;
28
+
29
+ // The route reads the cadence constant at module scope, so the module is
30
+ // replaced BEFORE the route module is imported.
31
+ mock.module('@/shared/lib/workspace/refresh-cadence', () => ({
32
+ STREAM_HEARTBEAT_MS: HEARTBEAT_MS,
33
+ SIDEBAR_IDLE_REFRESH_MS: 30_000,
34
+ SIDEBAR_STREAM_POLL_MS: 8_000,
35
+ SIDEBAR_REVALIDATE_THROTTLE_MS: 1_000,
36
+ PANEL_REFRESH_MS: 5_000,
37
+ FILE_MUTATION_THROTTLE_MS: 500,
38
+ GIT_STATUS_POLL_MS: 15_000,
39
+ GIT_STATUS_EVENT_THROTTLE_MS: 1_000,
40
+ REPO_DISCOVERY_POLL_MS: 1_500,
41
+ TODO_REFRESH_EVENT_THROTTLE_MS: 400,
42
+ BROWSER_POLL_MS: 1_000,
43
+ SIDEBAR_DATA_TTL_MS: 4_000,
44
+ SESSION_META_RETRY_SCHEDULE_MS: [250],
45
+ }));
46
+
47
+ const { agentWsRoutes } = await import('@/server/routes/agent/ws');
48
+
49
+ /** The live session the route subscribes to; records its own lifecycle. */
50
+ interface SessionStub {
51
+ isAlive: () => boolean;
52
+ onEvent: (listener: (event: unknown) => void) => () => void;
53
+ listeners: Set<(event: unknown) => void>;
54
+ unsubscribes: number;
55
+ }
56
+
57
+ function makeSession(): SessionStub {
58
+ const stub: SessionStub = {
59
+ isAlive: () => true,
60
+ listeners: new Set(),
61
+ unsubscribes: 0,
62
+ onEvent(listener) {
63
+ stub.listeners.add(listener);
64
+ return () => {
65
+ stub.unsubscribes += 1;
66
+ stub.listeners.delete(listener);
67
+ };
68
+ },
69
+ };
70
+ return stub;
71
+ }
72
+
73
+ let session = makeSession();
74
+ const app = new Elysia().use(agentWsRoutes);
75
+ let baseUrl = '';
76
+
77
+ beforeAll(async () => {
78
+ (globalThis as { __ompSessions?: unknown }).__ompSessions = {
79
+ get: (id: string) => (id === 'test-session' ? session : undefined),
80
+ };
81
+ await app.listen({ port: 0, hostname: '127.0.0.1' });
82
+ const port = (app.server as { port?: number } | null)?.port;
83
+ if (!port) throw new Error('test listener did not report a port');
84
+ baseUrl = `ws://127.0.0.1:${port}`;
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await app.stop();
89
+ });
90
+
91
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
92
+
93
+ /** One client, its received frames, and the promises for its lifecycle. */
94
+ function connect(sessionId: string) {
95
+ const frames: unknown[] = [];
96
+ const client = new WebSocket(`${baseUrl}/api/agent/${sessionId}/ws`);
97
+ const opened = Promise.withResolvers<void>();
98
+ const closed = Promise.withResolvers<number>();
99
+ client.onopen = () => opened.resolve();
100
+ client.onmessage = (event) => {
101
+ try {
102
+ frames.push(JSON.parse(String(event.data)));
103
+ } catch {
104
+ // A non-JSON frame would be a protocol bug; assertions read `frames`.
105
+ }
106
+ };
107
+ client.onclose = (event) => closed.resolve(event.code);
108
+ return { client, frames, opened: opened.promise, closed: closed.promise };
109
+ }
110
+
111
+ describe('agent observer WebSocket', () => {
112
+ test('survives many heartbeats instead of being torn down by its own keepalive', async () => {
113
+ session = makeSession();
114
+ const { client, closed } = connect('test-session');
115
+ // Five beats: the old code closed the socket at the second one.
116
+ await sleep(HEARTBEAT_MS * 5);
117
+ expect(client.readyState).toBe(WebSocket.OPEN);
118
+ client.close();
119
+ await closed;
120
+ });
121
+
122
+ test('delivers the connected greeting and live session frames', async () => {
123
+ session = makeSession();
124
+ const { client, frames, opened, closed } = connect('test-session');
125
+ await opened;
126
+ for (const listener of session.listeners) listener({ type: 'agent_start' });
127
+ await sleep(30);
128
+ expect(frames).toEqual([
129
+ { type: 'connected', sessionId: 'test-session' },
130
+ { type: 'agent_start' },
131
+ ]);
132
+ client.close();
133
+ await closed;
134
+ });
135
+
136
+ test('releases the session subscription when the client disconnects', async () => {
137
+ session = makeSession();
138
+ const { client, opened, closed } = connect('test-session');
139
+ await opened;
140
+ expect(session.listeners.size).toBe(1);
141
+ client.close();
142
+ await closed;
143
+ await sleep(30);
144
+ expect(session.unsubscribes).toBe(1);
145
+ expect(session.listeners.size).toBe(0);
146
+ });
147
+
148
+ test('refuses a session the chamber does not manage', async () => {
149
+ const { opened, closed, client } = connect('unknown-session');
150
+ const outcome = await Promise.race([
151
+ opened.then(() => 'opened' as const),
152
+ closed.then(() => 'closed' as const),
153
+ sleep(400).then(() => 'timeout' as const),
154
+ ]);
155
+ // The upgrade is rejected before `open` runs, so the client never opens.
156
+ expect(outcome).not.toBe('opened');
157
+ client.close();
158
+ });
159
+ });