@frebreco/canvas 0.0.1 → 0.2.0-next.1

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Files of the working dir, read-only (ADR 0002). The host's browser opens
3
+ * the paths its file frames show; the server sends each one now and again on
4
+ * every change (an agent writing a file shows up live), and the list of all
5
+ * shared files whenever it changes. Board paths come from guests, so every
6
+ * read goes through the shared set.
7
+ *
8
+ * One recursive watcher covers the working dir: editors and agents often
9
+ * replace files rather than write them in place, and a frame may wait for a
10
+ * file (or its directory) that does not exist yet.
11
+ */
12
+
13
+ import { readFileSync, statSync, watch, type FSWatcher } from "node:fs";
14
+ import { sep } from "node:path";
15
+ import type { FileContent } from "../shared/protocol";
16
+ import { SharedSet } from "./shared-set";
17
+
18
+ export const MAX_FILE_BYTES = 1024 * 1024;
19
+ const SETTLE_MS = 150;
20
+
21
+ export class Files {
22
+ private readonly shared: SharedSet;
23
+ /** Open paths → the clients that opened them. */
24
+ private readonly subscribers = new Map<string, Set<unknown>>();
25
+ /** What each open path last looked like, to only report real changes. */
26
+ private readonly last = new Map<string, string>();
27
+ private watcher: FSWatcher | null = null;
28
+ private treeWatched = false;
29
+ private lastTree = "";
30
+ private changed = new Set<string>();
31
+ private everything = false;
32
+ private timer: ReturnType<typeof setTimeout> | null = null;
33
+
34
+ constructor(
35
+ dir: string,
36
+ private readonly onFile: (path: string, file: FileContent) => void,
37
+ private readonly onTree: (paths: ReadonlyArray<string>) => void,
38
+ ) {
39
+ this.shared = new SharedSet(dir);
40
+ }
41
+
42
+ read(path: string): FileContent {
43
+ let absolute: string;
44
+ try {
45
+ absolute = this.shared.resolve(path);
46
+ } catch (error) {
47
+ return { kind: "denied", reason: (error as Error).message };
48
+ }
49
+ const stat = statSync(absolute, { throwIfNoEntry: false });
50
+ if (!stat) return { kind: "missing" };
51
+ if (!stat.isFile()) return { kind: "denied", reason: `${path} is not a file` };
52
+ if (stat.size > MAX_FILE_BYTES) return { kind: "too-large", size: stat.size };
53
+ const bytes = readFileSync(absolute);
54
+ if (bytes.subarray(0, 8000).includes(0)) return { kind: "binary", size: stat.size };
55
+ return { kind: "text", text: bytes.toString("utf8") };
56
+ }
57
+
58
+ open(path: string, client: unknown): void {
59
+ let set = this.subscribers.get(path);
60
+ if (!set) this.subscribers.set(path, (set = new Set()));
61
+ set.add(client);
62
+ this.send(path);
63
+ this.ensureWatcher();
64
+ }
65
+
66
+ close(path: string, client: unknown): void {
67
+ const set = this.subscribers.get(path);
68
+ set?.delete(client);
69
+ if (set?.size) return;
70
+ this.subscribers.delete(path);
71
+ this.last.delete(path);
72
+ }
73
+
74
+ /** A client went away: close everything it had open. */
75
+ drop(client: unknown): void {
76
+ for (const path of [...this.subscribers.keys()]) this.close(path, client);
77
+ }
78
+
79
+ watchTree(): void {
80
+ this.treeWatched = true;
81
+ this.lastTree = "";
82
+ this.sendTree();
83
+ this.ensureWatcher();
84
+ }
85
+
86
+ stop(): void {
87
+ this.watcher?.close();
88
+ if (this.timer) clearTimeout(this.timer);
89
+ }
90
+
91
+ private send(path: string) {
92
+ const file = this.read(path);
93
+ const key = JSON.stringify(file);
94
+ if (this.last.get(path) === key) return;
95
+ this.last.set(path, key);
96
+ this.onFile(path, file);
97
+ }
98
+
99
+ private sendTree() {
100
+ const paths = this.shared.list();
101
+ const key = paths.join("\0");
102
+ if (key === this.lastTree) return;
103
+ this.lastTree = key;
104
+ this.onTree(paths);
105
+ }
106
+
107
+ private ensureWatcher() {
108
+ if (this.watcher) return;
109
+ this.watcher = watch(this.shared.dir, { recursive: true }, (_, name) => {
110
+ if (name === null) this.everything = true;
111
+ else {
112
+ const path = name.split(sep).join("/");
113
+ // Our own state (the board is saved every few hundred ms).
114
+ if (path === ".canvas" || path.startsWith(".canvas/")) return;
115
+ this.changed.add(path);
116
+ }
117
+ if (!this.timer) this.timer = setTimeout(() => this.settle(), SETTLE_MS);
118
+ });
119
+ }
120
+
121
+ /** A burst of changes is over: re-read what it touched. */
122
+ private settle() {
123
+ this.timer = null;
124
+ const changed = this.changed;
125
+ const everything = this.everything;
126
+ this.changed = new Set();
127
+ this.everything = false;
128
+ for (const path of this.subscribers.keys()) {
129
+ // The file itself, or a directory above it (renamed, created, removed).
130
+ const touched =
131
+ everything || changed.has(path) || [...changed].some((name) => path.startsWith(`${name}/`));
132
+ if (touched) this.send(path);
133
+ }
134
+ if (this.treeWatched) this.sendTree();
135
+ }
136
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `canvas serve`: the local half of canvas. One WebSocket endpoint that only
3
+ * the host's browser may use (it presents the token from the printed link).
4
+ * Nothing here knows about guests — the host's browser is the relay.
5
+ */
6
+
7
+ import type { ServerWebSocket } from "bun";
8
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { ClientToServer, ServerToClient } from "../shared/protocol";
11
+ import { AgentManager, detectAgents } from "./agents";
12
+ import { BoardMcp } from "./board-mcp";
13
+ import { Files } from "./files";
14
+ import { Store } from "./store";
15
+ import { Terminals } from "./terminals";
16
+
17
+ export interface ServeOptions {
18
+ readonly dir: string;
19
+ readonly port: number;
20
+ readonly hostname: string;
21
+ readonly tls?: { readonly cert: string; readonly key: string };
22
+ }
23
+
24
+ export async function serve(options: ServeOptions) {
25
+ const store = new Store(options.dir);
26
+ excludeFromGit(options.dir);
27
+ const room = await store.room();
28
+ const clients = new Set<ServerWebSocket<unknown>>();
29
+ const broadcast = (message: ServerToClient) => {
30
+ const text = JSON.stringify(message);
31
+ for (const ws of clients) ws.send(text);
32
+ };
33
+
34
+ const files = new Files(
35
+ options.dir,
36
+ (path, file) => broadcast({ t: "file", path, file }),
37
+ (paths) => broadcast({ t: "tree", paths }),
38
+ );
39
+ process.on("exit", () => files.stop());
40
+ // Board tools: an agent's call goes to one host browser (the board is there).
41
+ const boardMcp = new BoardMcp({
42
+ read: (path) => files.read(path),
43
+ relay: (call) => {
44
+ const [ws] = clients;
45
+ if (!ws) return false;
46
+ ws.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
47
+ return true;
48
+ },
49
+ });
50
+ process.on("exit", () => boardMcp.stop());
51
+
52
+ const agentDefinitions = detectAgents();
53
+ const agents = new AgentManager({
54
+ dir: options.dir,
55
+ agents: agentDefinitions,
56
+ restored: store.sessions(),
57
+ mcpServers: (sessionId) => [boardMcp.serverFor(sessionId)],
58
+ onMeta: (meta) => {
59
+ store.appendMeta(meta);
60
+ broadcast({ t: "agent-meta", meta });
61
+ },
62
+ onEvent: (sessionId, event) => {
63
+ store.appendEvent(sessionId, event);
64
+ broadcast({ t: "agent-event", sessionId, event });
65
+ },
66
+ // Live state of the agent, not persisted: the meta keeps the values.
67
+ onOptions: (sessionId, options) => broadcast({ t: "agent-options", sessionId, options }),
68
+ onError: (message) => broadcast({ t: "error", message }),
69
+ });
70
+ process.on("exit", () => agents.close());
71
+ const terminals = new Terminals(
72
+ options.dir,
73
+ (id, data) => broadcast({ t: "term-data", id, data }),
74
+ (id, code) => broadcast({ t: "term-exit", id, code }),
75
+ );
76
+
77
+ const handle = (ws: ServerWebSocket<unknown>, message: ClientToServer) => {
78
+ switch (message.t) {
79
+ case "board-save":
80
+ return store.saveBoard(Buffer.from(message.state, "base64"));
81
+ case "agent-create":
82
+ return agents.create(message.id, message.agent);
83
+ case "agent-prompt":
84
+ return agents.prompt(message.sessionId, message.text, message.author);
85
+ case "agent-cancel":
86
+ return agents.cancel(message.sessionId);
87
+ case "agent-config":
88
+ return void agents
89
+ .configure(message.sessionId, message.configId, message.value)
90
+ .catch((error: unknown) => sendError(ws, error));
91
+ case "agent-permission":
92
+ return agents.resolvePermission(
93
+ message.sessionId,
94
+ message.requestId,
95
+ message.optionId,
96
+ message.by,
97
+ );
98
+ case "file-open":
99
+ return files.open(message.path, ws);
100
+ case "file-close":
101
+ return files.close(message.path, ws);
102
+ case "tree-watch":
103
+ return files.watchTree();
104
+ case "term-open": {
105
+ const scrollback = terminals.open(message.id, message.cols, message.rows);
106
+ if (scrollback)
107
+ ws.send(
108
+ JSON.stringify({
109
+ t: "term-data",
110
+ id: message.id,
111
+ data: scrollback,
112
+ } satisfies ServerToClient),
113
+ );
114
+ return;
115
+ }
116
+ case "term-input":
117
+ return terminals.input(message.id, message.data);
118
+ case "term-resize":
119
+ return terminals.resize(message.id, message.cols, message.rows);
120
+ case "board-result":
121
+ return boardMcp.result(message.callId, message.ok, message.text);
122
+ }
123
+ };
124
+
125
+ const server = Bun.serve({
126
+ port: options.port,
127
+ hostname: options.hostname,
128
+ ...(options.tls && {
129
+ tls: { cert: Bun.file(options.tls.cert), key: Bun.file(options.tls.key) },
130
+ }),
131
+ fetch(req, server) {
132
+ const url = new URL(req.url);
133
+ if (url.pathname !== "/ws")
134
+ return new Response("canvas server — open the link printed in your terminal", {
135
+ status: 404,
136
+ });
137
+ // A WebSocket is not subject to CORS: any page could open one to
138
+ // localhost. The token is what keeps other sites out.
139
+ if (url.searchParams.get("token") !== room.token)
140
+ return new Response("bad token", { status: 401 });
141
+ return server.upgrade(req) ? undefined : new Response("upgrade failed", { status: 400 });
142
+ },
143
+ websocket: {
144
+ maxPayloadLength: 64 * 1024 * 1024,
145
+ open(ws) {
146
+ clients.add(ws);
147
+ const board = store.board();
148
+ const { token: _, ...secrets } = room;
149
+ ws.send(
150
+ JSON.stringify({
151
+ t: "welcome",
152
+ room: secrets,
153
+ cwd: options.dir,
154
+ agents: agentDefinitions.map(({ kind, label }) => ({ kind, label })),
155
+ board: board ? Buffer.from(board).toString("base64") : null,
156
+ sessions: agents.snapshots(),
157
+ } satisfies ServerToClient),
158
+ );
159
+ },
160
+ message(ws, data) {
161
+ try {
162
+ handle(ws, JSON.parse(String(data)) as ClientToServer);
163
+ } catch (error) {
164
+ sendError(ws, error);
165
+ }
166
+ },
167
+ close(ws) {
168
+ clients.delete(ws);
169
+ files.drop(ws);
170
+ },
171
+ },
172
+ });
173
+
174
+ return { server, room };
175
+ }
176
+
177
+ function sendError(ws: ServerWebSocket<unknown>, error: unknown) {
178
+ const message = error instanceof Error ? error.message : String(error);
179
+ ws.send(JSON.stringify({ t: "error", message } satisfies ServerToClient));
180
+ }
181
+
182
+ /**
183
+ * Keep canvas's own files out of the user's commits: `.canvas/` holds board
184
+ * and session state. `.git/info/exclude` is local to the clone and never
185
+ * committed itself.
186
+ */
187
+ function excludeFromGit(dir: string) {
188
+ const exclude = join(dir, ".git", "info", "exclude");
189
+ if (!existsSync(join(dir, ".git", "info"))) return;
190
+ const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
191
+ const missing = [".canvas/"].filter((line) => !current.split("\n").includes(line));
192
+ if (missing.length)
193
+ appendFileSync(exclude, `\n# canvas: local state, never committed\n${missing.join("\n")}\n`);
194
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The files `canvas serve` is willing to show anyone (ADR 0002). Board paths
3
+ * are chosen by guests, so this is the ceiling of what a frame can reach:
4
+ *
5
+ * - inside the working dir, after resolving symlinks;
6
+ * - not in `.git/` or `.canvas/`, and not a well-known secret file;
7
+ * - in a git repo: not ignored by git (tracked files count as not ignored);
8
+ * outside git: not under `node_modules/` or a dot-directory.
9
+ *
10
+ * The tree lists the same set. The server cannot tell the host's own requests
11
+ * from a guest's, so the host sees the same set.
12
+ */
13
+
14
+ import { lstatSync, readdirSync, realpathSync } from "node:fs";
15
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
+
17
+ const HIDDEN_DIRS = new Set([".git", ".canvas"]);
18
+ const SECRET_NAMES = new Set([
19
+ ".env",
20
+ ".npmrc",
21
+ ".netrc",
22
+ ".pypirc",
23
+ ".git-credentials",
24
+ ".htpasswd",
25
+ "id_rsa",
26
+ "id_ecdsa",
27
+ "id_ed25519",
28
+ "id_dsa",
29
+ ]);
30
+ const SECRET_EXTENSIONS = [".pem", ".key", ".p12", ".pfx"];
31
+ const HARMLESS_ENV = /\.(example|sample|template)$/;
32
+ /** A walk outside git stops here rather than stream a whole disk. */
33
+ const WALK_LIMIT = 50_000;
34
+
35
+ /** Why a working-dir-relative path is never shared, whatever git says; null if it may be. */
36
+ export function refusal(path: string): string | null {
37
+ const segments = path.split(/[\\/]/);
38
+ if (segments.some((segment) => HIDDEN_DIRS.has(segment)))
39
+ return "canvas never shares .git or .canvas";
40
+ const name = segments.at(-1) ?? "";
41
+ if (
42
+ SECRET_NAMES.has(name) ||
43
+ (name.startsWith(".env.") && !HARMLESS_ENV.test(name)) ||
44
+ SECRET_EXTENSIONS.some((extension) => name.endsWith(extension))
45
+ )
46
+ return "looks like a secret";
47
+ return null;
48
+ }
49
+
50
+ export class SharedSet {
51
+ readonly dir: string;
52
+ readonly git: boolean;
53
+
54
+ constructor(dir: string) {
55
+ this.dir = realpathSync(dir);
56
+ this.git = Bun.spawnSync(["git", "rev-parse", "--is-inside-work-tree"], { cwd: this.dir })
57
+ .stdout.toString()
58
+ .startsWith("true");
59
+ }
60
+
61
+ /**
62
+ * The absolute path behind a board path, if it is shared; throws why not.
63
+ * The file need not exist yet — an agent may be about to write it.
64
+ */
65
+ resolve(path: string): string {
66
+ if (!path || isAbsolute(path) || path.includes("\0"))
67
+ throw new Error(`not a file path: ${path}`);
68
+ const absolute = resolve(this.dir, path);
69
+ const rel = relative(this.dir, absolute);
70
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
71
+ throw new Error(`${path} is outside the working dir`);
72
+ this.check(rel, path);
73
+
74
+ // Symlinks: what the path really points at must pass the same rules.
75
+ let real: string;
76
+ try {
77
+ real = realpathOfNearest(absolute);
78
+ } catch {
79
+ throw new Error(`${path} is a broken link`);
80
+ }
81
+ const realRel = relative(this.dir, real);
82
+ if (realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel))
83
+ throw new Error(`${path} links outside the working dir`);
84
+ if (realRel && realRel !== rel) this.check(realRel, path);
85
+ return absolute;
86
+ }
87
+
88
+ private check(rel: string, path: string) {
89
+ const refused = refusal(rel);
90
+ if (refused) throw new Error(`${path} is not shared: ${refused}`);
91
+ if (this.git) {
92
+ // 0: ignored, 1: not ignored, 128: git refuses (e.g. beyond a symlink).
93
+ const { exitCode } = Bun.spawnSync(["git", "check-ignore", "-q", "--", rel], {
94
+ cwd: this.dir,
95
+ });
96
+ if (exitCode === 0) throw new Error(`${path} is not shared: ignored by git`);
97
+ if (exitCode !== 1) throw new Error(`${path} is not shared: git can't place it (a symlink?)`);
98
+ } else if (
99
+ rel
100
+ .split(sep)
101
+ .some((s, i, all) => s === "node_modules" || (s.startsWith(".") && i < all.length - 1))
102
+ )
103
+ throw new Error(`${path} is not shared: in a hidden or dependency dir`);
104
+ }
105
+
106
+ /**
107
+ * Every shared file, as working-dir-relative paths with `/` separators.
108
+ * Symlinks are listed only if what they point at is shared too. Git knows
109
+ * which tracked files are links; only untracked ones need an lstat (one
110
+ * per file costs ~280 ms on a 21k-file repo, too slow for every change).
111
+ */
112
+ list(): string[] {
113
+ if (!this.git) return this.walk();
114
+ const git = (...args: string[]) =>
115
+ Bun.spawnSync(["git", "ls-files", "-z", ...args], { cwd: this.dir })
116
+ .stdout.toString()
117
+ .split("\0")
118
+ .filter(Boolean);
119
+ const links: string[] = [];
120
+ // `<mode> <object> <stage>\t<path>`; 120000 is a symlink.
121
+ const tracked = git("--stage").map((entry) => {
122
+ const path = entry.slice(entry.indexOf("\t") + 1);
123
+ if (entry.startsWith("120000")) links.push(path);
124
+ return path;
125
+ });
126
+ const untracked = git("--others", "--exclude-standard");
127
+ for (const path of untracked)
128
+ if (lstatSync(join(this.dir, path), { throwIfNoEntry: false })?.isSymbolicLink())
129
+ links.push(path);
130
+ const hidden = new Set([...git("--deleted"), ...links.filter((path) => !this.shares(path))]);
131
+ return [...new Set([...tracked, ...untracked])]
132
+ .filter((path) => !hidden.has(path) && !refusal(path))
133
+ .sort();
134
+ }
135
+
136
+ private shares(path: string): boolean {
137
+ try {
138
+ this.resolve(path);
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ private walk(): string[] {
146
+ const paths: string[] = [];
147
+ const visit = (dir: string) => {
148
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
149
+ if (paths.length >= WALK_LIMIT) return;
150
+ const path = join(dir, entry.name);
151
+ if (entry.isDirectory()) {
152
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
153
+ visit(path);
154
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
155
+ const rel = relative(this.dir, path).split(sep).join("/");
156
+ if (!refusal(rel) && (entry.isFile() || this.shares(rel))) paths.push(rel);
157
+ }
158
+ }
159
+ };
160
+ visit(this.dir);
161
+ return paths.sort();
162
+ }
163
+ }
164
+
165
+ /**
166
+ * realpath of the path, or of its nearest ancestor that exists (plus the
167
+ * rest). A dangling symlink counts as existing, so it throws rather than let
168
+ * a later write follow it.
169
+ */
170
+ function realpathOfNearest(absolute: string): string {
171
+ let existing = absolute;
172
+ while (!lstatSync(existing, { throwIfNoEntry: false }) && dirname(existing) !== existing)
173
+ existing = dirname(existing);
174
+ return join(realpathSync(existing), relative(existing, absolute));
175
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Everything `canvas serve` persists lives in `<dir>/.canvas/`:
3
+ *
4
+ * room.json room id, trystero key, host keypair, host token
5
+ * board.bin the latest Yjs state of the board
6
+ * sessions/<id>.ndjson one agent session: a meta line, then its events
7
+ *
8
+ * Keeping the room stable across restarts keeps the links people already
9
+ * have working.
10
+ */
11
+
12
+ import {
13
+ appendFileSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { join } from "node:path";
21
+ import type { AgentEvent, RoomSecrets, SessionMeta, SessionSnapshot } from "../shared/protocol";
22
+
23
+ export interface RoomFile extends RoomSecrets {
24
+ /** Proves a WebSocket client is the host's browser. */
25
+ readonly token: string;
26
+ }
27
+
28
+ const randomId = (bytes: number) =>
29
+ Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
30
+
31
+ export class Store {
32
+ private readonly root: string;
33
+
34
+ constructor(dir: string) {
35
+ this.root = join(dir, ".canvas");
36
+ mkdirSync(join(this.root, "sessions"), { recursive: true });
37
+ }
38
+
39
+ async room(): Promise<RoomFile> {
40
+ const path = join(this.root, "room.json");
41
+ if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8")) as RoomFile;
42
+
43
+ const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
44
+ "sign",
45
+ "verify",
46
+ ]);
47
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
48
+ const room: RoomFile = {
49
+ roomId: randomId(9),
50
+ key: randomId(18),
51
+ hostPublicKey: Buffer.from(raw).toString("base64url"),
52
+ hostPrivateKey: await crypto.subtle.exportKey("jwk", pair.privateKey),
53
+ token: randomId(24),
54
+ };
55
+ writeFileSync(path, JSON.stringify(room, null, 2), { mode: 0o600 });
56
+ return room;
57
+ }
58
+
59
+ board(): Uint8Array | null {
60
+ const path = join(this.root, "board.bin");
61
+ return existsSync(path) ? new Uint8Array(readFileSync(path)) : null;
62
+ }
63
+
64
+ saveBoard(state: Uint8Array): void {
65
+ writeFileSync(join(this.root, "board.bin"), state);
66
+ }
67
+
68
+ sessions(): SessionSnapshot[] {
69
+ const dir = join(this.root, "sessions");
70
+ return readdirSync(dir)
71
+ .filter((name) => name.endsWith(".ndjson"))
72
+ .flatMap((name) => {
73
+ let meta: SessionMeta | undefined;
74
+ const events: AgentEvent[] = [];
75
+ for (const line of readFileSync(join(dir, name), "utf8").split("\n")) {
76
+ if (!line) continue;
77
+ const record = JSON.parse(line) as { meta?: SessionMeta; event?: AgentEvent };
78
+ if (record.meta) meta = record.meta;
79
+ if (record.event) events.push(record.event);
80
+ }
81
+ return meta ? [{ meta, events }] : [];
82
+ });
83
+ }
84
+
85
+ appendMeta(meta: SessionMeta): void {
86
+ appendFileSync(
87
+ join(this.root, "sessions", `${meta.id}.ndjson`),
88
+ `${JSON.stringify({ meta })}\n`,
89
+ );
90
+ }
91
+
92
+ appendEvent(sessionId: string, event: AgentEvent): void {
93
+ appendFileSync(
94
+ join(this.root, "sessions", `${sessionId}.ndjson`),
95
+ `${JSON.stringify({ event })}\n`,
96
+ );
97
+ }
98
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shell sessions on a PTY (`Bun.Terminal`). Output is kept in a bounded
3
+ * scrollback so a browser that reconnects, or a guest who joins late, can
4
+ * replay the screen.
5
+ */
6
+
7
+ const SCROLLBACK = 200_000;
8
+
9
+ interface Term {
10
+ terminal: Bun.Terminal;
11
+ scrollback: string;
12
+ }
13
+
14
+ export class Terminals {
15
+ private readonly terms = new Map<string, Term>();
16
+ private readonly decoder = new TextDecoder();
17
+
18
+ constructor(
19
+ private readonly dir: string,
20
+ private readonly onData: (id: string, data: string) => void,
21
+ private readonly onExit: (id: string, code: number | null) => void,
22
+ ) {}
23
+
24
+ /** Open (or re-attach to) a terminal; returns the scrollback to replay. */
25
+ open(id: string, cols: number, rows: number): string {
26
+ const existing = this.terms.get(id);
27
+ if (existing) {
28
+ existing.terminal.resize(cols, rows);
29
+ return existing.scrollback;
30
+ }
31
+
32
+ const term: Term = {
33
+ scrollback: "",
34
+ terminal: new Bun.Terminal({
35
+ cols,
36
+ rows,
37
+ data: (_, bytes) => {
38
+ const data = this.decoder.decode(bytes, { stream: true });
39
+ term.scrollback = (term.scrollback + data).slice(-SCROLLBACK);
40
+ this.onData(id, data);
41
+ },
42
+ }),
43
+ };
44
+ this.terms.set(id, term);
45
+ const shell = process.env.SHELL ?? "bash";
46
+ const proc = Bun.spawn([shell, "-l"], {
47
+ cwd: this.dir,
48
+ terminal: term.terminal,
49
+ env: { ...process.env, TERM: "xterm-256color" },
50
+ });
51
+ void proc.exited.then((code) => {
52
+ this.terms.delete(id);
53
+ this.onExit(id, code);
54
+ });
55
+ return "";
56
+ }
57
+
58
+ input(id: string, data: string): void {
59
+ this.terms.get(id)?.terminal.write(data);
60
+ }
61
+
62
+ resize(id: string, cols: number, rows: number): void {
63
+ this.terms.get(id)?.terminal.resize(cols, rows);
64
+ }
65
+ }