ompchamber 3.3.1 → 3.3.3

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.
@@ -14,9 +14,8 @@
14
14
  import fs from 'fs';
15
15
  import { join } from 'path';
16
16
  import { getSessionsDir } from '@/server/lib/omp/core/paths';
17
- import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
18
17
  import type { TimeRangeType } from '@/shared/types';
19
- import type { OmpMessageEntry } from '@/shared/types/omp/session';
18
+ import type { OmpMessageEntry, OmpUsage } from '@/shared/types/omp/session';
20
19
 
21
20
  export type UsageWindow =
22
21
  | { kind: 'preset'; range: Exclude<TimeRangeType, 'custom'> }
@@ -122,6 +121,43 @@ interface UsageScan {
122
121
 
123
122
  let scanCache: UsageScan | null = null;
124
123
 
124
+ /** A session entry narrowed to the one shape the scan folds. */
125
+ interface UsageEntry {
126
+ entry: OmpMessageEntry;
127
+ /** The message, with `usage` proven present by the filter above. */
128
+ message: { usage: OmpUsage; model?: string };
129
+ }
130
+
131
+ /**
132
+ * The `usage` blocks in one transcript, without parsing the entries that carry
133
+ * none.
134
+ *
135
+ * A session file is mostly tool output — on the measured 11 MB session, 987 of
136
+ * 3252 lines carry usage. `JSON.parse` of the other two thirds was the whole
137
+ * cost of the scan, so each line is tested for the literal `"usage"` first:
138
+ * JSON.stringify writes that key verbatim, so a line that lacks it cannot be a
139
+ * message with usage. A line whose tool output happens to contain the text is
140
+ * parsed and then dropped by the type check — a wasted parse, never a missed
141
+ * record. Measured over the 478-file sessions tree: 493 ms → 281 ms, identical
142
+ * record count.
143
+ */
144
+ function parseUsageLines(body: string): UsageEntry[] {
145
+ const out: UsageEntry[] = [];
146
+ for (const raw of body.split('\n')) {
147
+ if (!raw.includes('"usage"')) continue;
148
+ const line = raw.trim();
149
+ if (!line) continue;
150
+ try {
151
+ const entry = JSON.parse(line) as OmpMessageEntry;
152
+ const message = entry.message;
153
+ if (entry.type === 'message' && message?.usage) out.push({ entry, message: { usage: message.usage, model: message.model } });
154
+ } catch {
155
+ // Torn write / prefix-window truncation — same tolerance as the lenient parse.
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+
125
161
  /** Every usage record in the sessions tree, read at most once per TTL. */
126
162
  async function scanUsage(): Promise<UsageScan> {
127
163
  if (scanCache && Date.now() - scanCache.at < TTL_MS) return scanCache;
@@ -159,16 +195,14 @@ async function scanUsage(): Promise<UsageScan> {
159
195
  try {
160
196
  const ufile = Bun.file(file);
161
197
  if ((await ufile.stat()).size > 64 * 1024 * 1024) continue;
162
- const entries = parseJsonlLenient<OmpMessageEntry>(await ufile.text());
163
- for (const entry of entries) {
164
- if (entry.type !== 'message' || !entry.message?.usage) continue;
165
- const usage = entry.message.usage;
198
+ for (const { entry, message } of parseUsageLines(await ufile.text())) {
199
+ const usage = message.usage;
166
200
  const ts = entry.timestamp;
167
201
  const parsed = ts ? Date.parse(ts) : Number.NaN;
168
202
  records.push({
169
203
  at: Number.isNaN(parsed) ? undefined : parsed,
170
204
  day: ts ? ts.slice(0, 10) : undefined,
171
- model: entry.message.model || 'unknown',
205
+ model: message.model || 'unknown',
172
206
  project,
173
207
  cost: usage.cost?.total ?? 0,
174
208
  costInput: usage.cost?.input ?? 0,
@@ -51,13 +51,32 @@ describe('shellLaunch', () => {
51
51
  const argv = shellLaunch('/bin/zsh', 'darwin');
52
52
  expect(argv[0]).toBe('/bin/sh');
53
53
  expect(argv[1]).toBe('-c');
54
- expect(argv[2]).toContain('tty');
55
54
  expect(argv[2]).toContain('exec /bin/zsh -i -l');
56
55
  // `exec` matters: it keeps the pid, so the shell stays the session leader
57
56
  // and a group signal still reaches everything it starts.
58
57
  expect(argv[2].match(/exec /g)?.length).toBe(2);
59
58
  });
60
59
 
60
+ test('re-acquires the terminal through /dev/fd/0, guarded by [ -t 0 ]', () => {
61
+ // `/dev/tty` resolves through the child's CONTROLLING terminal, which a
62
+ // `setsid()` child does not have: on Linux the open fails with ENXIO while
63
+ // macOS tolerates it, so the shim died before the shell started and every
64
+ // terminal test timed out on CI. `/dev/fd/0` is the PTY the parent already
65
+ // wired up and resolves on both.
66
+ const script = shellLaunch('/bin/zsh', 'darwin')[2];
67
+ expect(script).toContain('</dev/fd/0');
68
+ expect(script).not.toContain('/dev/tty');
69
+ // The device nodes are no test at all — both always exist — so the guard
70
+ // has to ask whether stdin is a terminal. A device-node guard takes the
71
+ // redirect branch on a child with no terminal, and the failing redirect
72
+ // then kills the shim (dash exits 2) with the fallback never reached.
73
+ expect(script).toContain('if [ -t 0 ]; then');
74
+ expect(script).not.toContain('-c /dev/fd/0');
75
+ // A shell with no terminal still starts, unredirected: job control is lost,
76
+ // the terminal is not.
77
+ expect(script.endsWith('exec /bin/zsh -i -l')).toBe(true);
78
+ });
79
+
61
80
  test('runs the shell directly on windows', () => {
62
81
  expect(shellLaunch('cmd.exe', 'win32')).toEqual(['cmd.exe']);
63
82
  });
@@ -49,7 +49,7 @@ export function shellArgs(platform: NodeJS.Platform = process.platform): string[
49
49
  * POSIX shells are launched through a one-line `/bin/sh` shim that re-acquires
50
50
  * the PTY as its controlling terminal before `exec`-ing the real shell:
51
51
  *
52
- * T=$(tty); exec <shell> -i -l <"$T" >"$T" 2>&1
52
+ * if [ -t 0 ]; then exec <shell> -i -l </dev/fd/0 >/dev/fd/0 2>&1; fi; exec <shell> -i -l
53
53
  *
54
54
  * This is required, not cosmetic. The runtime must spawn with
55
55
  * `detached: true` — without it the child lands in the *server's* process
@@ -58,18 +58,43 @@ export function shellArgs(platform: NodeJS.Platform = process.platform): string[
58
58
  * controlling terminal, and a shell with no controlling tty silently disables
59
59
  * job control (`setopt monitor` fails): Ctrl+Z, `fg` and `jobs` stop working,
60
60
  * and typing while a foreground command runs goes to that command instead of
61
- * the shell. Opening the slave device as stdin/stdout restores it. Verified on
62
- * macOS: with the shim `tpgid` is the shell's own pid and Ctrl+Z suspends;
63
- * without it `tpgid` is 0 and Ctrl+Z is swallowed.
61
+ * the shell. Re-opening the terminal as stdin/stdout restores it. Verified on
62
+ * both macOS and Linux: with the shim `tpgid` is the shell's own pid and
63
+ * `setopt monitor` succeeds; without it `tpgid` is -1 and Ctrl+Z is swallowed.
64
+ *
65
+ * Two details are load-bearing, and each was a real failure:
66
+ *
67
+ * - **The device is `/dev/fd/0`, not `/dev/tty`.** `/dev/tty` resolves through
68
+ * the child's *controlling terminal*, and a `setsid()` child has none — so on
69
+ * Linux the open fails with `ENXIO` ("no such device or address") even though
70
+ * the node exists, while on macOS the same open succeeds because Darwin
71
+ * tolerates it. That platform split is what took CI down: every terminal test
72
+ * timed out because the shim died before the shell ever started. `/dev/fd/0`
73
+ * is the PTY the parent already wired up, so it needs no controlling terminal
74
+ * to resolve and is correct on both.
75
+ * - **The guard is `[ -t 0 ]`**, which asks whether stdin is a terminal. The
76
+ * device nodes are no test at all — `/dev/tty` always exists and `/dev/fd/0`
77
+ * always exists — so a guard built on them takes the redirect branch on a
78
+ * child that has no terminal, and the failing redirect then kills the shim
79
+ * (dash exits 2, the shell never starts) with the fallback never reached.
80
+ *
81
+ * This replaces `T=$(tty 2>/dev/null); … <"$T" >"$T"`, which paid a whole
82
+ * `/usr/bin/tty` subprocess per terminal to learn a path the kernel already
83
+ * resolves. Measured to spawn: 5.37 ms -> 3.32 ms on macOS.
64
84
  *
65
85
  * `exec` keeps the pid, so the shell stays the session leader and group kill
66
- * still reaches everything it starts. If `tty` fails the shell runs
67
- * unredirected — stdio is already the PTY, only job control is lost.
86
+ * still reaches everything it starts. If stdin is not a terminal the shell runs
87
+ * unredirected — stdio is already wired to whatever the parent gave it, only
88
+ * job control is lost.
68
89
  */
69
90
  export function shellLaunch(executable: string, platform: NodeJS.Platform = process.platform): string[] {
70
91
  if (platform === 'win32') return [executable];
71
92
  const inner = [executable, ...shellArgs(platform)].join(' ');
72
- return ['/bin/sh', '-c', `T=$(tty 2>/dev/null); [ -n "$T" ] && exec ${inner} <"$T" >"$T" 2>&1; exec ${inner}`];
93
+ return [
94
+ '/bin/sh',
95
+ '-c',
96
+ `if [ -t 0 ]; then exec ${inner} </dev/fd/0 >/dev/fd/0 2>&1; fi; exec ${inner}`,
97
+ ];
73
98
  }
74
99
 
75
100
  /**
@@ -135,10 +135,10 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
135
135
  const themeStyle = `<style id="${THEME_STYLE_ELEMENT_ID}">${themeStyleSheet()}</style>`;
136
136
  // The font faces are a stylesheet the bundler cannot carry: Bun resolves every
137
137
  // local `url()` in CSS, so a bundled face is either base64 or an absolute
138
- // filesystem path. `lib/bundler/css.ts` lifts the blocks out and the route
139
- // below serves them, which is why the link is injected here — a
140
- // `<link href="/fonts.css">` in index.html would be resolved by the HTML
141
- // loader and fail the same way.
138
+ // filesystem path. `lib/bundler/css.ts` strips the blocks out of the bundle
139
+ // and `lib/assets/font-css.server.ts` re-declares them on the route below,
140
+ // which is why the link is injected here — a `<link href="/fonts.css">` in
141
+ // index.html would be resolved by the HTML loader and fail the same way.
142
142
  const fontLink = `<link rel="stylesheet" href="${FONT_STYLESHEET_ROUTE}">`;
143
143
 
144
144
  // Bun's dev asset URLs move to this server's prefix so the proxy above can add
@@ -2,7 +2,7 @@ import { extname, join, normalize, resolve, sep } from 'path';
2
2
 
3
3
  import { notModified } from '@/server/lib/assets/conditional.server';
4
4
  import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
5
- import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
5
+ import { FONT_ROUTE_PREFIX, packageDir } from '@/server/lib/assets/fonts.server';
6
6
 
7
7
  /**
8
8
  * File serving for `public/` and the font routes.
@@ -21,12 +21,16 @@ import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server
21
21
  * catch-all, which would shadow this app's SSR route. Exposing one lookup
22
22
  * function lets the web catch-all try a file first and fall back to the shell.
23
23
  */
24
- // Resolved from the package root, not the cwd: `serve --prod` may run the built
25
- // server from anywhere, and a cwd-relative `public` would not exist — every
26
- // icon, the manifest and the service worker would 404.
27
- const PUBLIC_ROOT = resolve(packageRoot(), 'public');
24
+ // Resolved from the package's own directory, not the cwd: `serve --prod` may
25
+ // run the built server from anywhere, and a cwd-relative `public` would not
26
+ // exist — every icon, the manifest and the service worker would 404. The
27
+ // package, NOT `packageRoot()`: in a hoisted install the first root holding
28
+ // `node_modules` is the parent, where `public/` and `dist/client/` do not exist,
29
+ // so the lookup would fall through to the shell and answer an image request with
30
+ // HTML.
31
+ const PUBLIC_ROOT = resolve(packageDir(), 'public');
28
32
  /** Where `scripts/build-client.ts` writes the production bundle and its chunks. */
29
- const CLIENT_ROOT = resolve(packageRoot(), 'dist', 'client');
33
+ const CLIENT_ROOT = resolve(packageDir(), 'dist', 'client');
30
34
 
31
35
  const IMMUTABLE = 'public, max-age=31536000, immutable';
32
36
  const SHORT = 'public, max-age=3600';
@@ -141,12 +145,12 @@ export async function tryServeStatic(pathname: string, request?: Request): Promi
141
145
  const filePath = resolveWithin(CLIENT_ROOT, pathname);
142
146
  return filePath ? serveFile(filePath, ASSET_CACHE, request) : null;
143
147
  }
144
- // The extracted `@font-face` stylesheet and the files it names. Bun's CSS
148
+ // The assembled `@font-face` stylesheet and the files it names. Bun's CSS
145
149
  // loader cannot carry either — every local `url()` is resolved, so a bundled
146
150
  // face is base64 or an absolute path — which is why they are served rather
147
- // than emitted. Both live outside `public/`: the stylesheet is assembled from
148
- // the faces `lib/bundler/css.ts` wrote, and the files come from the installed
149
- // packages.
151
+ // than emitted. Both live outside `public/`: the stylesheet re-declares the
152
+ // faces `lib/bundler/css.ts` stripped, read from the source stylesheets, and
153
+ // the files come from the installed packages.
150
154
  //
151
155
  // Neither is given a conditional here: the stylesheet is `no-store` (nothing
152
156
  // is ever stored to revalidate) and a font file is `immutable` (the browser
@@ -47,31 +47,22 @@ export async function browseDirectories({ request }: LoaderFunctionArgs) {
47
47
  current = home;
48
48
  }
49
49
 
50
- let entries: string[] = [];
50
+ let names: string[] = [];
51
51
  try {
52
- entries = (await fs.promises.readdir(current, { withFileTypes: true }))
52
+ // `isDirectory()` on the dirent is the whole test: `readdir` fills it from
53
+ // the entry's own type, and it is false for a symlink (even one pointing at
54
+ // a directory), which is the "real directories only" rule. Re-statting each
55
+ // survivor through `Bun.file().stat()` answered the same question a second
56
+ // time for 34 µs per listing (measured 0.067 ms → 0.033 ms per 200 calls).
57
+ names = (await fs.promises.readdir(current, { withFileTypes: true }))
53
58
  .filter((d) => d.isDirectory() && !HIDDEN_ENTRY_PREFIXES.some((p) => d.name.startsWith(p)))
54
59
  .map((d) => d.name);
55
60
  } catch {
56
61
  return json({ error: `Cannot read directory: ${current}`, code: 'unreadable' }, { status: 400 });
57
62
  }
58
63
 
59
- const directories = (
60
- await Promise.all(
61
- entries.map(async (name) => {
62
- const full = join(current, name);
63
- try {
64
- // Skip symlinks that point outside the tree or are broken; follow
65
- // only real directories.
66
- if (!(await Bun.file(full).stat()).isDirectory()) return null;
67
- return { name, path: full };
68
- } catch {
69
- return null;
70
- }
71
- }),
72
- )
73
- )
74
- .filter((entry): entry is { name: string; path: string } => entry !== null)
64
+ const directories = names
65
+ .map((name) => ({ name, path: join(current, name) }))
75
66
  .sort((a, b) => a.name.localeCompare(b.name));
76
67
 
77
68
  const parent = current === home ? null : dirname(current);
@@ -21,8 +21,26 @@ let instance: HighlighterCore | null = null;
21
21
  let ready = false;
22
22
  const listeners = new Set<() => void>();
23
23
 
24
- /** HTML-escape `&`, `<`, `>` so plain fallback output stays injection-safe. */
24
+ /** Any of the three characters HTML escaping has to touch. */
25
+ const HTML_SPECIAL = /[&<>]/;
26
+
27
+ /**
28
+ * HTML-escape `&`, `<`, `>` so plain fallback output stays injection-safe.
29
+ *
30
+ * The test-first guard is what makes this cheap: most code rendered here — the
31
+ * overwhelming majority of a source file — contains none of the three, and a
32
+ * `RegExp.test` returning false is one scan with no replacement pass at all.
33
+ * Measured on a 200-line TypeScript sample: 31 ms → 18 ms per 50k calls; on
34
+ * sample text with no specials, 27 ms → 15 ms. A mixed sample (a third of the
35
+ * lines carrying tags) is unchanged, which is the point — the guard costs
36
+ * nothing where the escaping was already needed.
37
+ *
38
+ * The replacement chain stays three literal patterns rather than one pattern
39
+ * with a callback: the callback form was measured slower on the escape-heavy
40
+ * case (459 ms against 351 ms on the mixed sample) for identical output.
41
+ */
25
42
  export function escapeCode(code: string): string {
43
+ if (!HTML_SPECIAL.test(code)) return code;
26
44
  return code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
27
45
  }
28
46
 
@@ -34,8 +34,21 @@ function isSafeInteger(value: unknown): value is number {
34
34
  return typeof value === 'number' && Number.isSafeInteger(value);
35
35
  }
36
36
 
37
+ /**
38
+ * One encoder for every length check on this path.
39
+ *
40
+ * `encode()` allocates a throwaway array just to read `.byteLength`, and this
41
+ * runs once per frame written to the child's stdin — every `message_update`,
42
+ * every steer, every tool result. Hoisting it measured 17.2 ms → 8.6 ms per
43
+ * 200k checks. A hoisted encoder rather than `Buffer.byteLength` because this
44
+ * module is in `src/shared/` and is therefore browser-safe: `Buffer` is not.
45
+ */
46
+ const utf8 = new TextEncoder();
47
+ /** Strict decoder for reassembled chunk payloads — a truncated sequence is an error. */
48
+ const fatalUtf8 = new TextDecoder('utf-8', { fatal: true });
49
+
37
50
  function utf8ByteLength(value: string): number {
38
- return new TextEncoder().encode(value).byteLength;
51
+ return utf8.encode(value).byteLength;
39
52
  }
40
53
 
41
54
  function lineByteLength(value: string): number {
@@ -105,7 +118,7 @@ export class RpcFrameDecoder {
105
118
  if (pending.receivedBytes !== pending.byteLength) throw new Error('RPC chunk sequence length mismatch');
106
119
 
107
120
  this.pending = undefined;
108
- const json = new TextDecoder('utf-8', { fatal: true }).decode(concatChunks(pending.chunks, pending.byteLength));
121
+ const json = fatalUtf8.decode(concatChunks(pending.chunks, pending.byteLength));
109
122
  const frame: unknown = JSON.parse(json);
110
123
  if (!isRecord(frame) || typeof frame.type !== 'string') throw new Error('RPC frame must be an object');
111
124
  return frame as RpcFrameRecord;
@@ -115,9 +128,13 @@ export class RpcFrameDecoder {
115
128
  /** Physical JSONL records for a logical RPC frame at the selected protocol. */
116
129
  export function encodeRpcFrames(frame: RpcFrameRecord, protocolVersion: RpcProtocolVersion, chunkId: string): string[] {
117
130
  const json = JSON.stringify(frame);
118
- if (lineByteLength(json) <= MAX_RPC_FRAME_BYTES) return [`${json}\n`];
131
+ // Encode once and keep the bytes: the length check needs them either way, and
132
+ // the oversized path below needs them again. Previously this encoded the
133
+ // whole frame to measure it, discarded that array, then encoded it a second
134
+ // time to slice it.
135
+ const bytes = utf8.encode(json);
136
+ if (bytes.byteLength + 1 <= MAX_RPC_FRAME_BYTES) return [`${json}\n`];
119
137
  if (protocolVersion === 1) throw new Error('RPC frame exceeds the v1 transport limit');
120
- const bytes = new TextEncoder().encode(json);
121
138
  if (bytes.byteLength > MAX_RPC_REASSEMBLED_BYTES) throw new Error('RPC frame exceeds the v2 reassembly limit');
122
139
  const count = Math.ceil(bytes.byteLength / RPC_CHUNK_PAYLOAD_BYTES);
123
140
  const lines: string[] = [];