@kauakdev/kauak 0.0.0-stage → 0.1.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,2387 @@
1
+ import fs$1, { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import http from "node:http";
4
+ import { WebSocketServer } from "ws";
5
+ import fs from "node:fs/promises";
6
+ import os from "node:os";
7
+ import { spawn, execFile } from "node:child_process";
8
+ import { EventEmitter } from "node:events";
9
+ import net from "node:net";
10
+ const AGENT_STATUSES = ["idle", "working", "blocked", "done", "unknown"];
11
+ const SSH_TARGET = /^[A-Za-z0-9_][A-Za-z0-9._@-]{0,127}$/;
12
+ const GIT_REF = /^[A-Za-z0-9_.][A-Za-z0-9_./-]{0,199}$/;
13
+ const AGENT_KIND = /^[a-z][a-z0-9_-]{0,31}$/;
14
+ const KEY = /^(?:(?:ctrl|alt|shift)\+){0,3}(?:enter|esc|tab|backspace|up|down|left|right|[a-z0-9])$/;
15
+ const MAX_READ_LINES = 5e3;
16
+ const MAX_INPUT_OPS = 256;
17
+ const MAX_INPUT_TEXT = 64 * 1024;
18
+ const MAX_ID = 512;
19
+ const MAX_FIELD = 4096;
20
+ const PARSERS = {
21
+ add_machine: (m) => str(m.ssh, MAX_FIELD, true) ? { type: "add_machine", ssh: m.ssh.trim(), label: str(m.label, MAX_FIELD) ? m.label.trim() : "" } : null,
22
+ remove_machine: (m) => str(m.machine) ? { type: "remove_machine", machine: m.machine } : null,
23
+ refresh: (m) => str(m.machine) ? { type: "refresh", machine: m.machine } : null,
24
+ // Everything else is about something on one floor, named in `machine`.
25
+ focus: (m) => str(m.machine) && str(m.pane_id) ? { type: "focus", machine: m.machine, pane_id: m.pane_id } : null,
26
+ commands: (m) => str(m.machine) && str(m.pane_id) ? { type: "commands", machine: m.machine, pane_id: m.pane_id } : null,
27
+ read: (m) => {
28
+ if (!str(m.machine) || !str(m.pane_id)) return null;
29
+ const lines2 = int(m.lines) && m.lines > 0 ? Math.min(m.lines, MAX_READ_LINES) : null;
30
+ return { type: "read", machine: m.machine, pane_id: m.pane_id, lines: lines2, ...typeof m.seq === "number" ? { seq: m.seq } : {} };
31
+ },
32
+ input: (m) => {
33
+ if (!str(m.machine) || !str(m.pane_id) || !Array.isArray(m.ops)) return null;
34
+ return { type: "input", machine: m.machine, pane_id: m.pane_id, ops: inputOps(m.ops), ...idOf(m) };
35
+ },
36
+ uncommitted: (m) => str(m.machine) && str(m.root, MAX_FIELD) ? { type: "uncommitted", machine: m.machine, root: m.root, ...idOf(m) } : null,
37
+ create_desk: (m) => {
38
+ if (!str(m.machine) || !str(m.workspace_id)) return null;
39
+ return { type: "create_desk", machine: m.machine, workspace_id: m.workspace_id, agent: agentOf(m.agent), ...idOf(m) };
40
+ },
41
+ create_room: (m) => {
42
+ if (!str(m.machine)) return null;
43
+ const room = roomSpec$1(m.room);
44
+ return room ? { type: "create_room", machine: m.machine, room, agent: agentOf(m.agent), ...idOf(m) } : null;
45
+ }
46
+ };
47
+ function parseClientMessage(value) {
48
+ if (!isObject(value) || typeof value.type !== "string" || !isClientType(value.type)) return null;
49
+ return PARSERS[value.type](value);
50
+ }
51
+ function isClientType(type) {
52
+ return Object.hasOwn(PARSERS, type);
53
+ }
54
+ function idOf(m) {
55
+ return int(m.id) ? { id: m.id } : {};
56
+ }
57
+ function inputOps(list2) {
58
+ const ops = [];
59
+ for (const op of list2.slice(0, MAX_INPUT_OPS)) {
60
+ if (typeof op?.text === "string") {
61
+ if (op.text) ops.push({ text: op.text.slice(0, MAX_INPUT_TEXT) });
62
+ } else if (Array.isArray(op?.keys)) {
63
+ const keys = op.keys.filter((k) => typeof k === "string" && KEY.test(k));
64
+ if (keys.length) ops.push({ keys });
65
+ }
66
+ }
67
+ return ops;
68
+ }
69
+ function agentOf(v) {
70
+ return typeof v === "string" && v ? v.slice(0, 64) : null;
71
+ }
72
+ function roomSpec$1(r) {
73
+ if (!isObject(r) || !str(r.cwd, MAX_FIELD, true) || r.label !== void 0 && !str(r.label, MAX_FIELD, true)) return null;
74
+ const label = typeof r.label === "string" ? { label: r.label } : {};
75
+ if (r.kind === "folder") return { kind: r.kind, cwd: r.cwd, ...label };
76
+ if (r.kind !== "worktree" || !str(r.branch, MAX_FIELD, true) || r.base !== void 0 && !str(r.base, MAX_FIELD, true)) return null;
77
+ return { kind: r.kind, cwd: r.cwd, branch: r.branch, ...typeof r.base === "string" ? { base: r.base } : {}, ...label };
78
+ }
79
+ function isObject(v) {
80
+ return typeof v === "object" && v !== null && !Array.isArray(v);
81
+ }
82
+ function int(v) {
83
+ return Number.isInteger(v);
84
+ }
85
+ function str(v, max = MAX_ID, empty = false) {
86
+ return typeof v === "string" && v.length <= max && (empty || v.length > 0);
87
+ }
88
+ const MAX_BYTES$1 = 64 * 1024;
89
+ async function readAppearances(settings) {
90
+ const dir = path.resolve(settings.appearanceDir);
91
+ const files = /* @__PURE__ */ new Set();
92
+ let names = [];
93
+ try {
94
+ names = (await fs.readdir(dir)).filter((name) => name.toLowerCase().endsWith(".json") && !name.startsWith("."));
95
+ } catch {
96
+ }
97
+ for (const name of names.sort()) files.add(path.join(dir, name));
98
+ for (const file of settings.appearanceFiles) files.add(path.resolve(file));
99
+ return { dir, packages: await Promise.all([...files].map(readPackage)) };
100
+ }
101
+ async function readPackage(file) {
102
+ try {
103
+ const st = await fs.stat(file);
104
+ if (!st.isFile()) return { file, error: "not a file" };
105
+ if (st.size > MAX_BYTES$1) return { file, error: `${Math.ceil(st.size / 1024)} KB; a package is 64 KB at most` };
106
+ return { file, package: JSON.parse(await fs.readFile(file, "utf8")) };
107
+ } catch (err) {
108
+ const e = err;
109
+ return { file, error: e.code === "ENOENT" ? "no such file" : e instanceof SyntaxError ? `not valid JSON (${e.message})` : e.message };
110
+ }
111
+ }
112
+ const CONTENT_TYPES = {
113
+ ".html": "text/html; charset=utf-8",
114
+ ".js": "text/javascript; charset=utf-8",
115
+ ".css": "text/css; charset=utf-8",
116
+ ".svg": "image/svg+xml",
117
+ ".png": "image/png",
118
+ ".ico": "image/x-icon",
119
+ ".json": "application/json",
120
+ ".woff2": "font/woff2"
121
+ };
122
+ const APPEARANCES_PATH = "/appearances.json";
123
+ class WsServer {
124
+ settings;
125
+ /** The built page's folder, ending in a separator so nothing beside it can pass for a file in it; null for none. */
126
+ pageDir;
127
+ hasPage;
128
+ allowedOrigins;
129
+ server;
130
+ wss;
131
+ constructor(settings, connected) {
132
+ this.settings = settings;
133
+ this.pageDir = settings.pageDir === null ? null : path.resolve(settings.pageDir) + path.sep;
134
+ this.hasPage = this.pageDir !== null && fs$1.existsSync(path.join(this.pageDir, "index.html"));
135
+ this.allowedOrigins = new Set(settings.origins);
136
+ this.server = http.createServer((req, res) => this.servePage(req, res));
137
+ this.wss = new WebSocketServer({
138
+ server: this.server,
139
+ // Browsers let any web page open a WebSocket to 127.0.0.1; only accept our own page.
140
+ verifyClient: ({ origin }) => !origin || this.ours(origin)
141
+ // no origin: not a browser
142
+ });
143
+ this.wss.on("connection", (ws) => {
144
+ console.log(`[bridge] client connected (${this.wss.clients.size})`);
145
+ const received = connected({ send: (msg) => ws.send(JSON.stringify(msg)) });
146
+ ws.on("message", (raw) => received(raw.toString()));
147
+ });
148
+ }
149
+ /** Whether a page at this URL is ours: its hostname is this computer's, or one KAUAK_ORIGINS adds. */
150
+ ours(url) {
151
+ try {
152
+ return this.allowedOrigins.has(new URL(url).hostname);
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+ /** Static files from the built page, which are not secret, and the installed appearances, which only our own pages get. */
158
+ servePage(req, res) {
159
+ if (req.method !== "GET" && req.method !== "HEAD") return res.writeHead(405).end();
160
+ let rel;
161
+ try {
162
+ rel = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
163
+ } catch {
164
+ return res.writeHead(400).end();
165
+ }
166
+ if (rel.endsWith("/")) rel += "index.html";
167
+ if (rel === APPEARANCES_PATH) {
168
+ this.serveAppearances(req, res).catch(() => res.headersSent || res.writeHead(500).end());
169
+ return;
170
+ }
171
+ const notFound = () => {
172
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
173
+ res.end(this.hasPage ? "Not found\n" : "The office page is not built. Run `pnpm build`, or `pnpm dev` for the Vite dev server.\n");
174
+ };
175
+ if (this.pageDir === null) return notFound();
176
+ const file = path.resolve(this.pageDir, `.${rel}`);
177
+ if (!file.startsWith(this.pageDir)) return res.writeHead(404).end();
178
+ fs$1.stat(file, (err, st) => {
179
+ if (err || !st.isFile()) return notFound();
180
+ res.writeHead(200, {
181
+ "content-type": CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream",
182
+ "content-length": st.size,
183
+ // Vite puts a content hash in every asset name; index.html must always be fresh.
184
+ "cache-control": rel.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache",
185
+ "x-content-type-options": "nosniff"
186
+ });
187
+ if (req.method === "HEAD") return res.end();
188
+ fs$1.createReadStream(file).pipe(res);
189
+ });
190
+ }
191
+ /**
192
+ * The appearance packages installed on this machine, read now, so an edited
193
+ * file shows on reload. They carry this machine's paths and whatever the
194
+ * files hold, so they are answered only under a name the WebSocket accepts
195
+ * a page from: a site that points its own name at 127.0.0.1 (DNS rebinding)
196
+ * is refused. A HEAD reads nothing.
197
+ */
198
+ async serveAppearances(req, res) {
199
+ const { host, origin } = req.headers;
200
+ if (!host || !this.ours(`http://${host}`) || origin !== void 0 && !this.ours(origin)) return res.writeHead(403).end();
201
+ const headers = { "content-type": "application/json", "cache-control": "no-cache", "x-content-type-options": "nosniff" };
202
+ if (req.method === "HEAD") return res.writeHead(200, headers).end();
203
+ const body = JSON.stringify(await readAppearances(this.settings));
204
+ res.writeHead(200, { ...headers, "content-length": Buffer.byteLength(body) }).end(body);
205
+ }
206
+ /** To every page connected, encoded once. */
207
+ broadcast(msg) {
208
+ const data = JSON.stringify(msg);
209
+ for (const client of this.wss.clients) if (client.readyState === 1) client.send(data);
210
+ }
211
+ /**
212
+ * Opens the port. Resolves with the WebSocket's address and the office's
213
+ * URL, null when there is no built page; rejects when the port cannot be
214
+ * opened, after saying why.
215
+ */
216
+ listen() {
217
+ const { port, host } = this.settings;
218
+ return new Promise((resolve, reject) => {
219
+ this.wss.once("error", (err) => {
220
+ console.error(
221
+ err.code === "EADDRINUSE" ? `[bridge] port ${port} is already in use. Is the office already running? Pick another port with --port or KAUAK_PORT.` : `[bridge] cannot listen on ${host}:${port}: ${err.message}`
222
+ );
223
+ reject(err);
224
+ });
225
+ this.server.listen(port, host, () => {
226
+ const shown = host.includes(":") ? `[${host}]` : host;
227
+ resolve({
228
+ address: `ws://${shown}:${port}`,
229
+ page: this.hasPage ? `http://${shown === "0.0.0.0" || shown === "[::]" ? "127.0.0.1" : shown}:${port}/` : null
230
+ });
231
+ });
232
+ });
233
+ }
234
+ /** Drops every page and closes the port. */
235
+ close() {
236
+ for (const ws of this.wss.clients) ws.terminate();
237
+ return new Promise((resolve) => {
238
+ this.wss.close();
239
+ this.server.close(() => resolve());
240
+ this.server.closeAllConnections();
241
+ });
242
+ }
243
+ }
244
+ async function build(page, m, msg) {
245
+ const agent = msg.agent;
246
+ let paneId;
247
+ try {
248
+ if (agent && !AGENT_KIND.test(agent)) throw new Error(`Unknown agent kind "${agent}".`);
249
+ paneId = msg.type === "create_desk" ? await m.createDesk(msg.workspace_id) : await m.createRoom(roomSpec(m, msg.room));
250
+ await m.refresh();
251
+ } catch (err) {
252
+ page.send({ machine: m.id, id: msg.id, type: "create_error", message: err.message });
253
+ return;
254
+ }
255
+ page.send({ machine: m.id, id: msg.id, type: "created", pane_id: paneId });
256
+ if (!agent) return;
257
+ try {
258
+ await m.startAgent(agent, paneId);
259
+ } catch (err) {
260
+ const message = `The desk is ready, but ${agent} did not start: ${err.message}`;
261
+ page.send({ machine: m.id, id: msg.id, type: "create_error", pane_id: paneId, message });
262
+ }
263
+ }
264
+ function roomSpec(m, room) {
265
+ const cwd = roomPath(m, room.cwd);
266
+ const label = room.label?.trim() ? room.label.trim().slice(0, 60) : void 0;
267
+ if (room.kind === "folder") return { kind: "folder", cwd, label };
268
+ const branch = room.branch.trim();
269
+ const base = room.base?.trim() || void 0;
270
+ if (!GIT_REF.test(branch)) throw new Error("Enter a branch name like feat/my-change.");
271
+ if (base && !GIT_REF.test(base)) throw new Error(`"${base}" is not a branch or commit.`);
272
+ return { kind: "worktree", cwd, branch, base, label };
273
+ }
274
+ function roomPath(m, raw) {
275
+ let p = raw.trim();
276
+ if (!p || p.length > 1024) throw new Error("Enter a folder.");
277
+ if (!m.ssh && (p === "~" || p.startsWith("~/"))) p = path.join(os.homedir(), p.slice(1));
278
+ if (!p.startsWith("/")) throw new Error(`Use an absolute path${m.ssh ? ` on ${m.label}` : ""}, like /home/you/code/project.`);
279
+ if (!m.ssh && !fs$1.statSync(p, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`There is no folder at ${p}.`);
280
+ return p;
281
+ }
282
+ function onFloor(handle2) {
283
+ return (msg, page, bridge) => {
284
+ const m = bridge.machines.get(msg.machine);
285
+ if (m) return handle2(m, msg, page, bridge);
286
+ };
287
+ }
288
+ const HANDLERS = {
289
+ add_machine: (msg, page, bridge) => {
290
+ const ssh = msg.ssh;
291
+ if (!SSH_TARGET.test(ssh)) {
292
+ page.send({ type: "machine_error", message: "Use an SSH host, user@host, or a Host alias from ~/.ssh/config." });
293
+ return;
294
+ }
295
+ if ([...bridge.machines.values()].some((m2) => m2.ssh === ssh)) {
296
+ page.send({ type: "machine_error", message: `${ssh} already has a floor.` });
297
+ return;
298
+ }
299
+ const label = msg.label || ssh.split("@").pop();
300
+ const m = bridge.addMachine({ id: bridge.uniqueId(label), label: label.slice(0, 40), ssh });
301
+ bridge.startMachine(m);
302
+ bridge.saveConfig();
303
+ bridge.broadcastMachines();
304
+ page.send({ type: "machine_added", machine: m.id });
305
+ },
306
+ remove_machine: onFloor((m, _msg, _page, bridge) => {
307
+ if (m.id === "local") return;
308
+ bridge.removeMachine(m);
309
+ bridge.saveConfig();
310
+ bridge.broadcastMachines();
311
+ }),
312
+ focus: onFloor(async (m, msg, page) => {
313
+ try {
314
+ await m.focusPane(msg.pane_id);
315
+ } catch (err) {
316
+ page.send({ type: "error", machine: m.id, message: err.message });
317
+ }
318
+ }),
319
+ // Terminal view: the pane's screen, or with `lines` the last `lines` rows
320
+ // of its history and screen. Reads run in parallel (each Herdr request
321
+ // takes ~100 ms); `seq` is echoed so the client can drop replies that
322
+ // arrive out of order.
323
+ read: onFloor(async (m, msg, page) => {
324
+ try {
325
+ const text = await m.readPane(msg.pane_id, msg.lines);
326
+ page.send({ type: "pane_output", machine: m.id, pane_id: msg.pane_id, text, seq: msg.seq });
327
+ } catch (err) {
328
+ page.send({ type: "error", machine: m.id, pane_id: msg.pane_id, message: err.message });
329
+ }
330
+ }),
331
+ // Keystrokes from the browser terminal. `ops` is an ordered list of
332
+ // { text } (literal bytes) and { keys } (named keys such as "enter" or
333
+ // "ctrl+c"), queued per pane (core/input.ts).
334
+ input: onFloor((m, msg, page, bridge) => bridge.input.queue(page, m, msg.pane_id, msg.ops, msg.id)),
335
+ // The message box's "/" menu. The agent and its folder come from the
336
+ // snapshot, not the page; only this machine's files are read.
337
+ commands: onFloor(async (m, msg, page, bridge) => {
338
+ const pane = m.snapshot?.panes.find((p) => p.pane_id === msg.pane_id);
339
+ if (!pane) return;
340
+ const commands = await bridge.deps.commands(pane.agent, pane.cwd, !m.ssh);
341
+ page.send({ type: "commands", machine: m.id, pane_id: msg.pane_id, agent: pane.agent, commands });
342
+ }),
343
+ // A printer's uncommitted view. Only checkouts a room is in are read (the floor's Printers).
344
+ uncommitted: onFloor(async (m, msg, page, bridge) => {
345
+ const uncommitted = await bridge.enriched.get(m.id).printers.uncommitted(msg.root);
346
+ page.send({ type: "uncommitted", machine: m.id, root: msg.root, id: msg.id, ...uncommitted });
347
+ }),
348
+ refresh: onFloor((m) => m.scheduleRefresh()),
349
+ // Build mode (core/build.ts).
350
+ create_desk: onFloor((m, msg, page) => build(page, m, msg)),
351
+ create_room: onFloor((m, msg, page) => build(page, m, msg))
352
+ };
353
+ function handle(msg, page, bridge) {
354
+ return run(msg.type, msg, page, bridge);
355
+ }
356
+ function run(type, msg, page, bridge) {
357
+ return HANDLERS[type](msg, page, bridge);
358
+ }
359
+ class InputQueue {
360
+ /** "machine/pane" → promise chain (see queue) */
361
+ queues;
362
+ /** "machine/pane" → batch still waiting for its turn */
363
+ openBatches;
364
+ constructor() {
365
+ this.queues = /* @__PURE__ */ new Map();
366
+ this.openBatches = /* @__PURE__ */ new Map();
367
+ }
368
+ /** Runs `job` after every job before it with the same key, whether they failed or not. */
369
+ enqueue(key, job) {
370
+ const prev = this.queues.get(key) ?? Promise.resolve();
371
+ const next = prev.then(job, job).finally(() => {
372
+ if (this.queues.get(key) === next) this.queues.delete(key);
373
+ });
374
+ this.queues.set(key, next);
375
+ }
376
+ /** `ops` for the pane, merged into this page's batch for it when one is still waiting for its turn. */
377
+ queue(page, machine, paneId, ops, id) {
378
+ const key = `${machine.id}/${paneId}`;
379
+ let batch = this.openBatches.get(key);
380
+ if (!batch || batch.page !== page) {
381
+ batch = { page, ops: [], id };
382
+ this.openBatches.set(key, batch);
383
+ const b = batch;
384
+ this.enqueue(key, async () => {
385
+ if (this.openBatches.get(key) === b) this.openBatches.delete(key);
386
+ try {
387
+ for (const op of b.ops) {
388
+ if ("text" in op) await machine.sendText(paneId, op.text);
389
+ else await machine.sendKeys(paneId, op.keys);
390
+ }
391
+ b.page.send({ type: "input_ack", machine: machine.id, pane_id: paneId, id: b.id });
392
+ } catch (err) {
393
+ b.page.send({ type: "error", machine: machine.id, pane_id: paneId, id: b.id, message: err.message });
394
+ }
395
+ });
396
+ }
397
+ batch.id = id;
398
+ for (const op of ops) {
399
+ const last = batch.ops[batch.ops.length - 1];
400
+ if ("text" in op) {
401
+ if (last && "text" in last && last.text.length + op.text.length <= MAX_INPUT_TEXT) last.text += op.text;
402
+ else batch.ops.push({ text: op.text });
403
+ } else if (last && "keys" in last) last.keys.push(...op.keys);
404
+ else batch.ops.push({ keys: [...op.keys] });
405
+ }
406
+ }
407
+ }
408
+ class Bridge {
409
+ config;
410
+ deps;
411
+ /** id → Runtime, in floor order. */
412
+ machines;
413
+ /** id → what the bridge adds to the floor */
414
+ enriched;
415
+ /** Every page's input, queued per pane. */
416
+ input;
417
+ /** The HTTP server, the page and the WebSocket. */
418
+ transport;
419
+ closing;
420
+ constructor(config, deps) {
421
+ this.config = config;
422
+ this.deps = deps;
423
+ this.machines = /* @__PURE__ */ new Map();
424
+ this.enriched = /* @__PURE__ */ new Map();
425
+ this.input = new InputQueue();
426
+ this.transport = new WsServer(config, (page) => this.connected(page));
427
+ this.closing = null;
428
+ this.addFloors();
429
+ }
430
+ // -------------------------------------------------------------- machines
431
+ /** The saved floors, as saveConfig writes them; each is checked before it is used. */
432
+ loadConfig() {
433
+ const file = this.config.machinesFile;
434
+ try {
435
+ const cfg = JSON.parse(fs$1.readFileSync(file, "utf8"));
436
+ return Array.isArray(cfg.machines) ? cfg.machines : [];
437
+ } catch (err) {
438
+ if (err.code !== "ENOENT") console.error(`[bridge] ignoring ${file}: ${err.message}`);
439
+ return [];
440
+ }
441
+ }
442
+ saveConfig() {
443
+ const list2 = [...this.machines.values()].filter((m) => m.id !== "local").map((m) => m.config);
444
+ fs$1.mkdirSync(path.dirname(this.config.machinesFile), { recursive: true });
445
+ fs$1.writeFileSync(this.config.machinesFile, `${JSON.stringify({ machines: list2 }, null, 2)}
446
+ `);
447
+ }
448
+ /** This machine's floor (its runtime's default socket), then the saved ones. */
449
+ addFloors() {
450
+ this.addMachine({ id: "local", label: "local" });
451
+ for (const c of this.loadConfig()) {
452
+ if (typeof c?.label !== "string" || (c.ssh ? !SSH_TARGET.test(c.ssh) : typeof c.socket !== "string")) {
453
+ console.error(`[bridge] skipping machine in ${this.config.machinesFile}: ${JSON.stringify(c)}`);
454
+ continue;
455
+ }
456
+ this.addMachine({ ...c, id: typeof c.id === "string" && !this.machines.has(c.id) ? c.id : this.uniqueId(c.label) });
457
+ }
458
+ }
459
+ /** A floor's runtime and what the bridge adds to it, not started. */
460
+ addMachine(cfg) {
461
+ const m = this.deps.runtime(cfg);
462
+ this.machines.set(m.id, m);
463
+ this.enriched.set(m.id, this.deps.enrichers(m));
464
+ return m;
465
+ }
466
+ /**
467
+ * The enrichers start first, so they hear of each snapshot before the
468
+ * pages are sent it; then the runtime, which connects.
469
+ */
470
+ startMachine(m) {
471
+ const { enrichers, printers } = this.enriched.get(m.id);
472
+ for (const e of enrichers) {
473
+ e.start();
474
+ e.on("change", () => {
475
+ if (m.snapshot) this.broadcast(this.snapshotMessage(m, m.snapshot));
476
+ });
477
+ }
478
+ printers.on("print", (sheet) => this.broadcast({ type: "print", machine: m.id, sheet }));
479
+ m.on("status", () => this.broadcastMachines());
480
+ m.on("snapshot", (snapshot) => this.broadcast(this.snapshotMessage(m, snapshot)));
481
+ m.start();
482
+ }
483
+ removeMachine(m) {
484
+ m.stop();
485
+ for (const e of this.enriched.get(m.id)?.enrichers ?? []) e.stop();
486
+ this.enriched.delete(m.id);
487
+ this.machines.delete(m.id);
488
+ }
489
+ uniqueId(base) {
490
+ const slug = base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "machine";
491
+ let id = slug, n = 2;
492
+ while (this.machines.has(id)) id = `${slug}-${n++}`;
493
+ return id;
494
+ }
495
+ machineInfos() {
496
+ return [...this.machines.values()].map((m) => m.info);
497
+ }
498
+ /** The floor's snapshot, decorated by each of its enrichers in turn. */
499
+ snapshotMessage(m, snapshot) {
500
+ for (const e of this.enriched.get(m.id)?.enrichers ?? []) snapshot = e.decorate(snapshot);
501
+ return { type: "snapshot", machine: m.id, snapshot };
502
+ }
503
+ // -------------------------------------------------------------- pages
504
+ broadcast(msg) {
505
+ this.transport.broadcast(msg);
506
+ }
507
+ broadcastMachines() {
508
+ this.broadcast({ type: "machines", machines: this.machineInfos() });
509
+ }
510
+ /** A page connected: it is sent the floors, their snapshots and their sheets; then each message from it goes to its handler (handlers.ts). */
511
+ connected(page) {
512
+ page.send({ type: "machines", machines: this.machineInfos() });
513
+ for (const m of this.machines.values()) {
514
+ if (m.snapshot) page.send(this.snapshotMessage(m, m.snapshot));
515
+ page.send({ type: "prints", machine: m.id, sheets: this.enriched.get(m.id)?.printers.history() ?? [] });
516
+ }
517
+ return async (data) => {
518
+ let msg;
519
+ try {
520
+ msg = parseClientMessage(JSON.parse(data));
521
+ } catch {
522
+ return;
523
+ }
524
+ if (msg) await handle(msg, page, this);
525
+ };
526
+ }
527
+ // -------------------------------------------------------------- listen and close
528
+ /** Starts the floors and opens the port; resolves with the office's URL, null when there is no built page. */
529
+ listen() {
530
+ for (const m of this.machines.values()) this.startMachine(m);
531
+ return this.transport.listen().then(({ address, page }) => {
532
+ const floors = `${this.machines.size} floor${this.machines.size === 1 ? "" : "s"} (${this.config.machinesFile})`;
533
+ console.log(`[bridge] websocket listening on ${address} · ${floors}`);
534
+ return page;
535
+ });
536
+ }
537
+ // The floors stop before the first await, so a process "exit" handler that
538
+ // cannot wait still takes the SSH tunnels and remote helpers down with it.
539
+ close() {
540
+ if (this.closing) return this.closing;
541
+ for (const m of this.machines.values()) m.stop();
542
+ for (const { enrichers } of this.enriched.values()) for (const e of enrichers) e.stop();
543
+ this.closing = this.transport.close();
544
+ return this.closing;
545
+ }
546
+ }
547
+ const CLAUDE_BUILTINS = [
548
+ ["add-dir", "Add a new working directory", "<path>"],
549
+ ["advisor", "Let Claude consult a stronger model at key moments"],
550
+ ["autocompact", "Set how full the context gets before auto-summarizing", "[auto|<tokens>]"],
551
+ ["background", "Send this session to the background and free the terminal", "[prompt]", ["bg"]],
552
+ ["batch", "Plan a large change; background agents each open a PR"],
553
+ ["branch", "Create a branch of the current conversation at this point", "[name]"],
554
+ ["btw", "Ask a quick side question without interrupting the main conversation", "[question]"],
555
+ ["cd", "Move this session to a new working directory", "<path>"],
556
+ ["clear", "Start a new session with empty context; the previous one stays resumable", "[name]", ["reset", "new"]],
557
+ ["code-review", "Review the current diff for correctness bugs", "[level]"],
558
+ ["color", "Set the prompt bar color for this session"],
559
+ ["compact", "Free up context by summarizing the conversation so far", "[instructions]"],
560
+ ["config", "Open settings", "[key=value]", ["settings"]],
561
+ ["context", "Show current context usage"],
562
+ ["copy", "Copy Claude's last response to clipboard (or /copy N for the Nth-latest)"],
563
+ ["daemon", "Manage background services and routines"],
564
+ ["debug", "Turn on debug logging and investigate problems", "[issue description]"],
565
+ ["diff", "View uncommitted changes and per-turn diffs"],
566
+ ["doctor", "Check the health of your Claude Code installation", "", ["checkup"]],
567
+ ["effort", "Set effort level for model usage"],
568
+ ["exit", "Exit Claude Code", "", ["quit"]],
569
+ ["export", "Export the current conversation to a file or clipboard", "[filename]"],
570
+ ["fast", "Toggle fast mode"],
571
+ ["feedback", "Send feedback to Anthropic or report a bug", "[report]", ["bug"]],
572
+ ["focus", "Toggle focus view: just your prompt, summary, and response"],
573
+ ["fork", "Copy this conversation into a new background session and keep working here", "[prompt]"],
574
+ ["goal", "Set a goal Claude checks before stopping", "[<condition> | clear]"],
575
+ ["help", "Show help and available commands"],
576
+ ["hooks", "View hook configurations for tool events"],
577
+ ["ide", "Manage IDE integrations and show status", "[open]"],
578
+ ["init", "Initialize a new CLAUDE.md file with codebase documentation"],
579
+ ["insights", "Generate a report analyzing your Claude Code sessions"],
580
+ ["install-github-app", "Set up Claude GitHub Actions for a repository"],
581
+ ["keybindings", "Open your keyboard shortcuts file"],
582
+ ["login", "Sign in with your Anthropic account"],
583
+ ["logout", "Sign out from your Anthropic account"],
584
+ ["loop", "Run a prompt or slash command on a recurring interval", "[interval] <prompt>"],
585
+ ["mcp", "Manage MCP servers", "[reconnect|enable|disable [<server>|all]]"],
586
+ ["memory", "Edit CLAUDE.md files and memory settings"],
587
+ ["model", "Set the AI model for Claude Code", "[model]"],
588
+ ["output-style", "List output styles or switch to one", "[style]"],
589
+ ["permissions", "Manage allow and deny tool permission rules", "", ["allowed-tools"]],
590
+ ["plan", "Enable plan mode or view the current session plan", "[open|<description>]"],
591
+ ["plugin", "Manage Claude Code plugins", "", ["plugins", "marketplace"]],
592
+ ["privacy-settings", "View and update your privacy settings"],
593
+ ["recap", "Generate a one-line session recap now"],
594
+ ["release-notes", "View release notes"],
595
+ ["reload-plugins", "Activate pending plugin changes in the current session"],
596
+ ["reload-skills", "Pick up skills added or changed on disk during this session"],
597
+ ["remote-control", "Control this session from your phone or claude.ai/code", "", ["rc"]],
598
+ ["rename", "Rename the current conversation", "[name]", ["name"]],
599
+ ["resume", "Resume a previous conversation", "[conversation id or search term]", ["continue"]],
600
+ ["review", "Review a pull request", "[PR]"],
601
+ ["rewind", "Restore the code and/or conversation to a previous point", "", ["checkpoint", "undo"]],
602
+ ["run", "Launch this project's app to see your change working"],
603
+ ["sandbox", "Configure sandboxed Bash"],
604
+ ["schedule", "Create and manage scheduled remote Claude Code agents", "", ["routines"]],
605
+ ["security-review", "Complete a security review of the pending changes on the current branch"],
606
+ ["simplify", "Review the changed code for reuse, simplification and efficiency"],
607
+ ["skills", "List available skills"],
608
+ ["status", "Show version, model, account, API connectivity, and tool statuses"],
609
+ ["statusline", "Set up Claude Code's status line"],
610
+ ["subtask", "Send a subagent off with your full context; its result comes back here", "<task>"],
611
+ ["tasks", "View and manage everything running in the background", "", ["bashes"]],
612
+ ["terminal-setup", "Install the Shift+Enter key binding for newlines"],
613
+ ["theme", "Change the theme"],
614
+ ["todos", "List current todo items"],
615
+ ["tui", "Set the terminal UI renderer", "[default|fullscreen]"],
616
+ ["usage", "Show session cost, plan usage, and activity stats", "", ["cost", "stats"]],
617
+ ["workflows", "Browse running and completed workflows"]
618
+ ];
619
+ const CODEX_BUILTINS = [
620
+ ["apps", "manage apps"],
621
+ ["cd", "change the current working directory"],
622
+ ["clear", "clear the terminal and start a new chat"],
623
+ ["compact", "summarize conversation to prevent hitting the context limit"],
624
+ ["copy", "copy the last response or part of it"],
625
+ ["diff", "show git diff (including untracked files)"],
626
+ ["exit", "exit Codex", "", ["quit"]],
627
+ ["export", "export the conversation as markdown"],
628
+ ["feedback", "send logs to maintainers"],
629
+ ["fork", "fork the current chat"],
630
+ ["goal", "set or view the goal for a long-running task"],
631
+ ["hooks", "view and manage lifecycle hooks"],
632
+ ["init", "create an AGENTS.md file with instructions for Codex"],
633
+ ["logout", "log out of Codex"],
634
+ ["mcp", "list configured MCP tools; use /mcp verbose for details"],
635
+ ["mention", "mention a file"],
636
+ ["model", "choose what model and reasoning effort to use"],
637
+ ["new", "start a new chat during a conversation"],
638
+ ["permissions", "choose what Codex is allowed to do", "", ["approvals"]],
639
+ ["plan", "switch to Plan mode"],
640
+ ["plugins", "browse plugins"],
641
+ ["ps", "list background terminals"],
642
+ ["rename", "rename the current thread"],
643
+ ["resume", "resume a saved chat"],
644
+ ["review", "review my current changes and find issues"],
645
+ ["side", "start a side conversation in an ephemeral fork"],
646
+ ["skills", "use skills to improve how Codex performs specific tasks"],
647
+ ["status", "show current session configuration and token usage"],
648
+ ["statusline", "configure which items appear in the status line"],
649
+ ["stop", "stop all background terminals"],
650
+ ["theme", "choose a syntax highlighting theme"],
651
+ ["usage", "view account usage or use a usage limit reset"],
652
+ ["vim", "toggle Vim mode for the composer"],
653
+ ["worktree", "start or continue a conversation in a new worktree"]
654
+ ];
655
+ const builtins = (rows) => rows.map(([name, description, hint, aliases]) => ({
656
+ name,
657
+ description,
658
+ ...hint ? { hint } : {},
659
+ ...aliases ? { aliases } : {},
660
+ source: "built-in"
661
+ }));
662
+ const HEAD_BYTES = 4096;
663
+ const MAX_DESCRIPTION = 200;
664
+ async function slashCommands(agent, cwd, local, dirs) {
665
+ if (agent === "claude") return dedupe([...builtins(CLAUDE_BUILTINS), ...local ? await claudeFiles(dirs.claudeDir, cwd) : []]);
666
+ if (agent === "codex") return dedupe([...builtins(CODEX_BUILTINS), ...local ? await codexPrompts(dirs.codexDir) : []]);
667
+ return [];
668
+ }
669
+ async function claudeFiles(home, cwd) {
670
+ const projects = projectDirs(cwd);
671
+ const out = [];
672
+ for (const dir of projects) {
673
+ out.push(...await commandDir(path.join(dir, ".claude", "commands"), "project"));
674
+ out.push(...await skillDir(path.join(dir, ".claude", "skills"), "project"));
675
+ }
676
+ out.push(...await commandDir(path.join(home, "commands"), "user"));
677
+ out.push(...await skillDir(path.join(home, "skills"), "user"));
678
+ out.push(...await pluginCommands(home, cwd, projects));
679
+ return out;
680
+ }
681
+ function projectDirs(cwd) {
682
+ const dirs = [];
683
+ if (!cwd || !path.isAbsolute(cwd)) return dirs;
684
+ const home = os.homedir();
685
+ for (let dir = path.resolve(cwd); dir !== home; dir = path.dirname(dir)) {
686
+ dirs.push(dir);
687
+ if (dir === path.dirname(dir) || existsSync(path.join(dir, ".git"))) break;
688
+ }
689
+ return dirs;
690
+ }
691
+ async function commandDir(dir, source, prefix = "") {
692
+ const out = [];
693
+ for (const file of await walk(dir, 3)) {
694
+ if (!file.endsWith(".md")) continue;
695
+ const head = await readHead(file);
696
+ if (head === null) continue;
697
+ const { meta, body } = frontmatter(head);
698
+ out.push(command(prefix + path.basename(file, ".md"), meta.description || firstLine(body), meta["argument-hint"], source));
699
+ }
700
+ return out;
701
+ }
702
+ async function skillDir(dir, source, prefix = "") {
703
+ const out = [];
704
+ for (const entry of await list$1(dir)) {
705
+ const head = await readHead(path.join(dir, entry, "SKILL.md"));
706
+ if (head === null) continue;
707
+ const { meta, body } = frontmatter(head);
708
+ if (meta["user-invocable"] === "false") continue;
709
+ out.push(command(prefix + (meta.name || entry), meta.description || firstLine(body), meta["argument-hint"], source));
710
+ }
711
+ return out;
712
+ }
713
+ async function pluginCommands(home, cwd, projects) {
714
+ const installed = await readJson(path.join(home, "plugins", "installed_plugins.json"));
715
+ if (!installed?.plugins || typeof installed.plugins !== "object") return [];
716
+ const enabled = { ...(await readJson(path.join(home, "settings.json")))?.enabledPlugins };
717
+ for (const dir of [...projects].reverse()) {
718
+ for (const f of ["settings.json", "settings.local.json"])
719
+ Object.assign(enabled, (await readJson(path.join(dir, ".claude", f)))?.enabledPlugins);
720
+ }
721
+ const here = cwd ? path.resolve(cwd) : "";
722
+ const out = [];
723
+ for (const [key, entries] of Object.entries(installed.plugins)) {
724
+ if (enabled[key] !== true || !Array.isArray(entries)) continue;
725
+ const entry = entries.find((e) => e?.scope === "user" || typeof e?.projectPath === "string" && inside(here, e.projectPath));
726
+ if (typeof entry?.installPath !== "string") continue;
727
+ const manifest = await readJson(path.join(entry.installPath, ".claude-plugin", "plugin.json"));
728
+ const name = typeof manifest?.name === "string" ? manifest.name : key.split("@")[0];
729
+ out.push(...await commandDir(path.join(entry.installPath, "commands"), name, `${name}:`));
730
+ out.push(...await skillDir(path.join(entry.installPath, "skills"), name, `${name}:`));
731
+ }
732
+ return out;
733
+ }
734
+ async function codexPrompts(home) {
735
+ const out = [];
736
+ for (const entry of await list$1(path.join(home, "prompts"))) {
737
+ if (!entry.endsWith(".md")) continue;
738
+ const head = await readHead(path.join(home, "prompts", entry));
739
+ if (head === null) continue;
740
+ const { meta, body } = frontmatter(head);
741
+ out.push(command(`prompts:${entry.slice(0, -3)}`, meta.description || firstLine(body), meta["argument-hint"], "user"));
742
+ }
743
+ return out;
744
+ }
745
+ function command(name, description, hint, source) {
746
+ return { name, description: clip(description ?? ""), ...hint ? { hint: clip(hint) } : {}, source };
747
+ }
748
+ function dedupe(cmds) {
749
+ const seen = /* @__PURE__ */ new Set();
750
+ return cmds.filter((c) => /^[\w.:-]+$/.test(c.name) && !seen.has(c.name) && seen.add(c.name));
751
+ }
752
+ function frontmatter(text) {
753
+ const meta = {};
754
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
755
+ if (!m) return { meta, body: text };
756
+ const lines2 = m[1].split(/\r?\n/);
757
+ for (let i = 0; i < lines2.length; i++) {
758
+ const kv = /^([\w-]+):\s*(.*)$/.exec(lines2[i]);
759
+ if (!kv) continue;
760
+ let value = kv[2].trim();
761
+ if (/^[|>][+-]?$/.test(value)) {
762
+ const block = [];
763
+ while (i + 1 < lines2.length && (/^\s/.test(lines2[i + 1]) || lines2[i + 1] === "")) block.push(lines2[++i].trim());
764
+ value = block.filter(Boolean).join(" ");
765
+ } else if (/^(["']).*\1$/.test(value)) {
766
+ value = value.slice(1, -1);
767
+ }
768
+ meta[kv[1]] = value;
769
+ }
770
+ return { meta, body: text.slice(m[0].length) };
771
+ }
772
+ function firstLine(body) {
773
+ return body.split(/\r?\n/).map((l) => l.replace(/^#+\s*/, "").trim()).find(Boolean) ?? "";
774
+ }
775
+ function clip(s) {
776
+ s = s.replace(/\s+/g, " ").trim();
777
+ return s.length > MAX_DESCRIPTION ? `${s.slice(0, MAX_DESCRIPTION - 1)}…` : s;
778
+ }
779
+ function inside(dir, root) {
780
+ const rel = path.relative(path.resolve(root), dir);
781
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
782
+ }
783
+ async function list$1(dir) {
784
+ try {
785
+ return (await fs.readdir(dir)).filter((e) => !e.startsWith(".")).sort();
786
+ } catch {
787
+ return [];
788
+ }
789
+ }
790
+ async function walk(dir, depth) {
791
+ let entries;
792
+ try {
793
+ entries = await fs.readdir(dir, { withFileTypes: true });
794
+ } catch {
795
+ return [];
796
+ }
797
+ const out = [];
798
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
799
+ if (e.name.startsWith(".")) continue;
800
+ const p = path.join(dir, e.name);
801
+ if (e.isDirectory() && depth > 0) out.push(...await walk(p, depth - 1));
802
+ else if (e.isFile() || e.isSymbolicLink()) out.push(p);
803
+ }
804
+ return out;
805
+ }
806
+ async function readHead(file) {
807
+ let fh;
808
+ try {
809
+ fh = await fs.open(file, "r");
810
+ const buf = Buffer.alloc(HEAD_BYTES);
811
+ const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);
812
+ return buf.subarray(0, bytesRead).toString("utf8");
813
+ } catch {
814
+ return null;
815
+ } finally {
816
+ await fh?.close();
817
+ }
818
+ }
819
+ async function readJson(file) {
820
+ try {
821
+ return JSON.parse(await fs.readFile(file, "utf8"));
822
+ } catch {
823
+ return null;
824
+ }
825
+ }
826
+ const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3"];
827
+ const TIMEOUT_MS = 2e4;
828
+ const RETRY_MS$1 = 3e4;
829
+ const NO_PYTHON_RETRY_MS = 5 * 6e4;
830
+ class RemoteScript {
831
+ m;
832
+ sshCommand;
833
+ what;
834
+ command;
835
+ child;
836
+ buf;
837
+ stderr;
838
+ seq;
839
+ /** request id → resolve */
840
+ waiting;
841
+ retryAt;
842
+ stopped;
843
+ /**
844
+ * `sshCommand`: the ssh executable (config.ts); `file`: the script, which
845
+ * its caller keeps beside itself; `what`: what stops working when it dies,
846
+ * for the log.
847
+ */
848
+ constructor(machine, sshCommand, file, what) {
849
+ this.m = machine;
850
+ this.sshCommand = sshCommand;
851
+ this.what = what;
852
+ const script = fs$1.readFileSync(file).toString("base64");
853
+ this.command = `python3 -u -c "import base64; exec(base64.b64decode('${script}'))"`;
854
+ this.child = null;
855
+ this.buf = "";
856
+ this.stderr = "";
857
+ this.seq = 0;
858
+ this.waiting = /* @__PURE__ */ new Map();
859
+ this.retryAt = 0;
860
+ this.stopped = false;
861
+ }
862
+ stop() {
863
+ this.stopped = true;
864
+ this.child?.kill();
865
+ }
866
+ /** The script's answer, or null when it is not running or did not answer within `timeout` ms. */
867
+ call(request, timeout = TIMEOUT_MS) {
868
+ if (this.stopped) return Promise.resolve(null);
869
+ if (!this.child) {
870
+ if (Date.now() < this.retryAt) return Promise.resolve(null);
871
+ this.start();
872
+ }
873
+ const id = ++this.seq;
874
+ return new Promise((resolve) => {
875
+ const timer = setTimeout(() => {
876
+ this.waiting.delete(id);
877
+ this.child?.kill();
878
+ resolve(null);
879
+ }, timeout);
880
+ this.waiting.set(id, (result) => {
881
+ clearTimeout(timer);
882
+ resolve(result);
883
+ });
884
+ this.child.stdin.write(`${JSON.stringify({ ...request, id })}
885
+ `);
886
+ });
887
+ }
888
+ start() {
889
+ const child = spawn(this.sshCommand, [...SSH_OPTS, "-o", "ControlPath=none", "--", this.m.ssh, this.command], {
890
+ stdio: ["pipe", "pipe", "pipe"]
891
+ });
892
+ this.child = child;
893
+ this.buf = "";
894
+ this.stderr = "";
895
+ child.stdout.setEncoding("utf8");
896
+ child.stderr.setEncoding("utf8");
897
+ child.stdin.on("error", () => {
898
+ });
899
+ child.stdout.on("data", (chunk) => {
900
+ this.buf += chunk;
901
+ let nl;
902
+ while ((nl = this.buf.indexOf("\n")) !== -1) {
903
+ const line = this.buf.slice(0, nl);
904
+ this.buf = this.buf.slice(nl + 1);
905
+ let msg;
906
+ try {
907
+ msg = JSON.parse(line);
908
+ } catch {
909
+ continue;
910
+ }
911
+ const done = this.waiting.get(msg.id);
912
+ this.waiting.delete(msg.id);
913
+ done?.(msg.result ?? null);
914
+ }
915
+ });
916
+ child.stderr.on("data", (d) => {
917
+ this.stderr = (this.stderr + d).slice(-2e3);
918
+ });
919
+ const gone = (code, signal) => {
920
+ if (this.child !== child) return;
921
+ this.child = null;
922
+ for (const done of this.waiting.values()) done(null);
923
+ this.waiting.clear();
924
+ if (this.stopped) return;
925
+ const noPython = code === 127;
926
+ this.retryAt = Date.now() + (noPython ? NO_PYTHON_RETRY_MS : RETRY_MS$1);
927
+ const why = noPython ? "python3 is not installed there" : lastLine$1(this.stderr) || (signal ? `killed by ${signal}` : `exit ${code}`);
928
+ console.warn(`[bridge] ${this.m.label}: ${this.what} stopped (${why}); retrying in ${(this.retryAt - Date.now()) / 1e3}s`);
929
+ };
930
+ child.once("exit", gone);
931
+ child.once("error", (err) => {
932
+ this.stderr += err.message;
933
+ gone(null, null);
934
+ });
935
+ }
936
+ }
937
+ function lastLine$1(s) {
938
+ return (s.trim().split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? "").replace(/^ssh: /, "");
939
+ }
940
+ const KINDS = /* @__PURE__ */ new Set(["claude", "codex"]);
941
+ const POLL_MS$1 = 2e3;
942
+ const KICK_MS = 300;
943
+ const MISS_MS = 15e3;
944
+ const FIRST_READ_MAX = 32 * 1024 * 1024;
945
+ const CLAUDE_WINDOW = 1e6;
946
+ const CLAUDE_WINDOW_SMALL = 2e5;
947
+ const CLAUDE_SMALL_MODEL = /^claude-(?:3|haiku)|^claude-(?:opus|sonnet)-4(?:-[015])?(?:-\d{8})?$/;
948
+ const SESSION_ID = /^[A-Za-z0-9-]{1,64}$/;
949
+ class ContextTracker extends EventEmitter {
950
+ m;
951
+ reader;
952
+ /** pane_id → { used, max } */
953
+ usage;
954
+ /** pane_id → pid of its agent's process (Claude Code's, or Codex's) */
955
+ pids;
956
+ /** pane_id → when to ask Herdr for its processes again */
957
+ misses;
958
+ running;
959
+ again;
960
+ kickTimer;
961
+ timer;
962
+ /** Makes the tracker for `machine`'s floor; `start()` begins reading. */
963
+ constructor(machine, settings) {
964
+ super();
965
+ this.m = machine;
966
+ this.reader = machine.ssh ? new RemoteReader(machine, settings.sshCommand) : new LocalReader(settings);
967
+ this.usage = /* @__PURE__ */ new Map();
968
+ this.pids = /* @__PURE__ */ new Map();
969
+ this.misses = /* @__PURE__ */ new Map();
970
+ this.running = false;
971
+ this.again = false;
972
+ this.kickTimer = null;
973
+ this.timer = null;
974
+ }
975
+ start() {
976
+ this.timer = setInterval(() => this.refresh(), POLL_MS$1);
977
+ this.timer.unref();
978
+ this.m.on("snapshot", () => this.kick());
979
+ }
980
+ stop() {
981
+ clearInterval(this.timer);
982
+ clearTimeout(this.kickTimer);
983
+ this.reader.stop();
984
+ }
985
+ /** The Kauak snapshot with `context` on every pane whose use is known. */
986
+ decorate(snapshot) {
987
+ if (this.usage.size === 0) return snapshot;
988
+ return {
989
+ ...snapshot,
990
+ panes: snapshot.panes.map((p) => this.usage.has(p.pane_id) ? { ...p, context: this.usage.get(p.pane_id) } : p)
991
+ };
992
+ }
993
+ kick() {
994
+ if (this.kickTimer) return;
995
+ this.kickTimer = setTimeout(() => {
996
+ this.kickTimer = null;
997
+ this.refresh();
998
+ }, KICK_MS);
999
+ }
1000
+ async refresh() {
1001
+ if (this.running) {
1002
+ this.again = true;
1003
+ return;
1004
+ }
1005
+ this.running = true;
1006
+ try {
1007
+ do {
1008
+ this.again = false;
1009
+ await this.update();
1010
+ } while (this.again);
1011
+ } catch (err) {
1012
+ console.error(`[bridge] ${this.m.label}: context meters:`, err.message);
1013
+ } finally {
1014
+ this.running = false;
1015
+ }
1016
+ }
1017
+ async update() {
1018
+ const snap = this.m.snapshot;
1019
+ if (!snap || this.m.state !== "live") return;
1020
+ const panes = [];
1021
+ const asked = /* @__PURE__ */ new Set();
1022
+ const sessionOf = (pane) => this.m.paneSession(pane.pane_id);
1023
+ const folderOf = (pane) => pane.cwd;
1024
+ const codexFolders = /* @__PURE__ */ new Map();
1025
+ for (const pane of snap.panes) {
1026
+ if (pane.agent === "codex" && !sessionOf(pane)) codexFolders.set(folderOf(pane), (codexFolders.get(folderOf(pane)) ?? 0) + 1);
1027
+ }
1028
+ for (const pane of snap.panes) {
1029
+ if (!KINDS.has(pane.agent)) continue;
1030
+ const session = sessionOf(pane);
1031
+ let pids = [];
1032
+ if (!session && (pane.agent === "claude" || codexFolders.get(folderOf(pane)) === 1)) {
1033
+ const known = this.pids.get(pane.pane_id);
1034
+ if (known) pids = [known];
1035
+ else if ((this.misses.get(pane.pane_id) ?? 0) <= Date.now()) {
1036
+ asked.add(pane.pane_id);
1037
+ pids = await this.processesOf(pane.pane_id);
1038
+ }
1039
+ }
1040
+ panes.push({ pane_id: pane.pane_id, agent: pane.agent, session, pids, cwd: folderOf(pane) });
1041
+ }
1042
+ const results = panes.length ? await this.reader.read(panes) : /* @__PURE__ */ new Map();
1043
+ if (!results) return;
1044
+ const next = /* @__PURE__ */ new Map();
1045
+ for (const p of panes) {
1046
+ const r = results.get(p.pane_id);
1047
+ if (r?.usage) next.set(p.pane_id, r.usage);
1048
+ if (r?.pid) {
1049
+ this.pids.set(p.pane_id, r.pid);
1050
+ this.misses.delete(p.pane_id);
1051
+ } else if (p.pids.length || asked.has(p.pane_id)) {
1052
+ this.pids.delete(p.pane_id);
1053
+ if (asked.has(p.pane_id)) this.misses.set(p.pane_id, Date.now() + MISS_MS);
1054
+ }
1055
+ }
1056
+ const live = new Set(snap.panes.map((p) => p.pane_id));
1057
+ for (const id of [...this.pids.keys()]) if (!live.has(id)) this.pids.delete(id);
1058
+ if (sameUsage(next, this.usage)) return;
1059
+ this.usage = next;
1060
+ this.emit("change");
1061
+ }
1062
+ processesOf(paneId) {
1063
+ return this.m.paneProcesses(paneId).catch(() => []);
1064
+ }
1065
+ }
1066
+ function sameUsage(a, b) {
1067
+ if (a.size !== b.size) return false;
1068
+ for (const [id, u] of a) {
1069
+ const v = b.get(id);
1070
+ if (!v || v.used !== u.used || v.max !== u.max) return false;
1071
+ }
1072
+ return true;
1073
+ }
1074
+ class LocalReader {
1075
+ dirs;
1076
+ /** transcript path → Transcript */
1077
+ transcripts;
1078
+ /** kind:session id → transcript path */
1079
+ found;
1080
+ /** kind:session id → when to look again */
1081
+ misses;
1082
+ /** rollout path → its session_meta: { cwd, originator, source } */
1083
+ rollouts;
1084
+ constructor(dirs) {
1085
+ this.dirs = dirs;
1086
+ this.transcripts = /* @__PURE__ */ new Map();
1087
+ this.found = /* @__PURE__ */ new Map();
1088
+ this.misses = /* @__PURE__ */ new Map();
1089
+ this.rollouts = /* @__PURE__ */ new Map();
1090
+ }
1091
+ stop() {
1092
+ }
1093
+ async read(panes) {
1094
+ const results = /* @__PURE__ */ new Map();
1095
+ const read = /* @__PURE__ */ new Set();
1096
+ for (const pane of panes) {
1097
+ const { file, pid } = await this.transcriptOf(pane).catch(() => ({ file: null, pid: null }));
1098
+ let usage = null;
1099
+ if (file) {
1100
+ read.add(file);
1101
+ let t = this.transcripts.get(file);
1102
+ if (!t) this.transcripts.set(file, t = new Transcript(file, pane.agent));
1103
+ usage = await t.usage().catch(() => null);
1104
+ }
1105
+ results.set(pane.pane_id, { usage, pid });
1106
+ }
1107
+ for (const file of [...this.transcripts.keys()]) if (!read.has(file)) this.transcripts.delete(file);
1108
+ return results;
1109
+ }
1110
+ async transcriptOf({ agent, session, pids, cwd }) {
1111
+ if (session?.kind === "path") return { file: session.value.endsWith(".jsonl") ? session.value : null, pid: null };
1112
+ if (session?.kind === "id") return { file: await this.find(agent, session.value, cwd), pid: null };
1113
+ if (agent === "codex") {
1114
+ const started = await processStart(pids);
1115
+ return started ? { file: await this.codexByFolder(cwd, started.at), pid: started.pid } : { file: null, pid: null };
1116
+ }
1117
+ for (const pid of pids) {
1118
+ const meta = await readSessionFile(this.dirs.claudeDir, pid);
1119
+ if (meta) return { file: await this.find("claude", meta.sessionId, meta.cwd), pid };
1120
+ }
1121
+ return { file: null, pid: null };
1122
+ }
1123
+ /** The newest terminal Codex rollout for `cwd` written since `since` (when Codex started), from the day it started on. */
1124
+ async codexByFolder(cwd, since) {
1125
+ let best = null, bestTime = 0;
1126
+ for (const dir of codexDayDirs(this.dirs.codexDir, since)) {
1127
+ for (const name of await fs$1.promises.readdir(dir).catch(() => [])) {
1128
+ if (!name.startsWith("rollout-") || !name.endsWith(".jsonl")) continue;
1129
+ const file = path.join(dir, name);
1130
+ const st = await fs$1.promises.stat(file).catch(() => null);
1131
+ if (!st || st.mtimeMs < since || st.mtimeMs <= bestTime) continue;
1132
+ let meta = this.rollouts.get(file);
1133
+ if (!meta) {
1134
+ meta = await rolloutMeta(file).catch(() => null);
1135
+ if (meta) this.rollouts.set(file, meta);
1136
+ }
1137
+ if (meta?.cwd === cwd && meta.originator === "codex-tui" && typeof meta.source === "string") {
1138
+ best = file;
1139
+ bestTime = st.mtimeMs;
1140
+ }
1141
+ }
1142
+ }
1143
+ return best;
1144
+ }
1145
+ /** A session id's transcript. Remembered once found; a miss is retried after a while (the file shows up with the first message). */
1146
+ async find(kind, id, cwd = null) {
1147
+ if (typeof id !== "string" || !SESSION_ID.test(id)) return null;
1148
+ const key = `${kind}:${id}`;
1149
+ const known = this.found.get(key);
1150
+ if (known && fs$1.existsSync(known)) return known;
1151
+ if ((this.misses.get(key) ?? 0) > Date.now()) return null;
1152
+ const file = kind === "claude" ? await findClaudeTranscript(this.dirs.claudeDir, id, cwd) : await findCodexRollout(this.dirs.codexDir, id);
1153
+ if (file) {
1154
+ this.found.set(key, file);
1155
+ this.misses.delete(key);
1156
+ } else this.misses.set(key, Date.now() + MISS_MS);
1157
+ return file;
1158
+ }
1159
+ }
1160
+ class RemoteReader {
1161
+ script;
1162
+ constructor(machine, sshCommand) {
1163
+ this.script = new RemoteScript(machine, sshCommand, new URL("./context_remote.py", import.meta.url), "context meters");
1164
+ }
1165
+ stop() {
1166
+ this.script.stop();
1167
+ }
1168
+ async read(panes) {
1169
+ const result = await this.script.call({ panes });
1170
+ return result ? new Map(Object.entries(result)) : null;
1171
+ }
1172
+ }
1173
+ function processStart(pids) {
1174
+ if (!pids.length) return Promise.resolve(null);
1175
+ return new Promise((resolve) => {
1176
+ execFile("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], { env: { ...process.env, LC_ALL: "C" } }, (_err, out) => {
1177
+ let best = null;
1178
+ for (const line of String(out ?? "").split("\n")) {
1179
+ const m = line.trim().match(/^(\d+)\s+(.+)$/);
1180
+ const at = m ? Date.parse(m[2]) : NaN;
1181
+ if (!Number.isNaN(at) && (!best || at < best.at)) best = { pid: Number(m[1]), at };
1182
+ }
1183
+ resolve(best);
1184
+ });
1185
+ });
1186
+ }
1187
+ function codexDayDirs(codexDir, since) {
1188
+ const dirs = [];
1189
+ const day = new Date(since - 864e5);
1190
+ day.setHours(12, 0, 0, 0);
1191
+ for (let i = 0; i < 31 && day.getTime() <= Date.now() + 864e5; i++, day.setDate(day.getDate() + 1)) {
1192
+ const pad = (n) => String(n).padStart(2, "0");
1193
+ dirs.push(path.join(codexDir, "sessions", String(day.getFullYear()), pad(day.getMonth() + 1), pad(day.getDate())));
1194
+ }
1195
+ return dirs;
1196
+ }
1197
+ async function rolloutMeta(file) {
1198
+ const fh = await fs$1.promises.open(file, "r").catch(() => null);
1199
+ if (!fh) return null;
1200
+ try {
1201
+ const buf = Buffer.alloc(1 << 20);
1202
+ const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
1203
+ const nl = buf.subarray(0, bytesRead).indexOf(10);
1204
+ if (nl === -1) return null;
1205
+ let e;
1206
+ try {
1207
+ e = JSON.parse(buf.subarray(0, nl).toString("utf8"));
1208
+ } catch {
1209
+ return {};
1210
+ }
1211
+ const p = e?.type === "session_meta" ? e.payload : null;
1212
+ return p ? { cwd: p.cwd ?? null, originator: p.originator ?? null, source: p.source ?? null } : {};
1213
+ } finally {
1214
+ await fh.close();
1215
+ }
1216
+ }
1217
+ async function readSessionFile(claudeDir, pid) {
1218
+ if (!Number.isInteger(pid) || !alive(pid)) return null;
1219
+ try {
1220
+ const meta = JSON.parse(await fs$1.promises.readFile(path.join(claudeDir, "sessions", `${pid}.json`), "utf8"));
1221
+ return meta.pid === pid && typeof meta.sessionId === "string" ? meta : null;
1222
+ } catch {
1223
+ return null;
1224
+ }
1225
+ }
1226
+ function alive(pid) {
1227
+ try {
1228
+ process.kill(pid, 0);
1229
+ return true;
1230
+ } catch (err) {
1231
+ return err.code === "EPERM";
1232
+ }
1233
+ }
1234
+ async function findClaudeTranscript(claudeDir, id, cwd) {
1235
+ const projects = path.join(claudeDir, "projects");
1236
+ if (cwd) {
1237
+ const guess = path.join(projects, cwd.replace(/[^A-Za-z0-9]/g, "-"), `${id}.jsonl`);
1238
+ if (fs$1.existsSync(guess)) return guess;
1239
+ }
1240
+ for (const dir of await fs$1.promises.readdir(projects).catch(() => [])) {
1241
+ const file = path.join(projects, dir, `${id}.jsonl`);
1242
+ if (fs$1.existsSync(file)) return file;
1243
+ }
1244
+ return null;
1245
+ }
1246
+ async function findCodexRollout(codexDir, id) {
1247
+ const ls = async (dir) => (await fs$1.promises.readdir(dir).catch(() => [])).sort().reverse();
1248
+ const root = path.join(codexDir, "sessions");
1249
+ for (const y of await ls(root))
1250
+ for (const m of await ls(path.join(root, y)))
1251
+ for (const d of await ls(path.join(root, y, m))) {
1252
+ const dir = path.join(root, y, m, d);
1253
+ const name = (await ls(dir)).find((f) => f.endsWith(`-${id}.jsonl`));
1254
+ if (name) return path.join(dir, name);
1255
+ }
1256
+ return null;
1257
+ }
1258
+ class Transcript {
1259
+ file;
1260
+ parser;
1261
+ offset;
1262
+ rest;
1263
+ skipFirst;
1264
+ constructor(file, kind) {
1265
+ this.file = file;
1266
+ this.parser = kind === "claude" ? new ClaudeLog() : new CodexLog();
1267
+ this.offset = 0;
1268
+ this.rest = Buffer.alloc(0);
1269
+ this.skipFirst = false;
1270
+ }
1271
+ async usage() {
1272
+ const { size } = await fs$1.promises.stat(this.file);
1273
+ if (size < this.offset) {
1274
+ this.parser = this.parser.fresh();
1275
+ this.offset = 0;
1276
+ this.rest = Buffer.alloc(0);
1277
+ }
1278
+ if (size > this.offset) await this.readTo(size);
1279
+ return this.parser.usage();
1280
+ }
1281
+ async readTo(size) {
1282
+ let start = this.offset;
1283
+ if (start === 0 && size > FIRST_READ_MAX) {
1284
+ start = size - FIRST_READ_MAX;
1285
+ this.skipFirst = true;
1286
+ }
1287
+ const stream = fs$1.createReadStream(this.file, { start, end: size - 1, highWaterMark: 1 << 20 });
1288
+ for await (const buf of stream) {
1289
+ let from = 0, nl;
1290
+ while ((nl = buf.indexOf(10, from)) !== -1) {
1291
+ const line = this.rest.length ? Buffer.concat([this.rest, buf.subarray(from, nl)]) : buf.subarray(from, nl);
1292
+ this.rest = Buffer.alloc(0);
1293
+ from = nl + 1;
1294
+ if (this.skipFirst) {
1295
+ this.skipFirst = false;
1296
+ continue;
1297
+ }
1298
+ if (this.parser.wants(line)) {
1299
+ try {
1300
+ this.parser.add(JSON.parse(line.toString("utf8")));
1301
+ } catch {
1302
+ }
1303
+ }
1304
+ }
1305
+ this.rest = Buffer.concat([this.rest, buf.subarray(from)]);
1306
+ }
1307
+ this.offset = size;
1308
+ }
1309
+ }
1310
+ class ClaudeLog {
1311
+ used = null;
1312
+ /** The model of the last call, e.g. "claude-opus-5-5". */
1313
+ model = null;
1314
+ /** Claude Code's name for it, which ends in "[1m]" when it asked for the 1M window. */
1315
+ modelId = null;
1316
+ fresh() {
1317
+ return new ClaudeLog();
1318
+ }
1319
+ wants(line) {
1320
+ return line.includes('"usage"') || line.includes('"compact_boundary"') || line.includes('"modelId"');
1321
+ }
1322
+ add(e) {
1323
+ if (e.isSidechain) return;
1324
+ if (e.type === "assistant") {
1325
+ const u = e.message?.usage;
1326
+ if (!u || e.message.model === "<synthetic>") return;
1327
+ const used = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
1328
+ if (used > 0) this.used = used;
1329
+ if (typeof e.message.model === "string") this.model = e.message.model;
1330
+ } else if (e.type === "system" && e.subtype === "compact_boundary") {
1331
+ const post = e.compactMetadata?.postTokens;
1332
+ this.used = typeof post === "number" ? post : null;
1333
+ } else if (e.type === "attachment" && e.attachment?.type === "model") {
1334
+ this.modelId = e.attachment.identity?.modelId ?? this.modelId;
1335
+ }
1336
+ }
1337
+ usage() {
1338
+ if (this.used === null) return null;
1339
+ const small = this.used <= CLAUDE_WINDOW_SMALL && !/\[1m\]/i.test(this.modelId ?? "") && CLAUDE_SMALL_MODEL.test(this.model ?? this.modelId ?? "");
1340
+ return { used: this.used, max: small ? CLAUDE_WINDOW_SMALL : CLAUDE_WINDOW };
1341
+ }
1342
+ }
1343
+ class CodexLog {
1344
+ used = null;
1345
+ max = null;
1346
+ fresh() {
1347
+ return new CodexLog();
1348
+ }
1349
+ wants(line) {
1350
+ return line.includes('"token_count"');
1351
+ }
1352
+ add(e) {
1353
+ const info = e.type === "event_msg" && e.payload?.type === "token_count" ? e.payload.info : null;
1354
+ if (!info) return;
1355
+ if (typeof info.last_token_usage?.total_tokens === "number") this.used = info.last_token_usage.total_tokens;
1356
+ if (typeof info.model_context_window === "number") this.max = info.model_context_window;
1357
+ }
1358
+ usage() {
1359
+ return this.used !== null && this.max ? { used: this.used, max: this.max } : null;
1360
+ }
1361
+ }
1362
+ const POLL_MS = 2e3;
1363
+ const ROOT_TTL_MS = 3e4;
1364
+ const FAIL_MS = 3e4;
1365
+ const MAX_FILES = 400;
1366
+ const MAX_BYTES = 512 * 1024;
1367
+ const MAX_SHEETS = 50;
1368
+ const MAX_DIFF_LINES = 800;
1369
+ const MAX_LINE = 400;
1370
+ const CONTEXT = 3;
1371
+ const LCS_CELLS = 1e6;
1372
+ const GIT_TIMEOUT_MS = 15e3;
1373
+ const MAX_GIT_OUT = 16 * 1024 * 1024;
1374
+ const MAX_DIFF_OUT = 8 * 1024 * 1024;
1375
+ const MAX_PAGES = 300;
1376
+ const MAX_TOTAL_LINES = 2e4;
1377
+ const MAX_UNTRACKED = 100;
1378
+ const UNCOMMITTED_TIMEOUT_MS = 6e4;
1379
+ const BINARY = Symbol("binary");
1380
+ const LARGE = Symbol("large");
1381
+ let sheetSeq = 0;
1382
+ class HostDown extends Error {
1383
+ constructor() {
1384
+ super("the remote helper is not running");
1385
+ }
1386
+ }
1387
+ class DiffTracker extends EventEmitter {
1388
+ m;
1389
+ host;
1390
+ /** folder → { root, at } */
1391
+ folders;
1392
+ /** git root → Checkout */
1393
+ checkouts;
1394
+ /** git root → sheets, oldest first */
1395
+ sheets;
1396
+ /** workspace id → git root */
1397
+ roots;
1398
+ updating;
1399
+ again;
1400
+ stopped;
1401
+ timer;
1402
+ /** Makes the printers for `machine`'s floor; `start()` begins following its rooms. */
1403
+ constructor(machine, settings) {
1404
+ super();
1405
+ this.m = machine;
1406
+ this.host = machine.ssh ? new RemoteHost(machine, settings.sshCommand) : new LocalHost();
1407
+ this.folders = /* @__PURE__ */ new Map();
1408
+ this.checkouts = /* @__PURE__ */ new Map();
1409
+ this.sheets = /* @__PURE__ */ new Map();
1410
+ this.roots = /* @__PURE__ */ new Map();
1411
+ this.updating = false;
1412
+ this.again = false;
1413
+ this.stopped = false;
1414
+ this.timer = null;
1415
+ }
1416
+ start() {
1417
+ this.m.on("snapshot", () => this.update());
1418
+ this.timer = setTimeout(() => this.poll(), POLL_MS);
1419
+ }
1420
+ stop() {
1421
+ this.stopped = true;
1422
+ clearTimeout(this.timer);
1423
+ this.host.stop();
1424
+ }
1425
+ /** The Kauak snapshot with each room's `git_root`. */
1426
+ decorate(snapshot) {
1427
+ if (this.roots.size === 0) return snapshot;
1428
+ return { ...snapshot, workspaces: snapshot.workspaces.map((w) => ({ ...w, git_root: this.roots.get(w.workspace_id) ?? null })) };
1429
+ }
1430
+ /** Every sheet still kept, oldest first. */
1431
+ history() {
1432
+ return [...this.sheets.values()].flat().sort((a, b) => a.at - b.at);
1433
+ }
1434
+ /** Everything not committed in a checkout a room is in: { files, incomplete, error? }. */
1435
+ async uncommitted(root) {
1436
+ const co = this.checkouts.get(root);
1437
+ if (!co) return { files: [], incomplete: false, error: "That room is not in a git checkout." };
1438
+ try {
1439
+ return await co.uncommitted();
1440
+ } catch (err) {
1441
+ return {
1442
+ files: [],
1443
+ incomplete: false,
1444
+ error: err instanceof HostDown ? `${this.m.label} is not answering.` : lastLine(err)
1445
+ };
1446
+ }
1447
+ }
1448
+ update() {
1449
+ if (this.updating) {
1450
+ this.again = true;
1451
+ return;
1452
+ }
1453
+ this.updating = true;
1454
+ this.resolveRooms().catch((err) => console.error(`[bridge] ${this.m.label}: printers:`, err.message)).finally(() => {
1455
+ this.updating = false;
1456
+ if (this.again) {
1457
+ this.again = false;
1458
+ this.update();
1459
+ }
1460
+ });
1461
+ }
1462
+ async resolveRooms() {
1463
+ const snap = this.m.snapshot;
1464
+ if (!snap) return;
1465
+ const next = /* @__PURE__ */ new Map();
1466
+ for (const ws of snap.workspaces) {
1467
+ const folder = ws.repo?.checkout ?? snap.panes.find((p) => p.workspace_id === ws.workspace_id)?.cwd;
1468
+ const root = folder ? await this.rootOf(folder) : null;
1469
+ if (root) next.set(ws.workspace_id, root);
1470
+ }
1471
+ if (this.stopped) return;
1472
+ const used = new Set(next.values());
1473
+ for (const root of [...this.checkouts.keys()]) {
1474
+ if (!used.has(root)) {
1475
+ this.checkouts.delete(root);
1476
+ this.sheets.delete(root);
1477
+ }
1478
+ }
1479
+ for (const root of used) if (!this.checkouts.has(root)) this.checkouts.set(root, new Checkout(root, this.host));
1480
+ if (!sameMap(next, this.roots)) {
1481
+ this.roots = next;
1482
+ this.emit("change");
1483
+ }
1484
+ }
1485
+ async rootOf(folder) {
1486
+ const known = this.folders.get(folder);
1487
+ if (known && (known.root || Date.now() - known.at < ROOT_TTL_MS)) return known.root;
1488
+ let root = null;
1489
+ try {
1490
+ root = (await this.host.git(folder, ["rev-parse", "--show-toplevel"])).out.toString().trim() || null;
1491
+ } catch (err) {
1492
+ if (err instanceof HostDown) return known?.root ?? null;
1493
+ }
1494
+ this.folders.set(folder, { root, at: Date.now() });
1495
+ return root;
1496
+ }
1497
+ async poll() {
1498
+ for (const co of [...this.checkouts.values()]) {
1499
+ if (this.stopped) return;
1500
+ if (this.checkouts.get(co.root) !== co || Date.now() < co.failedUntil) continue;
1501
+ try {
1502
+ for (const sheet of await co.scan()) this.print(co.root, sheet);
1503
+ } catch (err) {
1504
+ if (err instanceof HostDown) break;
1505
+ co.failedUntil = Date.now() + FAIL_MS;
1506
+ for (const [folder, f] of this.folders) if (f.root === co.root) this.folders.delete(folder);
1507
+ console.warn(`[bridge] ${this.m.label}: cannot read ${co.root}: ${lastLine(err)}`);
1508
+ }
1509
+ }
1510
+ if (!this.stopped) this.timer = setTimeout(() => this.poll(), POLL_MS);
1511
+ }
1512
+ print(root, sheet) {
1513
+ sheet.root = root;
1514
+ const list2 = this.sheets.get(root) ?? [];
1515
+ list2.push(sheet);
1516
+ if (list2.length > MAX_SHEETS) list2.splice(0, list2.length - MAX_SHEETS);
1517
+ this.sheets.set(root, list2);
1518
+ this.emit("print", sheet);
1519
+ }
1520
+ }
1521
+ class Checkout {
1522
+ root;
1523
+ host;
1524
+ /** repo-relative path → { sig, content } */
1525
+ files;
1526
+ ready;
1527
+ failedUntil;
1528
+ constructor(root, host) {
1529
+ this.root = root;
1530
+ this.host = host;
1531
+ this.files = /* @__PURE__ */ new Map();
1532
+ this.ready = false;
1533
+ this.failedUntil = 0;
1534
+ }
1535
+ /** The sheets for what changed since the last scan (none on the first: that is the baseline). */
1536
+ async scan() {
1537
+ const status = parseStatus(
1538
+ (await this.host.git(this.root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"])).out.toString("utf8")
1539
+ );
1540
+ const paths = [.../* @__PURE__ */ new Set([...status.keys(), ...this.files.keys()])];
1541
+ const stats = paths.length ? await this.host.stat(this.root, paths) : /* @__PURE__ */ new Map();
1542
+ const changed = [];
1543
+ for (const file of paths) {
1544
+ const st = stats.get(file) ?? null;
1545
+ if (st === "other") continue;
1546
+ const known = this.files.get(file);
1547
+ if (known?.sig === (st ?? "-")) {
1548
+ if (!status.has(file)) this.files.delete(file);
1549
+ continue;
1550
+ }
1551
+ changed.push(file);
1552
+ }
1553
+ if (!changed.length) {
1554
+ this.ready = true;
1555
+ return [];
1556
+ }
1557
+ const present = changed.filter((f) => stats.get(f));
1558
+ const contents = present.length ? await this.host.read(this.root, present) : /* @__PURE__ */ new Map();
1559
+ const fresh = this.ready ? changed.filter((f) => !this.files.has(f)) : [];
1560
+ const heads = fresh.length ? await this.headContents(fresh.map((f) => status.get(f)?.orig ?? f)) : /* @__PURE__ */ new Map();
1561
+ const sheets = [];
1562
+ for (const file of changed) {
1563
+ const now = decode(contents.get(file) ?? null);
1564
+ const known = this.files.get(file);
1565
+ const before = known ? known.content : this.ready ? heads.get(status.get(file)?.orig ?? file) ?? null : void 0;
1566
+ if (status.has(file)) this.files.set(file, { sig: stats.get(file) ?? "-", content: now });
1567
+ else this.files.delete(file);
1568
+ if (before === void 0) continue;
1569
+ const sheet = makeSheet(file, before, now);
1570
+ if (sheet) sheets.push(sheet);
1571
+ }
1572
+ this.ready = true;
1573
+ return sheets;
1574
+ }
1575
+ /** What HEAD has at each path (null where it has nothing), in one `git cat-file --batch`. */
1576
+ async headContents(paths) {
1577
+ const result = new Map(paths.map((p) => [p, null]));
1578
+ const asked = paths.filter((p) => !p.includes("\n"));
1579
+ if (!asked.length) return result;
1580
+ const { out } = await this.host.git(this.root, ["cat-file", "--batch"], {
1581
+ input: Buffer.from(asked.map((p) => `HEAD:${p}
1582
+ `).join(""))
1583
+ });
1584
+ let at = 0;
1585
+ for (const p of asked) {
1586
+ const nl = out.indexOf(10, at);
1587
+ if (nl === -1) break;
1588
+ const m = /^[0-9a-f]{40,64} (\w+) (\d+)$/.exec(out.subarray(at, nl).toString("utf8"));
1589
+ at = nl + 1;
1590
+ if (!m) continue;
1591
+ const size = Number(m[2]);
1592
+ if (m[1] === "blob") result.set(p, decode(out.subarray(at, at + size)));
1593
+ at += size + 1;
1594
+ }
1595
+ return result;
1596
+ }
1597
+ /** Everything not committed: `git diff` against HEAD and the untracked files, one page per file, by path. */
1598
+ async uncommitted() {
1599
+ const opts = { timeout: UNCOMMITTED_TIMEOUT_MS };
1600
+ const base = await this.host.git(this.root, ["rev-parse", "-q", "--verify", "HEAD"], opts).then(
1601
+ () => "HEAD",
1602
+ async () => (await this.host.git(this.root, ["hash-object", "-t", "tree", "--stdin"], { ...opts, input: Buffer.alloc(0) })).out.toString().trim()
1603
+ );
1604
+ const diff = await this.host.git(
1605
+ this.root,
1606
+ [
1607
+ "diff",
1608
+ base,
1609
+ "--no-color",
1610
+ "--no-ext-diff",
1611
+ "--no-textconv",
1612
+ "--no-relative",
1613
+ "--find-renames",
1614
+ "--src-prefix=a/",
1615
+ "--dst-prefix=b/",
1616
+ `-U${CONTEXT}`,
1617
+ "--"
1618
+ ],
1619
+ { ...opts, max: MAX_DIFF_OUT }
1620
+ );
1621
+ const pages = parseGitDiff(diff.out.toString("utf8"));
1622
+ let incomplete = diff.truncated;
1623
+ const untracked = (await this.host.git(this.root, ["ls-files", "--others", "--exclude-standard", "-z"], opts)).out.toString("utf8").split("\0").filter(Boolean);
1624
+ const read = untracked.slice(0, MAX_UNTRACKED);
1625
+ const contents = read.length ? await this.host.read(this.root, read, opts) : /* @__PURE__ */ new Map();
1626
+ for (const file of untracked) {
1627
+ const page = { path: file, change: "added", untracked: true, added: 0, removed: 0, diff: "", truncated: false };
1628
+ const content = contents.has(file) ? decode(contents.get(file)) : void 0;
1629
+ if (content === void 0) pages.push({ ...page, note: "Not printed: too many new files." });
1630
+ else if (content === null)
1631
+ continue;
1632
+ else if (typeof content === "symbol") pages.push({ ...page, note: content === LARGE ? "Too large to print." : "Binary file." });
1633
+ else pages.push({ ...page, ...lineDiff([], lines(content)) });
1634
+ }
1635
+ pages.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1636
+ if (pages.length > MAX_PAGES) {
1637
+ pages.length = MAX_PAGES;
1638
+ incomplete = true;
1639
+ }
1640
+ let total = 0;
1641
+ for (const page of pages) {
1642
+ const n = page.diff ? page.diff.split("\n").length : 0;
1643
+ if (total + n <= MAX_TOTAL_LINES) {
1644
+ total += n;
1645
+ continue;
1646
+ }
1647
+ Object.assign(page, { diff: "", truncated: false, note: "Not printed: there was too much to print." });
1648
+ incomplete = true;
1649
+ }
1650
+ return { files: pages, incomplete };
1651
+ }
1652
+ }
1653
+ class LocalHost {
1654
+ stop() {
1655
+ }
1656
+ git(cwd, args, { input = null, max = MAX_GIT_OUT } = {}) {
1657
+ return new Promise((resolve, reject) => {
1658
+ const child = execFile(
1659
+ "git",
1660
+ ["-c", "core.quotepath=off", ...args],
1661
+ {
1662
+ cwd,
1663
+ encoding: "buffer",
1664
+ maxBuffer: max,
1665
+ timeout: GIT_TIMEOUT_MS,
1666
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }
1667
+ },
1668
+ (err, stdout, stderr) => {
1669
+ if (err?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") resolve({ out: stdout, truncated: true });
1670
+ else if (err) reject(Object.assign(err, { stderr }));
1671
+ else resolve({ out: stdout, truncated: false });
1672
+ }
1673
+ );
1674
+ child.stdin.on("error", () => {
1675
+ });
1676
+ child.stdin.end(input ?? void 0);
1677
+ });
1678
+ }
1679
+ async stat(root, paths) {
1680
+ const stats = await Promise.all(paths.map((p) => fs$1.promises.lstat(path.join(root, p)).catch(() => null)));
1681
+ return new Map(
1682
+ paths.map((p, i) => {
1683
+ const st = stats[i];
1684
+ return [p, !st ? null : st.isFile() ? `${st.size}:${st.mtimeMs}` : "other"];
1685
+ })
1686
+ );
1687
+ }
1688
+ async read(root, paths) {
1689
+ const out = await Promise.all(
1690
+ paths.map(async (p) => {
1691
+ const file = path.join(root, p);
1692
+ const st = await fs$1.promises.stat(file).catch(() => null);
1693
+ if (!st) return null;
1694
+ if (st.size > MAX_BYTES) return LARGE;
1695
+ return fs$1.promises.readFile(file).catch(() => null);
1696
+ })
1697
+ );
1698
+ return new Map(paths.map((p, i) => [p, out[i]]));
1699
+ }
1700
+ }
1701
+ class RemoteHost {
1702
+ script;
1703
+ constructor(machine, sshCommand) {
1704
+ this.script = new RemoteScript(machine, sshCommand, new URL("./diffs_remote.py", import.meta.url), "printers");
1705
+ }
1706
+ stop() {
1707
+ this.script.stop();
1708
+ }
1709
+ async call(request, timeout) {
1710
+ const result = await this.script.call(request, timeout);
1711
+ if (result == null) throw new HostDown();
1712
+ return result;
1713
+ }
1714
+ async git(cwd, args, { input = null, max = MAX_GIT_OUT, timeout } = {}) {
1715
+ const r = await this.call(
1716
+ { op: "git", cwd, args, input: input ? input.toString("base64") : null, max },
1717
+ timeout
1718
+ );
1719
+ if (!r.ok) throw Object.assign(new Error(r.err || `git ${args[0]} failed`), { stderr: r.err });
1720
+ return { out: Buffer.from(r.out, "base64"), truncated: r.truncated };
1721
+ }
1722
+ async stat(root, paths) {
1723
+ return new Map(Object.entries(await this.call({ op: "stat", root, paths })));
1724
+ }
1725
+ async read(root, paths, { timeout } = {}) {
1726
+ const r = await this.call({ op: "read", root, paths, max: MAX_BYTES }, timeout);
1727
+ return new Map(
1728
+ Object.entries(r).map(([p, v]) => [p, v === "large" ? LARGE : v === null ? null : Buffer.from(v, "base64")])
1729
+ );
1730
+ }
1731
+ }
1732
+ function parseStatus(out) {
1733
+ const entries = /* @__PURE__ */ new Map();
1734
+ const parts = out.split("\0");
1735
+ for (let i = 0; i < parts.length && entries.size < MAX_FILES; i++) {
1736
+ const e = parts[i];
1737
+ if (e.length < 4) continue;
1738
+ const code = e.slice(0, 2);
1739
+ const orig = code[0] === "R" || code[0] === "C" ? parts[++i] ?? null : null;
1740
+ entries.set(e.slice(3), { code, orig });
1741
+ }
1742
+ return entries;
1743
+ }
1744
+ function parseGitDiff(text) {
1745
+ const pages = [];
1746
+ let page = null, rows = [], inHunk = false, mode = false;
1747
+ const done = () => {
1748
+ if (!page) return;
1749
+ page.diff = rows.join("\n");
1750
+ if (!page.diff && !page.note)
1751
+ page.note = page.change === "renamed" ? "Renamed; the contents did not change." : mode ? "Only the file's mode changed." : "Nothing to print.";
1752
+ pages.push(page);
1753
+ };
1754
+ for (const line of text.split("\n")) {
1755
+ if (line.startsWith("diff --git ")) {
1756
+ done();
1757
+ page = { path: gitLinePath(line.slice(11)), change: "modified", added: 0, removed: 0, diff: "", truncated: false };
1758
+ rows = [];
1759
+ inHunk = false;
1760
+ mode = false;
1761
+ continue;
1762
+ }
1763
+ if (!page) continue;
1764
+ if (!inHunk) {
1765
+ if (line.startsWith("new file mode")) page.change = "added";
1766
+ else if (line.startsWith("deleted file mode")) page.change = "deleted";
1767
+ else if (line.startsWith("old mode")) mode = true;
1768
+ else if (line.startsWith("rename from ")) {
1769
+ page.change = "renamed";
1770
+ page.from = unquote(line.slice(12));
1771
+ } else if (line.startsWith("rename to ")) page.path = unquote(line.slice(10));
1772
+ else if (line.startsWith("--- ") && line !== "--- /dev/null") page.path = headerPath(line.slice(4), "a/");
1773
+ else if (line.startsWith("+++ ") && line !== "+++ /dev/null") page.path = headerPath(line.slice(4), "b/");
1774
+ else if (line.startsWith("Binary files ")) page.note = "Binary file.";
1775
+ if (!line.startsWith("@@")) continue;
1776
+ inHunk = true;
1777
+ }
1778
+ const kind = line[0];
1779
+ if (kind === "+") page.added++;
1780
+ else if (kind === "-") page.removed++;
1781
+ else if (kind !== " " && kind !== "@") continue;
1782
+ if (rows.length >= MAX_DIFF_LINES) {
1783
+ page.truncated = true;
1784
+ continue;
1785
+ }
1786
+ rows.push(line.length > MAX_LINE + 1 ? `${line.slice(0, MAX_LINE + 1)}…` : line);
1787
+ }
1788
+ done();
1789
+ return pages;
1790
+ }
1791
+ function gitLinePath(s) {
1792
+ const n = (s.length - 5) / 2;
1793
+ if (Number.isInteger(n) && s.startsWith("a/") && s.slice(2 + n) === ` b/${s.slice(2, 2 + n)}`) return s.slice(2, 2 + n);
1794
+ const b = s.lastIndexOf(" b/");
1795
+ return unquote(b > 0 ? s.slice(b + 3) : s);
1796
+ }
1797
+ function headerPath(s, prefix) {
1798
+ const p = unquote(s.replace(/\t$/, ""));
1799
+ return p.startsWith(prefix) ? p.slice(prefix.length) : p;
1800
+ }
1801
+ function unquote(s) {
1802
+ if (s.length < 2 || !s.startsWith('"') || !s.endsWith('"')) return s;
1803
+ const bytes = [];
1804
+ const body = s.slice(1, -1);
1805
+ for (let i = 0; i < body.length; i++) {
1806
+ if (body[i] !== "\\") {
1807
+ bytes.push(...Buffer.from(body[i]));
1808
+ continue;
1809
+ }
1810
+ const c = body[++i] ?? "";
1811
+ if (/[0-7]/.test(c)) {
1812
+ bytes.push(parseInt(body.slice(i, i + 3), 8));
1813
+ i += 2;
1814
+ } else bytes.push({ n: 10, t: 9, r: 13, a: 7, b: 8, f: 12, v: 11 }[c] ?? c.charCodeAt(0));
1815
+ }
1816
+ return Buffer.from(bytes).toString("utf8");
1817
+ }
1818
+ function decode(buf) {
1819
+ if (buf === null || typeof buf === "symbol") return buf;
1820
+ if (buf.length > MAX_BYTES) return LARGE;
1821
+ return buf.subarray(0, 8e3).includes(0) ? BINARY : buf.toString("utf8");
1822
+ }
1823
+ function makeSheet(file, before, after) {
1824
+ if (before === after && typeof before !== "symbol") return null;
1825
+ const sheet = {
1826
+ id: `${Date.now().toString(36)}-${(++sheetSeq).toString(36)}`,
1827
+ path: file,
1828
+ change: before === null ? "added" : after === null ? "deleted" : "modified",
1829
+ at: Date.now()
1830
+ };
1831
+ if (typeof before === "symbol" || typeof after === "symbol") {
1832
+ const note = before === LARGE || after === LARGE ? "Too large to print." : "Binary file.";
1833
+ return { ...sheet, added: 0, removed: 0, diff: "", truncated: false, note };
1834
+ }
1835
+ const d = lineDiff(before === null ? [] : lines(before), after === null ? [] : lines(after));
1836
+ return d.added || d.removed ? { ...sheet, ...d } : null;
1837
+ }
1838
+ function lines(text) {
1839
+ const rows = text.split("\n");
1840
+ if (rows[rows.length - 1] === "") rows.pop();
1841
+ return rows.map((r) => r.endsWith("\r") ? r.slice(0, -1) : r);
1842
+ }
1843
+ function lineDiff(a, b) {
1844
+ let pre = 0;
1845
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
1846
+ let suf = 0;
1847
+ while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;
1848
+ const ops = a.slice(0, pre).map((t) => [" ", t]);
1849
+ ops.push(...middle(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf)));
1850
+ for (let i = a.length - suf; i < a.length; i++) ops.push([" ", a[i]]);
1851
+ return hunks(ops);
1852
+ }
1853
+ function middle(A, B) {
1854
+ const n = A.length, m = B.length;
1855
+ if (n * m > LCS_CELLS) return [...A.map((t) => ["-", t]), ...B.map((t) => ["+", t])];
1856
+ const w = m + 1;
1857
+ const L = new Uint16Array((n + 1) * w);
1858
+ for (let i2 = n - 1; i2 >= 0; i2--) {
1859
+ for (let j2 = m - 1; j2 >= 0; j2--) {
1860
+ L[i2 * w + j2] = A[i2] === B[j2] ? L[(i2 + 1) * w + j2 + 1] + 1 : Math.max(L[(i2 + 1) * w + j2], L[i2 * w + j2 + 1]);
1861
+ }
1862
+ }
1863
+ const out = [];
1864
+ let i = 0, j = 0;
1865
+ while (i < n && j < m) {
1866
+ if (A[i] === B[j]) {
1867
+ out.push([" ", A[i]]);
1868
+ i++;
1869
+ j++;
1870
+ } else if (L[(i + 1) * w + j] >= L[i * w + j + 1]) out.push(["-", A[i++]]);
1871
+ else out.push(["+", B[j++]]);
1872
+ }
1873
+ while (i < n) out.push(["-", A[i++]]);
1874
+ while (j < m) out.push(["+", B[j++]]);
1875
+ return out;
1876
+ }
1877
+ function hunks(ops) {
1878
+ let added = 0, removed = 0;
1879
+ const changes = [];
1880
+ const oldAt = new Int32Array(ops.length + 1), newAt = new Int32Array(ops.length + 1);
1881
+ for (let k = 0, o = 1, n = 1; k <= ops.length; k++) {
1882
+ oldAt[k] = o;
1883
+ newAt[k] = n;
1884
+ const kind = ops[k]?.[0];
1885
+ if (kind === "+") {
1886
+ added++;
1887
+ n++;
1888
+ changes.push(k);
1889
+ } else if (kind === "-") {
1890
+ removed++;
1891
+ o++;
1892
+ changes.push(k);
1893
+ } else if (kind === " ") {
1894
+ o++;
1895
+ n++;
1896
+ }
1897
+ }
1898
+ const out = [];
1899
+ let truncated = false;
1900
+ for (let c = 0; c < changes.length && !truncated; c++) {
1901
+ const start = Math.max(0, changes[c] - CONTEXT);
1902
+ let end = changes[c];
1903
+ while (c + 1 < changes.length && changes[c + 1] - end <= CONTEXT * 2 + 1) end = changes[++c];
1904
+ const slice = ops.slice(start, Math.min(ops.length, end + CONTEXT + 1));
1905
+ const oldLen = slice.filter(([k]) => k !== "+").length, newLen = slice.filter(([k]) => k !== "-").length;
1906
+ out.push(`@@ -${oldAt[start] - (oldLen ? 0 : 1)},${oldLen} +${newAt[start] - (newLen ? 0 : 1)},${newLen} @@`);
1907
+ for (const [k, t] of slice) out.push(k + (t.length > MAX_LINE ? `${t.slice(0, MAX_LINE)}…` : t));
1908
+ if (out.length > MAX_DIFF_LINES) truncated = true;
1909
+ }
1910
+ return { added, removed, diff: out.slice(0, MAX_DIFF_LINES).join("\n"), truncated };
1911
+ }
1912
+ function sameMap(a, b) {
1913
+ if (a.size !== b.size) return false;
1914
+ for (const [k, v] of a) if (b.get(k) !== v) return false;
1915
+ return true;
1916
+ }
1917
+ function lastLine(err) {
1918
+ const text = err.stderr?.toString() || err.message;
1919
+ return text.trim().split("\n").pop();
1920
+ }
1921
+ const RUNTIME = "Herdr";
1922
+ const STATUSES = new Set(AGENT_STATUSES);
1923
+ function toSnapshot(raw) {
1924
+ const rects = /* @__PURE__ */ new Map();
1925
+ for (const layout of list(raw?.layouts)) for (const p of list(layout?.panes)) rects.set(p?.pane_id, p?.rect);
1926
+ return {
1927
+ workspaces: list(raw?.workspaces).map((w) => ({
1928
+ workspace_id: String(w.workspace_id),
1929
+ number: Number.isInteger(w.number) ? w.number : 0,
1930
+ label: typeof w.label === "string" ? w.label : "",
1931
+ focused: w.focused === true,
1932
+ repo: repoOf(w.worktree),
1933
+ git_root: null
1934
+ })),
1935
+ panes: list(raw?.panes).map((p) => ({
1936
+ pane_id: String(p.pane_id),
1937
+ workspace_id: String(p.workspace_id),
1938
+ focused: p.focused === true,
1939
+ cwd: p.foreground_cwd || p.cwd || null,
1940
+ // Herdr strips the spinner or status glyph an agent puts in front of its title.
1941
+ title: p.terminal_title_stripped || p.terminal_title || "",
1942
+ agent: typeof p.agent === "string" && p.agent ? p.agent : null,
1943
+ agent_status: STATUSES.has(p.agent_status) ? p.agent_status : "unknown",
1944
+ screen: screenOf(p.scroll, rects.get(p.pane_id)),
1945
+ scrollback: (p.scroll?.max_offset_from_bottom ?? 0) > 0,
1946
+ context: null
1947
+ }))
1948
+ };
1949
+ }
1950
+ function repoOf(wt) {
1951
+ if (!wt || typeof wt.repo_key !== "string") return null;
1952
+ return { key: wt.repo_key, name: wt.repo_name, root: wt.repo_root, checkout: wt.checkout_path, linked: wt.is_linked_worktree === true };
1953
+ }
1954
+ function screenOf(scroll, rect) {
1955
+ const rows = scroll?.viewport_rows || rect?.height;
1956
+ if (!rows) return null;
1957
+ return { rows, cols: rect?.width ?? null, exact: rect !== void 0 && rect.height === scroll?.viewport_rows };
1958
+ }
1959
+ function paneSession(raw, paneId) {
1960
+ const pane = list(raw?.panes).find((p) => p.pane_id === paneId);
1961
+ const s = pane?.agent_session;
1962
+ return s && s.agent === pane.agent && (s.kind === "id" || s.kind === "path") && typeof s.value === "string" ? { kind: s.kind, value: s.value } : null;
1963
+ }
1964
+ function errorMessage(err) {
1965
+ if (err?.code === "ENOENT" || err?.code === "ECONNREFUSED") return `${RUNTIME} is not running`;
1966
+ const text = String(err?.message ?? err);
1967
+ return text.split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? text;
1968
+ }
1969
+ function list(v) {
1970
+ return Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : [];
1971
+ }
1972
+ const TUNNEL_READY_MS = 2e4;
1973
+ const RETRY_MS = [2e3, 4e3, 8e3, 15e3, 3e4];
1974
+ const SNAPSHOT_DEBOUNCE_MS = 80;
1975
+ const MAX_KEYS_PER_CALL = 64;
1976
+ const AGENT_WAIT_MS = 1e4;
1977
+ const AGENT_RETRY_MS = 300;
1978
+ const REMOTE_PROBE = `sh -c '${[
1979
+ `p="\${HERDR_SOCKET_PATH:-\${XDG_CONFIG_HOME:-$HOME/.config}/herdr/herdr.sock}"`,
1980
+ `printf "%s" "$p"`,
1981
+ `[ -S "$p" ] || exit 3`
1982
+ ].join("; ")}'`;
1983
+ const SUBSCRIPTIONS = [
1984
+ "workspace.created",
1985
+ "workspace.updated",
1986
+ "workspace.metadata_updated",
1987
+ "workspace.renamed",
1988
+ "workspace.moved",
1989
+ "workspace.reordered",
1990
+ "workspace.closed",
1991
+ "workspace.focused",
1992
+ "worktree.created",
1993
+ "worktree.opened",
1994
+ "worktree.removed",
1995
+ "tab.created",
1996
+ "tab.closed",
1997
+ "tab.focused",
1998
+ "tab.renamed",
1999
+ "tab.moved",
2000
+ "pane.created",
2001
+ "pane.closed",
2002
+ "pane.updated",
2003
+ "pane.focused",
2004
+ "pane.moved",
2005
+ "pane.exited",
2006
+ "pane.agent_detected",
2007
+ "layout.updated"
2008
+ ].map((type) => ({ type }));
2009
+ let reqSeq = 0;
2010
+ function herdrRequest(socketPath, method, params = {}) {
2011
+ return new Promise((resolve, reject) => {
2012
+ const id = `office:${++reqSeq}`;
2013
+ const sock = net.createConnection(socketPath);
2014
+ let buf = "";
2015
+ sock.setEncoding("utf8");
2016
+ sock.on("connect", () => sock.write(`${JSON.stringify({ id, method, params })}
2017
+ `));
2018
+ sock.on("data", (chunk) => {
2019
+ buf += chunk;
2020
+ const nl = buf.indexOf("\n");
2021
+ if (nl === -1) return;
2022
+ const line = buf.slice(0, nl);
2023
+ sock.end();
2024
+ try {
2025
+ const msg = JSON.parse(line);
2026
+ if (msg.error) reject(Object.assign(new Error(msg.error.message), { code: msg.error.code, method }));
2027
+ else resolve(msg.result);
2028
+ } catch (err) {
2029
+ reject(err);
2030
+ }
2031
+ });
2032
+ sock.on("close", () => reject(Object.assign(new Error("no reply from Herdr"), { method })));
2033
+ sock.on("error", reject);
2034
+ });
2035
+ }
2036
+ class Machine extends EventEmitter {
2037
+ settings;
2038
+ id;
2039
+ label;
2040
+ ssh;
2041
+ remoteSocket;
2042
+ socketPath;
2043
+ state;
2044
+ message;
2045
+ /** Herdr's latest `session.snapshot`; only this file and herdr.ts read it. */
2046
+ raw;
2047
+ /** The same as a Kauak Snapshot. */
2048
+ snapshot;
2049
+ tunnel;
2050
+ sub;
2051
+ retryTimer;
2052
+ refreshTimer;
2053
+ attempt;
2054
+ stopped;
2055
+ constructor({ id, label, ssh = null, socket = null, remoteSocket = null }, settings) {
2056
+ super();
2057
+ this.settings = settings;
2058
+ this.id = id;
2059
+ this.label = label;
2060
+ this.ssh = ssh;
2061
+ this.remoteSocket = remoteSocket;
2062
+ this.socketPath = ssh ? path.join(settings.tunnelDir, `${id}.sock`) : socket ?? settings.herdrSocket;
2063
+ this.state = "connecting";
2064
+ this.message = ssh ? `ssh ${ssh}…` : "";
2065
+ this.raw = null;
2066
+ this.snapshot = null;
2067
+ this.tunnel = null;
2068
+ this.sub = null;
2069
+ this.retryTimer = null;
2070
+ this.refreshTimer = null;
2071
+ this.attempt = 0;
2072
+ this.stopped = false;
2073
+ }
2074
+ /** The floor's Kauak MachineInfo. */
2075
+ get info() {
2076
+ return {
2077
+ id: this.id,
2078
+ label: this.label,
2079
+ ssh: this.ssh,
2080
+ state: this.state,
2081
+ message: this.message,
2082
+ runtime: { name: RUNTIME, version: this.raw?.version ?? null }
2083
+ };
2084
+ }
2085
+ /** Settings that go in the config file. */
2086
+ get config() {
2087
+ const c = { id: this.id, label: this.label };
2088
+ if (this.ssh) c.ssh = this.ssh;
2089
+ if (this.remoteSocket) c.remoteSocket = this.remoteSocket;
2090
+ if (!this.ssh && this.socketPath !== this.settings.herdrSocket) c.socket = this.socketPath;
2091
+ return c;
2092
+ }
2093
+ start() {
2094
+ this.connect();
2095
+ }
2096
+ stop() {
2097
+ this.stopped = true;
2098
+ clearTimeout(this.retryTimer);
2099
+ clearTimeout(this.refreshTimer);
2100
+ this.sub?.destroy();
2101
+ this.closeTunnel();
2102
+ }
2103
+ request(method, params) {
2104
+ return herdrRequest(this.socketPath, method, params);
2105
+ }
2106
+ // ------------------------------------------------------------ Kauak operations
2107
+ //
2108
+ // What the core and the enrichers ask of a floor (the Runtime port). Each is
2109
+ // a Herdr request or two; a failure rejects with a message fit to show
2110
+ // (herdr.ts).
2111
+ async focusPane(paneId) {
2112
+ await this.call("pane.focus", { pane_id: paneId });
2113
+ }
2114
+ /** The pane's screen as ANSI text; with `lines`, the last `lines` rows of its history and screen. */
2115
+ async readPane(paneId, lines2 = null) {
2116
+ const res = await this.call("pane.read", {
2117
+ pane_id: paneId,
2118
+ source: lines2 ? "recent" : "visible",
2119
+ format: "ansi",
2120
+ strip_ansi: false,
2121
+ lines: lines2
2122
+ });
2123
+ return res.read.text;
2124
+ }
2125
+ async sendText(paneId, text) {
2126
+ await this.call("pane.send_text", { pane_id: paneId, text });
2127
+ }
2128
+ /** Kauak's key names (KEY in @kauak/protocol) are Herdr's own, so they go as they are. */
2129
+ async sendKeys(paneId, keys) {
2130
+ for (let i = 0; i < keys.length; i += MAX_KEYS_PER_CALL) {
2131
+ await this.call("pane.send_keys", { pane_id: paneId, keys: keys.slice(i, i + MAX_KEYS_PER_CALL) });
2132
+ }
2133
+ }
2134
+ /**
2135
+ * A new desk: a new tab in the room, in the room's folder. Returns its
2136
+ * pane's id. A tab rather than a split: a split pane gets only part of the
2137
+ * Herdr window, and the side panel mirrors a pane at its real size, so a
2138
+ * desk split off another came out as a narrow strip with the rest of the
2139
+ * panel empty.
2140
+ */
2141
+ async createDesk(workspaceId) {
2142
+ const snap = this.raw;
2143
+ const room = snap?.workspaces.find((w) => w.workspace_id === workspaceId);
2144
+ if (!room) throw new Error("That room is gone.");
2145
+ const panes = snap.panes.filter((p) => p.workspace_id === room.workspace_id);
2146
+ const cwd = room.worktree?.checkout_path ?? (panes.find((p) => p.tab_id === room.active_tab_id) ?? panes[0])?.cwd ?? null;
2147
+ const res = await this.call("tab.create", { workspace_id: room.workspace_id, cwd, focus: false });
2148
+ return res.root_pane.pane_id;
2149
+ }
2150
+ /**
2151
+ * A new room (a Kauak RoomSpec, its folder already checked by the core): a
2152
+ * git worktree on a new branch, or a workspace in a folder. Returns its
2153
+ * first pane's id.
2154
+ */
2155
+ async createRoom(room) {
2156
+ const res = room.kind === "worktree" ? await this.call("worktree.create", {
2157
+ cwd: room.cwd,
2158
+ branch: room.branch,
2159
+ base: room.base ?? null,
2160
+ label: room.label ?? null,
2161
+ focus: false
2162
+ }) : await this.call("workspace.create", { cwd: room.cwd, label: room.label ?? null, focus: false });
2163
+ return res.root_pane.pane_id;
2164
+ }
2165
+ /** Start an agent of `kind` in a pane, once the pane's shell is ready for it. */
2166
+ async startAgent(kind, paneId) {
2167
+ const name = `${kind}-${Math.random().toString(36).slice(2, 6)}`.slice(0, 32);
2168
+ for (const started = Date.now(); ; await new Promise((r) => setTimeout(r, AGENT_RETRY_MS))) {
2169
+ try {
2170
+ await this.request("agent.start", { name, kind, pane_id: paneId });
2171
+ return;
2172
+ } catch (err) {
2173
+ if (err.code !== "agent_pane_busy" || Date.now() - started > AGENT_WAIT_MS)
2174
+ throw new Error(errorMessage(err));
2175
+ }
2176
+ }
2177
+ }
2178
+ /** The agent session reported for the pane's agent (a transcript path or a session id), or null. */
2179
+ paneSession(paneId) {
2180
+ return paneSession(this.raw, paneId);
2181
+ }
2182
+ /** The pids of the pane's foreground processes. */
2183
+ async paneProcesses(paneId) {
2184
+ const res = await this.call("pane.process_info", { pane_id: paneId });
2185
+ return (res?.process_info?.foreground_processes ?? []).map((p) => p.pid).filter(Number.isInteger);
2186
+ }
2187
+ async call(method, params) {
2188
+ try {
2189
+ return await this.request(method, params);
2190
+ } catch (err) {
2191
+ throw new Error(errorMessage(err));
2192
+ }
2193
+ }
2194
+ // ------------------------------------------------------------ lifecycle
2195
+ async connect() {
2196
+ this.retryTimer = null;
2197
+ if (this.stopped) return;
2198
+ try {
2199
+ if (this.ssh && this.tunnel && !fs$1.existsSync(this.socketPath)) this.closeTunnel();
2200
+ if (this.ssh && !this.tunnel) await this.openTunnel();
2201
+ this.subscribe();
2202
+ } catch (err) {
2203
+ this.fail(err.message);
2204
+ }
2205
+ }
2206
+ /** Mark the floor down and retry. A tunnel failure explains more than the connection drop it causes, so it wins. */
2207
+ fail(message, fromTunnel = false) {
2208
+ if (this.stopped) return;
2209
+ this.sub?.destroy();
2210
+ this.sub = null;
2211
+ if (this.retryTimer && !fromTunnel) return;
2212
+ this.setState("down", message);
2213
+ if (this.retryTimer) return;
2214
+ const wait = RETRY_MS[Math.min(this.attempt++, RETRY_MS.length - 1)];
2215
+ console.warn(`[bridge] ${this.label}: ${message}; retrying in ${wait / 1e3}s`);
2216
+ this.retryTimer = setTimeout(() => this.connect(), wait);
2217
+ }
2218
+ setState(state, message = "") {
2219
+ if (state === this.state && message === this.message) return;
2220
+ this.state = state;
2221
+ this.message = message;
2222
+ this.emit("status", this.info);
2223
+ }
2224
+ subscribe() {
2225
+ const sock = net.createConnection(this.socketPath);
2226
+ this.sub = sock;
2227
+ let buf = "";
2228
+ let answered = false;
2229
+ sock.setEncoding("utf8");
2230
+ sock.on("connect", () => {
2231
+ sock.write(`${JSON.stringify({ id: "office:sub", method: "events.subscribe", params: { subscriptions: SUBSCRIPTIONS } })}
2232
+ `);
2233
+ });
2234
+ sock.on("data", (chunk) => {
2235
+ if (!answered) {
2236
+ answered = true;
2237
+ console.log(`[bridge] ${this.label}: subscribed to herdr events`);
2238
+ this.scheduleRefresh();
2239
+ }
2240
+ buf += chunk;
2241
+ let nl;
2242
+ while ((nl = buf.indexOf("\n")) !== -1) {
2243
+ const line = buf.slice(0, nl).trim();
2244
+ buf = buf.slice(nl + 1);
2245
+ if (!line) continue;
2246
+ let msg;
2247
+ try {
2248
+ msg = JSON.parse(line);
2249
+ } catch {
2250
+ continue;
2251
+ }
2252
+ if (msg.error) {
2253
+ console.error(`[bridge] ${this.label}: subscribe error:`, msg.error);
2254
+ continue;
2255
+ }
2256
+ if (msg.event) this.scheduleRefresh();
2257
+ }
2258
+ });
2259
+ const lost = (why) => {
2260
+ if (this.sub === sock) this.fail(why);
2261
+ };
2262
+ sock.on(
2263
+ "error",
2264
+ (err) => lost(err.code === "ENOENT" || err.code === "ECONNREFUSED" ? `Herdr is not running (${err.code})` : err.message)
2265
+ );
2266
+ sock.on("close", () => lost(answered ? "Herdr closed the event stream" : `no reply from Herdr${this.ssh ? ` on ${this.ssh}` : ""}`));
2267
+ }
2268
+ scheduleRefresh() {
2269
+ clearTimeout(this.refreshTimer);
2270
+ this.refreshTimer = setTimeout(() => {
2271
+ this.refresh().catch((err) => console.error(`[bridge] ${this.label}: snapshot failed:`, err.message));
2272
+ }, SNAPSHOT_DEBOUNCE_MS);
2273
+ }
2274
+ /** Fetch a fresh `session.snapshot` now and emit it as a Kauak snapshot. */
2275
+ async refresh() {
2276
+ clearTimeout(this.refreshTimer);
2277
+ this.raw = (await this.request("session.snapshot")).snapshot;
2278
+ this.snapshot = toSnapshot(this.raw);
2279
+ this.attempt = 0;
2280
+ this.setState("live");
2281
+ this.emit("snapshot", this.snapshot);
2282
+ }
2283
+ // ------------------------------------------------------------ ssh tunnel
2284
+ async openTunnel() {
2285
+ fs$1.mkdirSync(this.settings.tunnelDir, { recursive: true, mode: 448 });
2286
+ fs$1.chmodSync(this.settings.tunnelDir, 448);
2287
+ const remote = this.remoteSocket ?? await this.probeRemoteSocket();
2288
+ try {
2289
+ fs$1.unlinkSync(this.socketPath);
2290
+ } catch {
2291
+ }
2292
+ const child = spawn(
2293
+ this.settings.sshCommand,
2294
+ [
2295
+ ...SSH_OPTS,
2296
+ "-o",
2297
+ "ControlPath=none",
2298
+ "-N",
2299
+ "-o",
2300
+ "ExitOnForwardFailure=yes",
2301
+ "-o",
2302
+ "StreamLocalBindUnlink=yes",
2303
+ "-L",
2304
+ `${this.socketPath}:${remote}`,
2305
+ "--",
2306
+ this.ssh
2307
+ ],
2308
+ { stdio: ["ignore", "ignore", "pipe"] }
2309
+ );
2310
+ this.tunnel = child;
2311
+ let stderr = "";
2312
+ child.stderr.setEncoding("utf8");
2313
+ child.stderr.on("data", (d) => {
2314
+ stderr = (stderr + d).slice(-2e3);
2315
+ });
2316
+ const why = (code, signal) => lastLine$1(stderr) || (signal ? `killed by ${signal}` : `connection closed (exit ${code})`);
2317
+ await new Promise((resolve, reject) => {
2318
+ const started = Date.now();
2319
+ let settled = false;
2320
+ const died = (message) => {
2321
+ if (settled) return;
2322
+ settled = true;
2323
+ clearInterval(poll);
2324
+ if (this.tunnel === child) this.closeTunnel();
2325
+ reject(new Error(message));
2326
+ };
2327
+ const poll = setInterval(() => {
2328
+ if (fs$1.existsSync(this.socketPath)) {
2329
+ settled = true;
2330
+ clearInterval(poll);
2331
+ resolve();
2332
+ } else if (Date.now() - started > TUNNEL_READY_MS) died("ssh: timed out opening the tunnel");
2333
+ }, 100);
2334
+ child.once("exit", (code, signal) => died(`ssh: ${why(code, signal)}`));
2335
+ child.once("error", (err) => died(`ssh: ${err.message}`));
2336
+ });
2337
+ console.log(`[bridge] ${this.label}: tunnel up (${this.socketPath} → ${this.ssh}:${remote})`);
2338
+ child.once("exit", (code, signal) => {
2339
+ if (this.tunnel !== child) return;
2340
+ this.tunnel = null;
2341
+ this.fail(`ssh tunnel closed: ${why(code, signal)}`, true);
2342
+ });
2343
+ }
2344
+ closeTunnel() {
2345
+ const child = this.tunnel;
2346
+ this.tunnel = null;
2347
+ child?.kill();
2348
+ if (this.ssh)
2349
+ try {
2350
+ fs$1.unlinkSync(this.socketPath);
2351
+ } catch {
2352
+ }
2353
+ }
2354
+ probeRemoteSocket() {
2355
+ return new Promise((resolve, reject) => {
2356
+ execFile(
2357
+ this.settings.sshCommand,
2358
+ [...SSH_OPTS, "--", this.ssh, REMOTE_PROBE],
2359
+ { timeout: TUNNEL_READY_MS },
2360
+ (err, stdout, stderr) => {
2361
+ if (!err) return resolve(stdout.trim());
2362
+ if (err.code === 3) return reject(new Error(`Herdr is not running on ${this.ssh} (no socket at ${stdout.trim()})`));
2363
+ reject(new Error(`ssh: ${lastLine$1(stderr) || err.message}`));
2364
+ }
2365
+ );
2366
+ });
2367
+ }
2368
+ }
2369
+ function createBridge(config, deps = {}) {
2370
+ const bridge = new Bridge(config, { ...herdrBridge(config), ...deps });
2371
+ const floors = bridge.machines;
2372
+ return { listen: () => bridge.listen(), close: () => bridge.close(), floors };
2373
+ }
2374
+ function herdrBridge(config) {
2375
+ return {
2376
+ runtime: (floor) => new Machine(floor, config),
2377
+ enrichers: (floor) => {
2378
+ const context = new ContextTracker(floor, config);
2379
+ const diffs = new DiffTracker(floor, config);
2380
+ return { enrichers: [context, diffs], printers: diffs };
2381
+ },
2382
+ commands: (agent, cwd, local) => slashCommands(agent, cwd, local, config)
2383
+ };
2384
+ }
2385
+ export {
2386
+ createBridge
2387
+ };