ompchamber 3.3.1 → 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/dist/client/index.html +1 -1
- package/dist/client/index.js +290 -291
- package/dist/client/static/js/{index-4qx6m2p7.js → index-e3axkbk1.js} +333 -333
- package/package.json +1 -1
- package/src/server/lib/fs/git-repos.ts +45 -25
- package/src/server/lib/lifecycle/proc/darwin.ts +25 -14
- package/src/server/lib/omp/rpc/lines.ts +17 -7
- package/src/server/lib/omp/session/usage/aggregate.ts +41 -7
- package/src/server/lib/terminal/shell.test.ts +20 -1
- package/src/server/lib/terminal/shell.ts +32 -7
- package/src/server/routes/fs/read.ts +9 -18
- package/src/shared/lib/code/highlighter.ts +19 -1
- package/src/shared/lib/omp/rpc/frame.ts +21 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "3.3.
|
|
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>",
|
|
@@ -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
|
|
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
|
|
27
|
+
import fs from 'fs';
|
|
28
|
+
import path from 'path';
|
|
20
29
|
|
|
21
30
|
const MAX_GIT_DEPTH = 8;
|
|
22
31
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
163
|
-
return written > 0 ?
|
|
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
|
-
|
|
180
|
-
|
|
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(
|
|
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(
|
|
200
|
+
const length = Number(argvSize[0]);
|
|
189
201
|
if (length <= 4) return null;
|
|
190
|
-
const argc =
|
|
202
|
+
const argc = argvView.getInt32(0, true);
|
|
191
203
|
let cursor = 4;
|
|
192
|
-
while (cursor < length &&
|
|
193
|
-
while (cursor < length &&
|
|
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 &&
|
|
200
|
-
args.push(
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
buffer
|
|
25
|
-
|
|
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
|
|
163
|
-
|
|
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:
|
|
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
|
-
*
|
|
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.
|
|
62
|
-
* macOS: with the shim `tpgid` is the shell's own pid and
|
|
63
|
-
* without it `tpgid` is
|
|
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
|
|
67
|
-
* unredirected — stdio is already
|
|
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 [
|
|
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
|
|
50
|
+
let names: string[] = [];
|
|
51
51
|
try {
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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[] = [];
|