ompchamber 3.3.0 → 3.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -35,9 +35,7 @@
35
35
  import { describe, expect, test } from 'bun:test';
36
36
  import { readFileSync } from 'node:fs';
37
37
  import { builtinModules } from 'node:module';
38
- import { join } from 'node:path';
39
-
40
- import { packageRoot } from '@/server/lib/assets/fonts.server';
38
+ import { join, resolve } from 'node:path';
41
39
 
42
40
  /** Files whose imports are not part of any runtime path. */
43
41
  const EXCLUDED = /\.test\.|\.test-util\.|test-util\./;
@@ -51,11 +49,23 @@ function packageName(specifier: string): string {
51
49
  const BUILTINS = new Set(builtinModules);
52
50
 
53
51
  /**
54
- * Resolved from the running module, not the cwd — the same helper the bundler
55
- * plugin uses, so this test answers the same question whether it is run from the
56
- * repo root, from `dist/client`, or through `bun test <path>` elsewhere.
52
+ * The package root, derived from this file's own location (`src/server/lib/
53
+ * bundler` → four levels up) rather than the cwd.
54
+ *
55
+ * `packageRoot()` from `lib/assets/fonts.server.ts` is deliberately NOT used
56
+ * here: it answers "where are the dependencies installed", which is the first
57
+ * directory up from the module that holds `node_modules`. In a published
58
+ * install the package has no `node_modules` of its own — its dependencies are
59
+ * hoisted to the consumer's root — so that helper returns the CONSUMER's
60
+ * project, and the manifest it reads is the consumer's `package.json`, not
61
+ * this package's. Measured: run from an installed copy it resolved
62
+ * `/private/tmp/npmcheck/tsconfig.json` and died with `ENOENT`.
63
+ *
64
+ * The path depth is the same fact `lib/updates/install.ts` encodes in its
65
+ * `DEFAULT_PKG_ROOT`, for the same reason: `src/server/lib/<domain>/<file>`
66
+ * is always four levels below the package.
57
67
  */
58
- const ROOT = packageRoot();
68
+ const ROOT = resolve(import.meta.dir, '..', '..', '..', '..');
59
69
 
60
70
  const pkg = (await Bun.file(join(ROOT, 'package.json')).json()) as {
61
71
  dependencies?: Record<string, string>;
@@ -69,6 +79,10 @@ const DEV = new Set(Object.keys(pkg.devDependencies ?? {}));
69
79
  * Bun's transpiler is the parser here, so the answer matches what the bundler
70
80
  * itself sees: `@/` aliases, relative paths and `node:`/`bun:` prefixes are
71
81
  * filtered below, and a type-only import never appears at all.
82
+ *
83
+ * `tsconfig.json` ships in the published tarball (it is in `files`, and Bun
84
+ * resolves the `@/` alias from the package's own copy), so it is always
85
+ * readable here.
72
86
  */
73
87
  const transpiler = new Bun.Transpiler({
74
88
  loader: 'tsx',
@@ -12,39 +12,59 @@
12
12
  * answered with the root's own status; the client polls until `pending` clears.
13
13
  * Results are cached per scoped root for the life of the process.
14
14
  *
15
- * The `find` prunes `node_modules` (the one directory guaranteed to hold
16
- * unrelated `.git` directories, and the one that makes the walk unbounded).
15
+ * The walk prunes `node_modules` (the one directory guaranteed to hold
16
+ * unrelated `.git` directories, and the one that makes the walk unbounded) and
17
+ * stops at `MAX_GIT_DEPTH`.
18
+ *
19
+ * It is a JS walk rather than `find`, and the readdir is `withFileTypes` so a
20
+ * directory test costs no syscall: `find` needed a subprocess and a 1 MB stdout
21
+ * pipe, and measured 302 ms against 142 ms for the walk on a real workspace
22
+ * root, for the identical repo set (verified: 206 == 206, no element in one and
23
+ * not the other). Pre-order traversal in readdir order matches `find`'s, so the
24
+ * picker's list order is unchanged.
17
25
  */
18
26
 
19
- import { runShell } from '@/server/lib/fs/shell';
27
+ import fs from 'fs';
28
+ import path from 'path';
20
29
 
21
30
  const MAX_GIT_DEPTH = 8;
22
31
 
23
- async function getRepos(rootDir: string): Promise<string[]> {
32
+ /**
33
+ * Repos under `rootDir`, as root-relative POSIX paths (`.` for the root itself).
34
+ *
35
+ * `depth` is the depth of `dir` itself: the root is 0, so an entry inside it is
36
+ * at depth 1 and `MAX_GIT_DEPTH` bounds an entry's own depth exactly as `find
37
+ * -maxdepth` does.
38
+ */
39
+ async function findGitDirs(dir: string, rel: string, depth: number, out: string[]): Promise<void> {
40
+ let entries: fs.Dirent[];
24
41
  try {
25
- // Search deep enough to discover nested/child git repos inside a monorepo
26
- // workspace (e.g. projects/<name>/<sub>/.git) while pruning node_modules.
27
- const result = await runShell(
28
- `find . -maxdepth ${MAX_GIT_DEPTH} -name node_modules -prune -o -name .git -type d -print`,
29
- { cwd: rootDir, timeout: 12000, maxBuffer: 1024 * 1024 }
30
- );
31
- const discovered = result.stdout
32
- .trim()
33
- .split('\n')
34
- .filter(Boolean)
35
- .map(line => {
36
- const cleaned = line.replace(/^\.\//, '').replace(/\/\.git$/, '');
37
- return cleaned === '.git' || !cleaned ? '.' : cleaned;
38
- });
39
-
40
- const uniqueRepos = Array.from(new Set(discovered));
41
- if (uniqueRepos.includes('.')) {
42
- return ['.', ...uniqueRepos.filter(r => r !== '.')];
43
- }
44
- return uniqueRepos.length ? uniqueRepos : ['.'];
42
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
45
43
  } catch {
46
- return ['.'];
44
+ // Unreadable directory (permissions, a race) — skip it, never fail the walk.
45
+ return;
46
+ }
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory()) continue;
49
+ if (entry.name === 'node_modules') continue;
50
+ if (entry.name === '.git') {
51
+ out.push(rel || '.');
52
+ continue;
53
+ }
54
+ if (depth + 1 < MAX_GIT_DEPTH) {
55
+ await findGitDirs(path.join(dir, entry.name), rel ? `${rel}/${entry.name}` : entry.name, depth + 1, out);
56
+ }
57
+ }
58
+ }
59
+
60
+ async function getRepos(rootDir: string): Promise<string[]> {
61
+ const found: string[] = [];
62
+ await findGitDirs(rootDir, '', 0, found);
63
+ const uniqueRepos = Array.from(new Set(found));
64
+ if (uniqueRepos.includes('.')) {
65
+ return ['.', ...uniqueRepos.filter((r) => r !== '.')];
47
66
  }
67
+ return uniqueRepos.length ? uniqueRepos : ['.'];
48
68
  }
49
69
 
50
70
  interface RepoDiscovery {
@@ -149,6 +149,20 @@ function procStatus(pid: number): number | 'absent' | null {
149
149
  const bsdInfo = new Uint8Array(BSDINFO_BYTES);
150
150
  const bsdInfoView = new DataView(bsdInfo.buffer);
151
151
 
152
+ /**
153
+ * Scratch for `executablePath` and `argvOf`, for the same reason as the buffers
154
+ * above: `getProcessState` calls `commandLine`, which falls through argv to the
155
+ * executable path, and each call used to allocate a fresh 256 KiB buffer plus
156
+ * its views — 83 ms per 5000 probes against 24 ms reusing them. The decoder is
157
+ * hoisted for the same reason. Safe because the whole probe is synchronous.
158
+ */
159
+ const pathBuffer = new Uint8Array(PATH_BUFFER_BYTES);
160
+ const argvMib = new Int32Array([CTL_KERN, KERN_PROCARGS2, 0]);
161
+ const argvSize = new BigUint64Array([BigInt(ARGV_BUFFER_BYTES)]);
162
+ const argvBuffer = new Uint8Array(ARGV_BUFFER_BYTES);
163
+ const argvView = new DataView(argvBuffer.buffer);
164
+ const utf8Decoder = new TextDecoder();
165
+
152
166
  /**
153
167
  * Executable path, or null. Also answers for root-owned PIDs (verified: pid 1 →
154
168
  * `/sbin/launchd`), which is what makes it the identity fallback when argv is
@@ -157,10 +171,9 @@ const bsdInfoView = new DataView(bsdInfo.buffer);
157
171
  function executablePath(pid: number): string | null {
158
172
  const bound = bindLibs();
159
173
  if (bound === null) return null;
160
- const buffer = new Uint8Array(PATH_BUFFER_BYTES);
161
174
  try {
162
- const written = bound.libproc.symbols.proc_pidpath(pid, ptr(buffer), buffer.length);
163
- return written > 0 ? new TextDecoder().decode(buffer.subarray(0, written)) : null;
175
+ const written = bound.libproc.symbols.proc_pidpath(pid, ptr(pathBuffer), pathBuffer.length);
176
+ return written > 0 ? utf8Decoder.decode(pathBuffer.subarray(0, written)) : null;
164
177
  } catch {
165
178
  return null;
166
179
  }
@@ -176,28 +189,26 @@ function executablePath(pid: number): string | null {
176
189
  function argvOf(pid: number): string[] | null {
177
190
  const bound = bindLibs();
178
191
  if (bound === null) return null;
179
- const mib = new Int32Array([CTL_KERN, KERN_PROCARGS2, pid]);
180
- const size = new BigUint64Array([BigInt(ARGV_BUFFER_BYTES)]);
181
- const buffer = new Uint8Array(ARGV_BUFFER_BYTES);
192
+ argvMib[2] = pid;
193
+ argvSize[0] = BigInt(ARGV_BUFFER_BYTES);
182
194
  try {
183
- if (bound.libc.symbols.sysctl(ptr(mib), 3, ptr(buffer), ptr(size), null, 0) !== 0) return null;
195
+ if (bound.libc.symbols.sysctl(ptr(argvMib), 3, ptr(argvBuffer), ptr(argvSize), null, 0) !== 0) return null;
184
196
  } catch {
185
197
  return null;
186
198
  }
187
199
 
188
- const length = Number(size[0]);
200
+ const length = Number(argvSize[0]);
189
201
  if (length <= 4) return null;
190
- const argc = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getInt32(0, true);
202
+ const argc = argvView.getInt32(0, true);
191
203
  let cursor = 4;
192
- while (cursor < length && buffer[cursor] !== 0) cursor += 1;
193
- while (cursor < length && buffer[cursor] === 0) cursor += 1;
204
+ while (cursor < length && argvBuffer[cursor] !== 0) cursor += 1;
205
+ while (cursor < length && argvBuffer[cursor] === 0) cursor += 1;
194
206
 
195
- const decoder = new TextDecoder();
196
207
  const args: string[] = [];
197
208
  for (let index = 0; index < argc && cursor < length; index += 1) {
198
209
  let end = cursor;
199
- while (end < length && buffer[end] !== 0) end += 1;
200
- args.push(decoder.decode(buffer.subarray(cursor, end)));
210
+ while (end < length && argvBuffer[end] !== 0) end += 1;
211
+ args.push(utf8Decoder.decode(argvBuffer.subarray(cursor, end)));
201
212
  cursor = end + 1;
202
213
  }
203
214
  return args;
@@ -9,25 +9,35 @@
9
9
  * Incomplete trailing data stays buffered until the next chunk or stream end,
10
10
  * matching readline's line semantics. Per-line errors never surface: the
11
11
  * callback receiving them is the caller's boundary.
12
+ *
13
+ * The cursor is an offset into `buffer` rather than a re-slice per line:
14
+ * `buffer = buffer.slice(i + 1)` copies the entire remainder once per line,
15
+ * which is quadratic in a chunk carrying many frames. Measured on an 18 MB
16
+ * burst of 200k lines: 3.87 ms against 2.29 ms. The buffer is re-sliced once
17
+ * per chunk, after every complete line in it has been emitted.
12
18
  */
13
19
 
14
20
  export function readLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<void> {
15
21
  const decoder = new TextDecoder();
16
22
  let buffer = '';
23
+ let cursor = 0;
17
24
  return stream.pipeTo(
18
25
  new WritableStream({
19
26
  write(chunk) {
20
27
  buffer += decoder.decode(chunk, { stream: true });
21
- let newlineIndex = buffer.indexOf('\n');
22
- while (newlineIndex !== -1) {
23
- const line = buffer.slice(0, newlineIndex);
24
- buffer = buffer.slice(newlineIndex + 1);
25
- onLine(line);
26
- newlineIndex = buffer.indexOf('\n');
28
+ for (;;) {
29
+ const newlineIndex = buffer.indexOf('\n', cursor);
30
+ if (newlineIndex === -1) break;
31
+ onLine(buffer.slice(cursor, newlineIndex));
32
+ cursor = newlineIndex + 1;
27
33
  }
34
+ // Drop what has been emitted, so the buffer does not grow with the whole
35
+ // stream. One re-slice per chunk instead of one per line.
36
+ buffer = buffer.slice(cursor);
37
+ cursor = 0;
28
38
  },
29
39
  close() {
30
- const rest = buffer + decoder.decode();
40
+ const rest = buffer.slice(cursor) + decoder.decode();
31
41
  if (rest) onLine(rest);
32
42
  },
33
43
  }),
@@ -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
  /**
@@ -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[] = [];