ompchamber 3.1.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +306 -298
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +45 -23
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/editor/index.tsx +15 -7
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/font.ts +53 -0
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +134 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/listener.ts +29 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/omp/subagent.ts +6 -0
- package/src/shared/types/settings/state.ts +7 -16
- package/dist/client/static/js/index-5yfre1kw.css +0 -1
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
49
|
+
let cwd = '';
|
|
40
50
|
|
|
41
51
|
await scanSessionEntries(filePath, (entry, index) => {
|
|
42
|
-
if (entry.type === 'session'
|
|
43
|
-
|
|
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
|
-
|
|
58
|
-
window.
|
|
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
|
|
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 =
|
|
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> = {
|
|
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
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
return
|
|
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
|
-
|
|
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
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
*
|
|
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
|
|
29
|
-
|
|
30
|
-
let source: ShellSource = null;
|
|
30
|
+
export type ShellRender = { ok: true; html: string } | { ok: false; reason: string };
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
50
|
-
if (!response.ok) return { ok: false, reason:
|
|
51
|
-
|
|
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 };
|