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
@@ -1,18 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The JSONL pass behind both telemetry endpoints.
8
+ *
9
+ * `visit` is called per entry and may return `false` to stop early. The parse
10
+ * itself is cached by `(path, size, mtimeMs)`, because `/api/telemetry/context`
11
+ * and `/api/telemetry/raw-messages` are two polling endpoints that parse the
12
+ * SAME file — measured 70.6 ms and 64.2 ms on an 11 MB session, per request,
13
+ * with no cache between them. One parse now serves both until the file moves.
14
+ *
15
+ * The cache is keyed on the raw `stat.mtimeMs`, never a `Date`: `getTime()`
16
+ * truncates the fractional millisecond APFS reports, which is what made the
17
+ * session-scan cache miss on every call (see files.ts).
18
+ */
19
+
1
20
  import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
2
21
  import type { OmpMessageEntry } from '@/shared/types/omp/session';
3
22
 
4
- /** Streaming JSONL pass for the telemetry builders; `visit` returns `false` to stop early. */
5
- export async function scanSessionEntries(
6
- filePath: string,
7
- visit: (entry: OmpMessageEntry, index: number) => boolean | void,
8
- ): Promise<void> {
23
+ /** Sessions larger than this are not cached; a transcript past it is pathological. */
24
+ const MAX_CACHED_SESSION_BYTES = 64 * 1024 * 1024;
25
+ /** Bound on remembered transcripts, so a long session list cannot retain them all. */
26
+ const MAX_CACHED_SESSIONS = 16;
27
+
28
+ interface CachedEntries {
29
+ size: number;
30
+ mtimeMs: number;
31
+ entries: OmpMessageEntry[];
32
+ }
33
+
34
+ interface ScanCacheHost {
35
+ entries: Map<string, CachedEntries>;
36
+ }
37
+
38
+ declare global {
39
+ // eslint-disable-next-line no-var
40
+ var __ompChamberTelemetryScanCache: ScanCacheHost | undefined;
41
+ }
42
+
43
+ function cache(): Map<string, CachedEntries> {
44
+ globalThis.__ompChamberTelemetryScanCache ??= { entries: new Map() };
45
+ return globalThis.__ompChamberTelemetryScanCache.entries;
46
+ }
47
+
48
+ /**
49
+ * Every entry of `filePath`, parsed once per file version.
50
+ *
51
+ * Callers must treat the returned array (and its entries) as immutable — the
52
+ * same array is handed to the next caller, and the builders only read.
53
+ */
54
+ export async function loadSessionEntries(filePath: string): Promise<OmpMessageEntry[]> {
55
+ let size: number;
56
+ let mtimeMs: number;
57
+ try {
58
+ const stat = await Bun.file(filePath).stat();
59
+ size = stat.size;
60
+ mtimeMs = stat.mtimeMs;
61
+ } catch {
62
+ return [];
63
+ }
64
+
65
+ const slot = cache();
66
+ const cached = slot.get(filePath);
67
+ if (cached && cached.size === size && cached.mtimeMs === mtimeMs) {
68
+ // Refresh insertion order so the bound evicts the least recently used.
69
+ slot.delete(filePath);
70
+ slot.set(filePath, cached);
71
+ return cached.entries;
72
+ }
73
+
9
74
  let body: string;
10
75
  try {
11
76
  body = await Bun.file(filePath).text();
12
77
  } catch {
13
- return;
78
+ return [];
14
79
  }
15
- const records = parseJsonlLenient<OmpMessageEntry>(body);
80
+ const entries = parseJsonlLenient<OmpMessageEntry>(body);
81
+ if (size <= MAX_CACHED_SESSION_BYTES) {
82
+ slot.set(filePath, { size, mtimeMs, entries });
83
+ while (slot.size > MAX_CACHED_SESSIONS) {
84
+ const oldest = slot.keys().next().value;
85
+ if (oldest === undefined) break;
86
+ slot.delete(oldest);
87
+ }
88
+ }
89
+ return entries;
90
+ }
91
+
92
+ /** JSONL pass for the telemetry builders; `visit` returns `false` to stop early. */
93
+ export async function scanSessionEntries(
94
+ filePath: string,
95
+ visit: (entry: OmpMessageEntry, index: number) => boolean | void,
96
+ ): Promise<void> {
97
+ const records = await loadSessionEntries(filePath);
16
98
  for (let index = 0; index < records.length; index++) {
17
99
  const entry = records[index];
18
100
  if (!entry) continue;
@@ -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;
@@ -19,22 +19,58 @@
19
19
  * own routing table rather than Elysia's (verified against Bun 1.4.2). Fetching
20
20
  * our own listener is the supported way to obtain the markup.
21
21
  *
22
- * The server reference is injected rather than imported to avoid a cycle:
23
- * `index.ts` owns the listener and imports `ssrRoutes`, which reaches here.
22
+ * The listener reference is shared with the dev asset proxy, which needs the
23
+ * same URL to reach the routes Bun's own table owns — see `lifecycle/listener`.
24
24
  */
25
25
 
26
+ import { describeShellBuildFailure } from '@/server/lib/bundler/build-errors.server';
26
27
  import { SHELL_ROUTE } from '@/server/lib/lifecycle/shell-route';
28
+ import { listenerUrl } from '@/server/lib/lifecycle/listener';
27
29
 
28
- type ShellSource = { url: URL } | null;
29
-
30
- let source: ShellSource = null;
30
+ export type ShellRender = { ok: true; html: string } | { ok: false; reason: string };
31
31
 
32
- /** Called by the server entry once its listener is up. */
33
- export function setShellSource(server: { url: URL }): void {
34
- source = server;
32
+ /**
33
+ * Why the route refused, in the terms of the edit that broke it.
34
+ *
35
+ * A status alone is the least useful half of the answer: the route answers 500
36
+ * because Bun could not bundle the page, and the file, line and message are in
37
+ * the response — but only inside Bun's "Build Failed" page, which carries them
38
+ * as a binary payload the page decodes at runtime. So the entrypoint is rebuilt
39
+ * to obtain them in readable form. See `bundler/build-errors.server` for why
40
+ * that probe agrees with the route.
41
+ *
42
+ * Runs only on a failure, on a page that is already broken.
43
+ */
44
+ async function shellFailureReason(status: number, markupInvalid = false): Promise<string> {
45
+ const detail = await describeShellBuildFailure();
46
+ if (detail) {
47
+ const reason = markupInvalid ? 'The shell route answered with markup that is not the shell.' : `Shell route answered ${status}.`;
48
+ return `${reason}\n\nThe client bundle does not build:\n\n${detail}`;
49
+ }
50
+ // Markup that is not the shell while the bundle builds cleanly means the
51
+ // route is serving a CACHED failure: Bun caches the HTML route's output once
52
+ // in production and never rebuilds it, so the empty body a failed first
53
+ // request left behind outlives the fix. There is nothing to point at in the
54
+ // source, and no amount of reloading helps — only a restart does.
55
+ return 'The shell route answered with markup that is not the shell, and the client bundle builds cleanly.\n\n'
56
+ + 'In production Bun caches the HTML route\'s output, so a build failure on the first request is cached as an empty page. Restart the server to rebuild it.';
35
57
  }
36
58
 
37
- export type ShellRender = { ok: true; html: string } | { ok: false; reason: string };
59
+ /**
60
+ * The bootstrap marker the SSR route substitutes, and therefore the one string
61
+ * that proves the markup came from `index.html`.
62
+ *
63
+ * A status check alone is not enough, and the gap is not theoretical: with
64
+ * `development: false` Bun caches the route's output, and a build that failed on
65
+ * the FIRST request leaves it caching an EMPTY body answered `200`. Every later
66
+ * request then looks successful, and the substitution below finds nothing to
67
+ * replace — so the browser gets a blank page with a 200 and no way to tell why.
68
+ * Measured on Bun 1.4.2: request 1 `500` + empty, requests 2..n `200` + empty,
69
+ * and it stayed empty after the source was fixed, because the cached output is
70
+ * never rebuilt in production. The marker turns that into the same actionable
71
+ * page the development path already produces.
72
+ */
73
+ export const SHELL_MARKER = '<!--app-bootstrap-->';
38
74
 
39
75
  /**
40
76
  * The current shell markup, or a reason it could not be produced.
@@ -44,11 +80,14 @@ export type ShellRender = { ok: true; html: string } | { ok: false; reason: stri
44
80
  * thing that could serve markup pointing at a previous build's asset hashes.
45
81
  */
46
82
  export async function renderShell(): Promise<ShellRender> {
47
- if (!source) return { ok: false, reason: 'The HTTP listener is not up yet.' };
83
+ const base = listenerUrl();
84
+ if (!base) return { ok: false, reason: 'The HTTP listener is not up yet.' };
48
85
  try {
49
- const response = await fetch(new URL(SHELL_ROUTE, source.url));
50
- if (!response.ok) return { ok: false, reason: `Shell route answered ${response.status}.` };
51
- return { ok: true, html: await response.text() };
86
+ const response = await fetch(new URL(SHELL_ROUTE, base));
87
+ if (!response.ok) return { ok: false, reason: await shellFailureReason(response.status) };
88
+ const html = await response.text();
89
+ if (!html.includes(SHELL_MARKER)) return { ok: false, reason: await shellFailureReason(response.status, true) };
90
+ return { ok: true, html };
52
91
  } catch (error) {
53
92
  const message = error instanceof Error ? error.message : String(error);
54
93
  return { ok: false, reason: message };