@tarfa/cli 0.0.0 → 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.
package/dist/serve.js ADDED
@@ -0,0 +1,4293 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ CodexAppServer,
4
+ CodexAppServerDown,
5
+ DEVICE_REFUSED,
6
+ FileClaudeSessionsStore,
7
+ FileDeviceStore,
8
+ FileHiddenFoldersStore,
9
+ FilePairingStore,
10
+ FileParkedStore,
11
+ FileRecentStore,
12
+ FileStatusStore,
13
+ FsFileWriter,
14
+ FsProjectLister,
15
+ GitRepoRemote,
16
+ IN_BAND_REFUSAL,
17
+ IkResponder,
18
+ KNOWN_CLIS,
19
+ MAX_FRAME_BYTES,
20
+ MAX_MESSAGE_BYTES,
21
+ PROTOCOL_VERSION,
22
+ REFUSAL,
23
+ SecureSession,
24
+ ShortWorkspaceIds,
25
+ TmuxAdapter,
26
+ WrongStaticKeyError,
27
+ assertServerConfig,
28
+ audioMediaType,
29
+ b64url,
30
+ browseDir,
31
+ childEnv,
32
+ cliOfCommand,
33
+ configWarnings,
34
+ constantTimeEqual,
35
+ createWorkspace,
36
+ deriveWorkspaceName,
37
+ detectInstalled,
38
+ deviceFingerprint,
39
+ enrolDevice,
40
+ enrolmentMode,
41
+ exactTarget,
42
+ formatAgentMessage,
43
+ fsIsDirectory,
44
+ getRecentPaths,
45
+ handshakeFrame,
46
+ handshakePattern,
47
+ imageMediaType,
48
+ isEntrypoint,
49
+ isHiddenPath,
50
+ isNoDaemonCodex,
51
+ isWorkspaceStatus,
52
+ listWorkspaces,
53
+ loadConfig,
54
+ loadOrCreateAgentKeys,
55
+ locateAgentEnv,
56
+ loginShellResolver,
57
+ mayEnrol,
58
+ migrateStateDir,
59
+ packageVersion,
60
+ parseBrowserAgentMessage,
61
+ parseBrowserClientMessage,
62
+ parseClientMessage,
63
+ parseDeviceHello,
64
+ pathWithinRoots,
65
+ pdfMediaType,
66
+ readHandshakeFrame,
67
+ redeemPairing,
68
+ relationOf,
69
+ runsClaude,
70
+ sessionOfCodexThread,
71
+ standingOf,
72
+ startTimer,
73
+ systemClock,
74
+ systemPathResolver,
75
+ withLegacyEnv,
76
+ writeFileAtomic
77
+ } from "./chunk-LXIGP77E.js";
78
+
79
+ // src/serve.ts
80
+ import { homedir as homedir2 } from "node:os";
81
+ import { join as join7 } from "node:path";
82
+
83
+ // src/application/claude-voice.ts
84
+ var HOLD_REPEAT_MS = 30;
85
+ var LEAD_MS = 1e3;
86
+ var TAIL_MS = 500;
87
+ var IDLE_MS = 5e3;
88
+ var MAX_MS = 5 * 6e4;
89
+ var DRAIN_MS = LEAD_MS + 1e4;
90
+ function voiceSettingsProblem(settings) {
91
+ const s = typeof settings === "object" && settings !== null ? settings : {};
92
+ const enabled = s.voice?.enabled ?? s.voiceEnabled;
93
+ if (enabled !== true) return "Voice is off in Claude Code on this machine: run /voice in a workspace first";
94
+ if (s.voice?.mode !== void 0 && s.voice.mode !== "hold") {
95
+ return "Claude voice needs Claude Code\u2019s voice mode set to hold (/config)";
96
+ }
97
+ return null;
98
+ }
99
+ var errorText = (e) => e instanceof Error ? e.message : String(e);
100
+ var Dictation = class {
101
+ constructor(deps, pressSpace, release) {
102
+ this.deps = deps;
103
+ this.pressSpace = pressSpace;
104
+ this.release = release;
105
+ this.done = new Promise((resolve) => this.resolve = resolve);
106
+ this.max = setTimeout(() => this.abort("Dictation stopped: it ran past 5 minutes"), MAX_MS);
107
+ this.touch();
108
+ void this.begin();
109
+ }
110
+ deps;
111
+ pressSpace;
112
+ release;
113
+ done;
114
+ resolve;
115
+ queue = [];
116
+ stream = null;
117
+ ended = false;
118
+ finished = false;
119
+ hold;
120
+ lead;
121
+ idle;
122
+ draining;
123
+ max;
124
+ write(pcm) {
125
+ if (this.finished || this.ended) return;
126
+ this.touch();
127
+ if (this.stream) this.stream.write(pcm);
128
+ else this.queue.push(pcm);
129
+ }
130
+ end() {
131
+ if (this.finished || this.ended) return;
132
+ this.ended = true;
133
+ clearTimeout(this.idle);
134
+ if (this.stream) this.drain(this.stream);
135
+ }
136
+ abort(reason) {
137
+ this.stream?.abort();
138
+ this.finish(reason);
139
+ }
140
+ async begin() {
141
+ const problem = voiceSettingsProblem(await this.deps.settings.read().catch(() => null));
142
+ if (this.finished) return;
143
+ if (problem) return this.finish(problem);
144
+ const press = () => {
145
+ try {
146
+ this.pressSpace();
147
+ } catch {
148
+ }
149
+ };
150
+ this.hold = setInterval(press, HOLD_REPEAT_MS);
151
+ press();
152
+ this.lead = setTimeout(() => this.play(), LEAD_MS);
153
+ }
154
+ play() {
155
+ if (this.finished) return;
156
+ let stream;
157
+ try {
158
+ stream = this.deps.player.open();
159
+ } catch (e) {
160
+ return this.finish(errorText(e));
161
+ }
162
+ this.stream = stream;
163
+ stream.played.catch((e) => this.finish(errorText(e)));
164
+ for (const pcm of this.queue.splice(0)) stream.write(pcm);
165
+ if (this.ended) this.drain(stream);
166
+ }
167
+ drain(stream) {
168
+ this.draining = setTimeout(() => this.abort("Dictation stopped: playback did not finish"), DRAIN_MS);
169
+ void stream.end().then(
170
+ () => setTimeout(() => this.finish(null), TAIL_MS),
171
+ (e) => this.finish(errorText(e))
172
+ );
173
+ }
174
+ touch() {
175
+ clearTimeout(this.idle);
176
+ this.idle = setTimeout(() => this.abort("Dictation stopped: the phone stopped sending audio"), IDLE_MS);
177
+ }
178
+ finish(result) {
179
+ if (this.finished) return;
180
+ this.finished = true;
181
+ clearInterval(this.hold);
182
+ clearTimeout(this.lead);
183
+ clearTimeout(this.idle);
184
+ clearTimeout(this.draining);
185
+ clearTimeout(this.max);
186
+ this.queue = [];
187
+ this.release();
188
+ this.resolve(result);
189
+ }
190
+ };
191
+ var ClaudeVoice = class {
192
+ constructor(deps) {
193
+ this.deps = deps;
194
+ }
195
+ deps;
196
+ busy = false;
197
+ available() {
198
+ return this.deps.player.available();
199
+ }
200
+ start(pressSpace) {
201
+ if (this.busy) {
202
+ const done = Promise.resolve("Claude voice is already in use on this machine");
203
+ return { write: () => void 0, end: () => void 0, abort: () => void 0, done };
204
+ }
205
+ this.busy = true;
206
+ return new Dictation(this.deps, pressSpace, () => this.busy = false);
207
+ }
208
+ };
209
+
210
+ // src/infrastructure/aplay-voice-player.ts
211
+ import { spawn } from "node:child_process";
212
+ import { readFile } from "node:fs/promises";
213
+ var LOOPBACK_DEVICE = "plughw:Loopback,0";
214
+ var aplayArgs = (device) => ["-q", "-D", device, "-t", "raw", "-f", "S16_LE", "-c", "1", "-r", "16000", "-"];
215
+ var APLAY_ARGS = aplayArgs(LOOPBACK_DEVICE);
216
+ var AplayVoicePlayer = class {
217
+ constructor(command = "aplay", args = APLAY_ARGS, cardsPath = "/proc/asound/cards") {
218
+ this.command = command;
219
+ this.args = args;
220
+ this.cardsPath = cardsPath;
221
+ }
222
+ command;
223
+ args;
224
+ cardsPath;
225
+ // Whether snd-aloop is loaded: without it there is no virtual mic to play into.
226
+ async available() {
227
+ try {
228
+ return /\[Loopback\s*\]/.test(await readFile(this.cardsPath, "utf8"));
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+ open() {
234
+ const child = spawn(this.command, this.args, { env: childEnv(), stdio: ["pipe", "ignore", "pipe"] });
235
+ let stderr = "";
236
+ child.stderr.on("data", (d) => {
237
+ stderr = (stderr + d.toString()).slice(-2e3);
238
+ });
239
+ const failure = () => stderr.split("\n").filter((l) => l.trim() && !l.includes("underrun")).at(-1)?.trim();
240
+ const played = new Promise((resolve, reject) => {
241
+ child.on("error", (e) => reject(new Error(`aplay: ${e.message}`)));
242
+ child.on(
243
+ "close",
244
+ (code, signal) => code === 0 ? resolve() : reject(new Error(`aplay: ${failure() ?? `exited with ${code ?? signal}`}`))
245
+ );
246
+ });
247
+ played.catch(() => void 0);
248
+ child.stdin.on("error", () => void 0);
249
+ return {
250
+ played,
251
+ write: (pcm) => {
252
+ if (child.stdin.writable) child.stdin.write(pcm);
253
+ },
254
+ end: () => {
255
+ child.stdin.end();
256
+ return played;
257
+ },
258
+ // Not SIGTERM: aplay's handler only flags the abort and goes back to reading stdin,
259
+ // keeping the device open, so every later dictation failed "Device or resource busy".
260
+ // Nothing is left to flush on an abort, and the kernel frees the device on exit.
261
+ abort: () => {
262
+ child.stdin.destroy();
263
+ child.kill("SIGKILL");
264
+ }
265
+ };
266
+ }
267
+ };
268
+ var FileClaudeSettings = class {
269
+ constructor(path) {
270
+ this.path = path;
271
+ }
272
+ path;
273
+ async read() {
274
+ try {
275
+ return JSON.parse(await readFile(this.path, "utf8"));
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+ };
281
+
282
+ // src/infrastructure/file-launchers-store.ts
283
+ import { readFile as readFile2, mkdir } from "node:fs/promises";
284
+ import { dirname } from "node:path";
285
+ var MAX_LAUNCHERS = 20;
286
+ var FileLaunchersStore = class {
287
+ constructor(filePath) {
288
+ this.filePath = filePath;
289
+ }
290
+ filePath;
291
+ // Handlers run concurrently and each write is read-modify-write, so they are queued. One
292
+ // process owns the file, hence no file lock.
293
+ queue = Promise.resolve();
294
+ async list() {
295
+ let parsed;
296
+ try {
297
+ parsed = JSON.parse(await readFile2(this.filePath, "utf8"));
298
+ } catch {
299
+ return [];
300
+ }
301
+ return Array.isArray(parsed) ? [...new Set(parsed.filter((c) => typeof c === "string" && c !== ""))] : [];
302
+ }
303
+ remember(command) {
304
+ return this.serialized(async () => {
305
+ const all = await this.list();
306
+ if (all.includes(command)) return;
307
+ await this.persist([...all, command].slice(-MAX_LAUNCHERS));
308
+ });
309
+ }
310
+ forget(command) {
311
+ return this.serialized(async () => {
312
+ const all = await this.list();
313
+ const rest = all.filter((c) => c !== command);
314
+ if (rest.length !== all.length) await this.persist(rest);
315
+ });
316
+ }
317
+ serialized(task) {
318
+ const run = this.queue.then(task);
319
+ this.queue = run.catch(() => void 0);
320
+ return run;
321
+ }
322
+ async persist(commands) {
323
+ await mkdir(dirname(this.filePath), { recursive: true });
324
+ await writeFileAtomic(this.filePath, JSON.stringify(commands, null, 2));
325
+ }
326
+ };
327
+
328
+ // src/infrastructure/file-keyterms-store.ts
329
+ import { readFile as readFile3 } from "node:fs/promises";
330
+
331
+ // src/application/parse-keyterms.ts
332
+ var MAX_KEYTERMS = 100;
333
+ var MAX_TERM_LENGTH = 50;
334
+ var MAX_TERM_WORDS = 5;
335
+ var FORBIDDEN = /[<>{}[\]\\]/;
336
+ function parseKeyterms(raw) {
337
+ const seen = /* @__PURE__ */ new Set();
338
+ const terms = [];
339
+ for (const line of raw.split(/\r?\n/)) {
340
+ const term = line.trim();
341
+ if (term.length === 0 || term.startsWith("#")) continue;
342
+ if (term.length > MAX_TERM_LENGTH || FORBIDDEN.test(term)) continue;
343
+ if (term.split(/\s+/).length > MAX_TERM_WORDS) continue;
344
+ const key = term.toLowerCase();
345
+ if (seen.has(key)) continue;
346
+ seen.add(key);
347
+ terms.push(term);
348
+ if (terms.length === MAX_KEYTERMS) break;
349
+ }
350
+ return terms;
351
+ }
352
+
353
+ // src/infrastructure/file-keyterms-store.ts
354
+ var FileKeytermsStore = class {
355
+ constructor(filePath) {
356
+ this.filePath = filePath;
357
+ }
358
+ filePath;
359
+ async list() {
360
+ try {
361
+ return parseKeyterms(await readFile3(this.filePath, "utf8"));
362
+ } catch {
363
+ return [];
364
+ }
365
+ }
366
+ };
367
+
368
+ // src/infrastructure/proc-system-stats.ts
369
+ import { readFile as readFile4 } from "node:fs/promises";
370
+ var CGROUP_ROOT = "/sys/fs/cgroup";
371
+ var readText = (path) => readFile4(path, "utf8");
372
+ function parseCpuSample(stat5) {
373
+ const line = stat5.split("\n").find((l) => l.startsWith("cpu "));
374
+ if (!line) return null;
375
+ const fields = line.trim().split(/\s+/).slice(1).map(Number);
376
+ if (fields.length < 4 || fields.some(Number.isNaN)) return null;
377
+ const total = fields.reduce((sum, n) => sum + n, 0);
378
+ const idle = (fields[3] ?? 0) + (fields[4] ?? 0);
379
+ return { busy: total - idle, total };
380
+ }
381
+ function parseMeminfo(meminfo) {
382
+ const values = /* @__PURE__ */ new Map();
383
+ for (const line of meminfo.split("\n")) {
384
+ const match = /^(\w+):\s+(\d+) kB$/.exec(line.trim());
385
+ if (match?.[1]) values.set(match[1], Number(match[2]) * 1024);
386
+ }
387
+ return values;
388
+ }
389
+ function parseStall(pressure) {
390
+ const match = /^some\s+avg10=([\d.]+)/m.exec(pressure);
391
+ return match ? Number(match[1]) : void 0;
392
+ }
393
+ function parseKeyedCounters(raw) {
394
+ const values = /* @__PURE__ */ new Map();
395
+ for (const line of raw.split("\n")) {
396
+ const [key, value] = line.trim().split(/\s+/);
397
+ if (key && value !== void 0 && /^\d+$/.test(value)) values.set(key, Number(value));
398
+ }
399
+ return values;
400
+ }
401
+ function parseLimit(raw) {
402
+ const value = raw.trim();
403
+ if (value === "max") return null;
404
+ const bytes = Number(value);
405
+ return Number.isFinite(bytes) ? bytes : null;
406
+ }
407
+ var ProcSystemStats = class {
408
+ constructor(read_ = readText, platform = process.platform) {
409
+ this.read_ = read_;
410
+ this.platform = platform;
411
+ }
412
+ read_;
413
+ platform;
414
+ previous = null;
415
+ async read() {
416
+ if (this.platform !== "linux") return null;
417
+ const cpuPercent = await this.sampleCpu();
418
+ if (cpuPercent === null) return null;
419
+ let meminfo;
420
+ try {
421
+ meminfo = parseMeminfo(await this.read_("/proc/meminfo"));
422
+ } catch {
423
+ return null;
424
+ }
425
+ const total = meminfo.get("MemTotal");
426
+ const available = meminfo.get("MemAvailable");
427
+ if (total === void 0 || available === void 0) return null;
428
+ const swapTotal = meminfo.get("SwapTotal") ?? 0;
429
+ const swapFree = meminfo.get("SwapFree") ?? 0;
430
+ return {
431
+ cpuPercent,
432
+ memory: { usedBytes: total - available, totalBytes: total },
433
+ swapUsedBytes: swapTotal - swapFree,
434
+ ...await this.readLimit() ?? {}
435
+ };
436
+ }
437
+ // CPU usage is a delta, so the first sample only primes the next one.
438
+ async sampleCpu() {
439
+ let sample;
440
+ try {
441
+ sample = parseCpuSample(await this.read_("/proc/stat"));
442
+ } catch {
443
+ return null;
444
+ }
445
+ if (!sample) return null;
446
+ const previous = this.previous;
447
+ this.previous = sample;
448
+ if (!previous) return null;
449
+ const window = sample.total - previous.total;
450
+ if (window <= 0) return null;
451
+ return (sample.busy - previous.busy) / window * 100;
452
+ }
453
+ async readLimit() {
454
+ let cgroup;
455
+ try {
456
+ cgroup = (await this.read_("/proc/self/cgroup")).trim().replace(/^0::/, "");
457
+ } catch {
458
+ return null;
459
+ }
460
+ const segments = cgroup.split("/").filter(Boolean);
461
+ for (let depth = segments.length; depth >= 0; depth -= 1) {
462
+ const dir = [CGROUP_ROOT, ...segments.slice(0, depth)].join("/");
463
+ const [high, max] = await Promise.all([
464
+ this.readNumber(`${dir}/memory.high`),
465
+ this.readNumber(`${dir}/memory.max`)
466
+ ]);
467
+ const totalBytes = Math.min(...[high, max].filter((n) => n !== null));
468
+ if (!Number.isFinite(totalBytes)) continue;
469
+ const usedBytes = await this.readNumber(`${dir}/memory.current`);
470
+ if (usedBytes === null) continue;
471
+ const stallPercent = await this.readStall(`${dir}/memory.pressure`);
472
+ const reclaimableBytes = await this.readReclaimable(`${dir}/memory.stat`);
473
+ const oomKills = await this.readCounter(`${dir}/memory.events`, "oom_kill");
474
+ return {
475
+ limit: {
476
+ usedBytes,
477
+ totalBytes,
478
+ ...stallPercent !== void 0 && { stallPercent },
479
+ // Only a real byte value is a kill line; an uncapped cgroup reads the literal
480
+ // "max", and reporting that as a ceiling would make the client's headroom fiction.
481
+ ...max !== null && { maxBytes: max },
482
+ ...reclaimableBytes !== void 0 && { reclaimableBytes },
483
+ ...oomKills !== void 0 && { oomKills }
484
+ }
485
+ };
486
+ }
487
+ return null;
488
+ }
489
+ // Cache and reclaimable slab, less mapped file pages: those are reclaimable in principle
490
+ // but are the working set of running programs, so counting them as free overstates what a
491
+ // new task can actually take.
492
+ async readReclaimable(path) {
493
+ let counters;
494
+ try {
495
+ counters = parseKeyedCounters(await this.read_(path));
496
+ } catch {
497
+ return void 0;
498
+ }
499
+ const file = counters.get("file");
500
+ const slab = counters.get("slab_reclaimable");
501
+ if (file === void 0 || slab === void 0) return void 0;
502
+ return Math.max(0, file - (counters.get("file_mapped") ?? 0) + slab);
503
+ }
504
+ async readCounter(path, key) {
505
+ try {
506
+ return parseKeyedCounters(await this.read_(path)).get(key);
507
+ } catch {
508
+ return void 0;
509
+ }
510
+ }
511
+ async readNumber(path) {
512
+ try {
513
+ return parseLimit(await this.read_(path));
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+ async readStall(path) {
519
+ try {
520
+ return parseStall(await this.read_(path));
521
+ } catch {
522
+ return void 0;
523
+ }
524
+ }
525
+ };
526
+
527
+ // src/infrastructure/systemd-scope-reaper.ts
528
+ import { execFile } from "node:child_process";
529
+ import { readFile as readFile5 } from "node:fs/promises";
530
+ import { promisify } from "node:util";
531
+ var exec = promisify(execFile);
532
+ var PANE_SCOPE = /^tmux-spawn-[0-9a-f-]+\.scope$/;
533
+ var readProcCgroup = (pid) => readFile5(`/proc/${pid}/cgroup`, "utf8");
534
+ var execFileRunner = async (cmd, args) => {
535
+ await exec(cmd, args, { env: childEnv() });
536
+ };
537
+ function isPaneScope(name) {
538
+ return PANE_SCOPE.test(name);
539
+ }
540
+ var SystemdScopeReaper = class {
541
+ constructor(readCgroup = readProcCgroup, run = execFileRunner) {
542
+ this.readCgroup = readCgroup;
543
+ this.run = run;
544
+ }
545
+ readCgroup;
546
+ run;
547
+ async scopeForPid(pid) {
548
+ let cgroup;
549
+ try {
550
+ cgroup = await this.readCgroup(pid);
551
+ } catch {
552
+ return null;
553
+ }
554
+ const leaf = cgroup.trim().split("/").pop() ?? "";
555
+ return isPaneScope(leaf) ? leaf : null;
556
+ }
557
+ async stopScope(name) {
558
+ if (!isPaneScope(name)) {
559
+ throw new Error(`refusing to stop non-pane unit: ${name}`);
560
+ }
561
+ await this.run("systemctl", ["--user", "stop", name]);
562
+ }
563
+ };
564
+
565
+ // src/infrastructure/file-client-log-store.ts
566
+ import { appendFile, mkdir as mkdir2 } from "node:fs/promises";
567
+ import { dirname as dirname2 } from "node:path";
568
+ var FileClientLogStore = class {
569
+ constructor(filePath, now = () => Date.now()) {
570
+ this.filePath = filePath;
571
+ this.now = now;
572
+ }
573
+ filePath;
574
+ now;
575
+ async append(batch) {
576
+ const rt = this.now();
577
+ const lines = batch.events.map(
578
+ (event) => JSON.stringify({ ...event, ua: batch.ua, shell: batch.shell, rt }) + "\n"
579
+ );
580
+ await mkdir2(dirname2(this.filePath), { recursive: true });
581
+ await appendFile(this.filePath, lines.join(""), "utf8");
582
+ }
583
+ };
584
+
585
+ // src/infrastructure/file-hook-event-log.ts
586
+ import { appendFile as appendFile2, mkdir as mkdir3, rename, stat } from "node:fs/promises";
587
+ import { dirname as dirname3 } from "node:path";
588
+ var FileHookEventLog = class {
589
+ constructor(filePath, maxBytes = 50 * 1024 * 1024, now = () => Date.now()) {
590
+ this.filePath = filePath;
591
+ this.maxBytes = maxBytes;
592
+ this.now = now;
593
+ }
594
+ filePath;
595
+ maxBytes;
596
+ now;
597
+ async append(entry) {
598
+ await mkdir3(dirname3(this.filePath), { recursive: true });
599
+ const size = await stat(this.filePath).then(
600
+ (s) => s.size,
601
+ () => 0
602
+ );
603
+ if (size > this.maxBytes) await rename(this.filePath, `${this.filePath}.1`);
604
+ const line = JSON.stringify({ ts: new Date(this.now()).toISOString(), ...entry }) + "\n";
605
+ await appendFile2(this.filePath, line, "utf8");
606
+ }
607
+ };
608
+
609
+ // src/infrastructure/file-message-log.ts
610
+ import { appendFile as appendFile3, mkdir as mkdir4, rename as rename2, stat as stat2 } from "node:fs/promises";
611
+ import { dirname as dirname4 } from "node:path";
612
+ var FileMessageLog = class {
613
+ constructor(filePath, maxBytes = 50 * 1024 * 1024) {
614
+ this.filePath = filePath;
615
+ this.maxBytes = maxBytes;
616
+ }
617
+ filePath;
618
+ maxBytes;
619
+ async append(entry) {
620
+ await mkdir4(dirname4(this.filePath), { recursive: true });
621
+ const size = await stat2(this.filePath).then(
622
+ (s) => s.size,
623
+ () => 0
624
+ );
625
+ if (size > this.maxBytes) await rename2(this.filePath, `${this.filePath}.1`);
626
+ await appendFile3(this.filePath, JSON.stringify(entry) + "\n", "utf8");
627
+ }
628
+ };
629
+
630
+ // src/infrastructure/proc-ancestry.ts
631
+ import { execFile as execFile2 } from "node:child_process";
632
+ import { readFile as readFile6 } from "node:fs/promises";
633
+ import { promisify as promisify2 } from "node:util";
634
+ var exec2 = promisify2(execFile2);
635
+ var MAX_GENERATIONS = 64;
636
+ async function procParentOf(pid, read = (path) => readFile6(path, "utf8")) {
637
+ try {
638
+ const stat5 = await read(`/proc/${pid}/stat`);
639
+ const ppid = Number(stat5.slice(stat5.lastIndexOf(")") + 1).trim().split(/\s+/)[1]);
640
+ return Number.isInteger(ppid) ? ppid : null;
641
+ } catch {
642
+ return null;
643
+ }
644
+ }
645
+ async function psParentOf(pid) {
646
+ try {
647
+ const { stdout } = await exec2("ps", ["-o", "ppid=", "-p", String(pid)], { env: childEnv() });
648
+ const ppid = Number(stdout.trim());
649
+ return stdout.trim() !== "" && Number.isInteger(ppid) ? ppid : null;
650
+ } catch {
651
+ return null;
652
+ }
653
+ }
654
+ var ProcAncestry = class {
655
+ constructor(parentOf = process.platform === "linux" ? procParentOf : psParentOf) {
656
+ this.parentOf = parentOf;
657
+ }
658
+ parentOf;
659
+ async isAncestor(ancestorPid, pid) {
660
+ let current = pid;
661
+ for (let step = 0; step < MAX_GENERATIONS && current > 1; step++) {
662
+ const parent = await this.parentOf(current);
663
+ if (parent === null) return false;
664
+ if (parent === ancestorPid) return true;
665
+ current = parent;
666
+ }
667
+ return false;
668
+ }
669
+ };
670
+
671
+ // src/infrastructure/composite-delivery.ts
672
+ var CompositeDelivery = class {
673
+ constructor(byCli) {
674
+ this.byCli = byCli;
675
+ }
676
+ byCli;
677
+ async deliver(target, text) {
678
+ const delivery = this.byCli[target.cli];
679
+ if (!delivery) return { ok: false, reason: `no delivery for ${target.cli} on this agent yet` };
680
+ try {
681
+ return await delivery.deliver(target, text);
682
+ } catch (err) {
683
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
684
+ }
685
+ }
686
+ };
687
+
688
+ // src/infrastructure/claude-inbox-delivery.ts
689
+ import { randomUUID } from "node:crypto";
690
+ import { connect } from "node:net";
691
+ var PINNED_PEER_PROTOCOL = 1;
692
+ var TIMEOUT_MS = 5e3;
693
+ var MAX_FRAME_BYTES2 = 1e6;
694
+ var ClaudeInboxDelivery = class {
695
+ constructor(sessions, firstPane = async () => null) {
696
+ this.sessions = sessions;
697
+ this.firstPane = firstPane;
698
+ }
699
+ sessions;
700
+ firstPane;
701
+ async deliver(target, text) {
702
+ const inbox = await this.sessions.inboxOf(target.id, await this.firstPane(target.id));
703
+ if (!inbox) return { ok: false, reason: `no live Claude session in ${target.id}` };
704
+ if (inbox === "ambiguous") return { ok: false, reason: `several Claude sessions in ${target.id}; retry in a few seconds` };
705
+ if (inbox.peerProtocol !== PINNED_PEER_PROTOCOL) {
706
+ return {
707
+ ok: false,
708
+ reason: `Claude peer protocol ${inbox.peerProtocol} is not supported (expected ${PINNED_PEER_PROTOCOL})`
709
+ };
710
+ }
711
+ const fromName = target.from.replace(/["<>]/g, "");
712
+ const frame = JSON.stringify({
713
+ msgV: 1,
714
+ msg_id: randomUUID(),
715
+ type: "user",
716
+ message: {
717
+ role: "user",
718
+ // A closing tag inside the text would end the envelope early, and an opening one would
719
+ // let the rest pose as a second message from another sender: both are escaped.
720
+ content: `<cross-session-message from-name="${fromName}">
721
+ ${text.replace(/<(\/?cross-session-message)/gi, "&lt;$1")}
722
+ </cross-session-message>`
723
+ },
724
+ priority: "next"
725
+ });
726
+ const bytes = Buffer.byteLength(frame, "utf8");
727
+ if (bytes > MAX_FRAME_BYTES2) return { ok: false, reason: `message too large for Claude (${bytes} bytes)` };
728
+ const auth = inbox.token === void 0 ? "" : `${JSON.stringify({ type: "auth", token: inbox.token })}
729
+ `;
730
+ return write(inbox.socketPath, `${auth}${frame}
731
+ `, target.id);
732
+ }
733
+ };
734
+ function write(socketPath, payload, id) {
735
+ return new Promise((resolve) => {
736
+ const socket = connect(socketPath, () => socket.end(payload));
737
+ const settle = (result) => {
738
+ clearTimeout(timer);
739
+ socket.destroy();
740
+ resolve(result);
741
+ };
742
+ const timer = setTimeout(() => settle({ ok: false, reason: "the Claude inbox did not answer in 5 s" }), TIMEOUT_MS);
743
+ socket.on("error", (err) => {
744
+ const unreachable = err.code === "ENOENT" || err.code === "ECONNREFUSED";
745
+ settle({
746
+ ok: false,
747
+ reason: unreachable ? `the Claude inbox of ${id} is not reachable` : `the Claude inbox of ${id} failed: ${err.message}`
748
+ });
749
+ });
750
+ socket.on("close", (hadError) => {
751
+ if (!hadError) settle({ ok: true });
752
+ });
753
+ });
754
+ }
755
+
756
+ // src/infrastructure/pi-inbox-delivery.ts
757
+ import { connect as connect2 } from "node:net";
758
+ import { join } from "node:path";
759
+ var TIMEOUT_MS2 = 5e3;
760
+ var PiInboxDelivery = class {
761
+ constructor(inboxDir) {
762
+ this.inboxDir = inboxDir;
763
+ }
764
+ inboxDir;
765
+ async deliver(target, text) {
766
+ if (target.id.includes("/") || target.id.startsWith(".")) {
767
+ return { ok: false, reason: `the Pi inbox of ${target.id} failed: not a workspace id` };
768
+ }
769
+ return exchange(join(this.inboxDir, `${target.id}.sock`), `${JSON.stringify({ v: 1, text })}
770
+ `, target.id);
771
+ }
772
+ };
773
+ function exchange(socketPath, line, id) {
774
+ return new Promise((resolve) => {
775
+ let answer = "";
776
+ let settled = false;
777
+ const socket = connect2(socketPath, () => socket.write(line));
778
+ socket.setEncoding("utf8");
779
+ const settle = (result) => {
780
+ if (settled) return;
781
+ settled = true;
782
+ clearTimeout(timer);
783
+ socket.destroy();
784
+ resolve(result);
785
+ };
786
+ const failed = (message) => settle({ ok: false, reason: `the Pi inbox of ${id} failed: ${message}` });
787
+ const timer = setTimeout(() => settle({ ok: false, reason: "the Pi inbox did not answer in 5 s" }), TIMEOUT_MS2);
788
+ socket.on("data", (d) => {
789
+ answer += d;
790
+ const end = answer.indexOf("\n");
791
+ if (end < 0) return;
792
+ let reply;
793
+ try {
794
+ reply = JSON.parse(answer.slice(0, end));
795
+ } catch {
796
+ return failed("unexpected answer");
797
+ }
798
+ const r = reply;
799
+ if (r?.ok === true) return settle({ ok: true });
800
+ if (r?.ok === false) return settle({ ok: false, reason: `the Pi inbox refused: ${String(r.error ?? "no reason")}` });
801
+ failed("unexpected answer");
802
+ });
803
+ socket.on("error", (err) => {
804
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") return settle({ ok: false, reason: "Pi without the Tarfa inbox" });
805
+ failed(err.message);
806
+ });
807
+ socket.on("close", () => failed("closed without answering"));
808
+ });
809
+ }
810
+
811
+ // src/infrastructure/codex-delivery.ts
812
+ var RACE = /^(expected active turn id |no active turn to steer)/;
813
+ var NoTurnToSteer = class extends Error {
814
+ constructor() {
815
+ super("the thread is busy with no turn to steer");
816
+ }
817
+ };
818
+ var CodexDelivery = class {
819
+ constructor(codex) {
820
+ this.codex = codex;
821
+ }
822
+ codex;
823
+ async deliver(target, text) {
824
+ const thread = target.codexThread;
825
+ if (!thread) {
826
+ return {
827
+ ok: false,
828
+ reason: `no Codex thread recorded for ${target.id} (only tarfa spawn --cli codex workspaces take messages)`
829
+ };
830
+ }
831
+ try {
832
+ if (!(await this.codex.loadedThreads()).includes(thread)) {
833
+ return { ok: false, reason: `the Codex thread of ${target.id} is not open` };
834
+ }
835
+ try {
836
+ await this.post(thread, text);
837
+ } catch (err) {
838
+ if (!(err instanceof NoTurnToSteer) && !RACE.test(err.message)) throw err;
839
+ await this.post(thread, text);
840
+ }
841
+ return { ok: true };
842
+ } catch (err) {
843
+ if (err instanceof CodexAppServerDown) return { ok: false, reason: err.message };
844
+ return { ok: false, reason: `the Codex app server failed: ${err.message}` };
845
+ }
846
+ }
847
+ async post(thread, text) {
848
+ const state2 = await this.codex.threadState(thread);
849
+ if (state2.status === "idle") return this.codex.startTurn(thread, text);
850
+ if (!state2.activeTurnId) throw new NoTurnToSteer();
851
+ return this.codex.steer(thread, text, state2.activeTurnId);
852
+ }
853
+ };
854
+
855
+ // src/infrastructure/file-workspace-log-store.ts
856
+ import { appendFile as appendFile4, mkdir as mkdir5 } from "node:fs/promises";
857
+ import { dirname as dirname5 } from "node:path";
858
+ var FileWorkspaceLogStore = class {
859
+ constructor(filePath) {
860
+ this.filePath = filePath;
861
+ }
862
+ filePath;
863
+ async append(entry) {
864
+ await mkdir5(dirname5(this.filePath), { recursive: true });
865
+ await appendFile4(this.filePath, JSON.stringify(entry) + "\n", "utf8");
866
+ }
867
+ };
868
+
869
+ // src/application/track-live-workspaces.ts
870
+ async function trackLiveWorkspaces(deps) {
871
+ if (!deps.tmux.serverId) return [];
872
+ const serverId = await deps.tmux.serverId();
873
+ const sessions = await deps.tmux.listSessions(deps.sessionPrefix);
874
+ const parkedIds = new Set((await deps.parked.list()).map((p) => p.id));
875
+ const previous = await deps.snapshot.read();
876
+ const parkedNow = [];
877
+ if (previous && previous.serverId !== serverId) {
878
+ const survivors = new Set(sessions.filter((s) => s.command.trim().length > 0).map((s) => s.name));
879
+ for (const entry of previous.workspaces) {
880
+ if (survivors.has(entry.id) || parkedIds.has(entry.id)) continue;
881
+ const claudeSessionId = deps.status.getClaudeSessionId(entry.id) ?? entry.claudeSessionId;
882
+ const now = deps.clock.now();
883
+ try {
884
+ await deps.workspaceLog.append({
885
+ id: entry.id,
886
+ name: entry.name,
887
+ projectPath: entry.projectPath,
888
+ machineId: entry.machineId,
889
+ createdAt: entry.createdAt,
890
+ closedAt: now,
891
+ reason: "server-lost",
892
+ lastStatus: deps.status.get(entry.id),
893
+ claudeSessionId
894
+ });
895
+ } catch {
896
+ }
897
+ if (!claudeSessionId) continue;
898
+ await deps.parked.add({
899
+ ...entry,
900
+ command: entry.command.trim().length > 0 ? entry.command : "claude",
901
+ claudeSessionId,
902
+ parkedAt: now
903
+ });
904
+ parkedIds.add(entry.id);
905
+ parkedNow.push(entry.id);
906
+ deps.status.set(entry.id, "parked");
907
+ }
908
+ }
909
+ await deps.snapshot.write({
910
+ serverId,
911
+ workspaces: sessions.filter((s) => !parkedIds.has(s.name)).map((s) => {
912
+ const claudeSessionId = deps.status.getClaudeSessionId(s.name);
913
+ return {
914
+ id: s.name,
915
+ name: deriveWorkspaceName(s.title, s.startPath, s.command, s.name),
916
+ projectPath: s.startPath,
917
+ command: s.command,
918
+ machineId: deps.machineId,
919
+ createdAt: s.createdAt,
920
+ ...claudeSessionId ? { claudeSessionId } : {},
921
+ ...s.parent ? { parent: s.parent } : {}
922
+ };
923
+ })
924
+ });
925
+ return parkedNow;
926
+ }
927
+
928
+ // src/application/reconcile-parked.ts
929
+ async function reconcileParked(deps) {
930
+ const orphans = [];
931
+ for (const { id } of await deps.parked.list()) {
932
+ if (await deps.tmux.hasSession(id)) {
933
+ await deps.tmux.killSession(id);
934
+ orphans.push(id);
935
+ }
936
+ }
937
+ return orphans;
938
+ }
939
+
940
+ // src/application/startup-reconcile.ts
941
+ async function reconcileAtStartup(deps) {
942
+ try {
943
+ const retagged = await deps.tmux.retagLegacyOptions?.() ?? 0;
944
+ deps.log(`tarfa agent: re-tagged ${retagged} @perch_ tmux option(s) as @tarfa_`);
945
+ } catch (err) {
946
+ deps.log(`tarfa agent: could not re-tag @perch_ tmux options: ${err instanceof Error ? err.message : String(err)}`);
947
+ }
948
+ try {
949
+ const recovered = await trackLiveWorkspaces(deps);
950
+ if (recovered.length > 0) deps.log(`tarfa agent: parked ${recovered.length} workspace(s) lost with the tmux server`);
951
+ } catch (err) {
952
+ deps.log(`tarfa agent: could not check for lost workspaces: ${err instanceof Error ? err.message : String(err)}`);
953
+ }
954
+ await reconcileParked(deps);
955
+ const sessions = await deps.tmux.listSessions(deps.sessionPrefix);
956
+ const parkedIds = (await deps.parked.list()).map((p) => p.id);
957
+ deps.status.prune([...sessions.map((s) => s.name), ...parkedIds]);
958
+ }
959
+
960
+ // src/infrastructure/file-live-snapshot-store.ts
961
+ import { readFile as readFile7, mkdir as mkdir6 } from "node:fs/promises";
962
+ import { dirname as dirname6 } from "node:path";
963
+ var FileLiveSnapshotStore = class {
964
+ constructor(filePath) {
965
+ this.filePath = filePath;
966
+ }
967
+ filePath;
968
+ async read() {
969
+ let parsed;
970
+ try {
971
+ parsed = JSON.parse(await readFile7(this.filePath, "utf8"));
972
+ } catch {
973
+ return void 0;
974
+ }
975
+ if (typeof parsed !== "object" || parsed === null) return void 0;
976
+ const { serverId, workspaces } = parsed;
977
+ if (serverId !== null && typeof serverId !== "string" || !Array.isArray(workspaces)) return void 0;
978
+ return {
979
+ serverId,
980
+ workspaces: workspaces.map(parseEntry).filter((e) => e !== void 0)
981
+ };
982
+ }
983
+ async write(snapshot) {
984
+ const text = JSON.stringify(snapshot, null, 2);
985
+ const current = await readFile7(this.filePath, "utf8").catch(() => void 0);
986
+ if (current === text) return;
987
+ await mkdir6(dirname6(this.filePath), { recursive: true });
988
+ await writeFileAtomic(this.filePath, text);
989
+ }
990
+ async remove(id) {
991
+ const snapshot = await this.read();
992
+ if (!snapshot) return;
993
+ await this.write({ ...snapshot, workspaces: snapshot.workspaces.filter((w) => w.id !== id) });
994
+ }
995
+ };
996
+ function parseEntry(value) {
997
+ if (typeof value !== "object" || value === null) return void 0;
998
+ const v = value;
999
+ const str = (k) => typeof v[k] === "string" ? v[k] : void 0;
1000
+ const id = str("id");
1001
+ const name = str("name");
1002
+ const projectPath = str("projectPath");
1003
+ const command = str("command");
1004
+ const machineId = str("machineId");
1005
+ const createdAt = typeof v.createdAt === "number" ? v.createdAt : void 0;
1006
+ if (!id || !name || !projectPath || command === void 0 || !machineId || createdAt === void 0) {
1007
+ return void 0;
1008
+ }
1009
+ const claudeSessionId = str("claudeSessionId");
1010
+ const parent = str("parent");
1011
+ return {
1012
+ id,
1013
+ name,
1014
+ projectPath,
1015
+ command,
1016
+ machineId,
1017
+ createdAt,
1018
+ ...claudeSessionId ? { claudeSessionId } : {},
1019
+ ...parent ? { parent } : {}
1020
+ };
1021
+ }
1022
+
1023
+ // src/infrastructure/fs-file-reader.ts
1024
+ import { open, stat as stat3 } from "node:fs/promises";
1025
+ var FsFileReader = class {
1026
+ async read(path, maxBytes) {
1027
+ const info = await stat3(path);
1028
+ const truncated = info.size > maxBytes;
1029
+ const toRead = truncated ? maxBytes : info.size;
1030
+ const fh = await open(path, "r");
1031
+ try {
1032
+ const buf = Buffer.alloc(toRead);
1033
+ const { bytesRead } = await fh.read(buf, 0, toRead, 0);
1034
+ return { bytes: buf.subarray(0, bytesRead), truncated };
1035
+ } finally {
1036
+ await fh.close();
1037
+ }
1038
+ }
1039
+ };
1040
+
1041
+ // src/infrastructure/node-pty-adapter.ts
1042
+ import * as pty from "node-pty";
1043
+ var NodePtyAdapter = class {
1044
+ spawn(input) {
1045
+ const proc = pty.spawn(input.command, input.args, {
1046
+ // The PWA's front-end is xterm.js, a 256-colour terminal, and this is what the tmux
1047
+ // client believes it is talking to. `xterm-color` understated it twice over: it has
1048
+ // no `kcbt`, so tmux could not parse back-tab (\x1b[Z) and forwarded a bare `Z`,
1049
+ // and its 8-colour claim made tmux flatten 256-colour output to the nearest ANSI
1050
+ // colour before it ever reached the phone.
1051
+ name: "xterm-256color",
1052
+ cols: input.cols,
1053
+ rows: input.rows,
1054
+ cwd: process.env.HOME ?? process.cwd(),
1055
+ env: childEnv()
1056
+ });
1057
+ return {
1058
+ onData: (listener) => {
1059
+ proc.onData(listener);
1060
+ },
1061
+ onExit: (listener) => {
1062
+ proc.onExit(() => listener());
1063
+ },
1064
+ write: (data) => {
1065
+ proc.write(data);
1066
+ },
1067
+ resize: (cols, rows) => {
1068
+ proc.resize(cols, rows);
1069
+ },
1070
+ kill: () => {
1071
+ proc.kill();
1072
+ }
1073
+ };
1074
+ }
1075
+ };
1076
+
1077
+ // src/infrastructure/fs-browser-discovery.ts
1078
+ import { readdir, readFile as readFile8 } from "node:fs/promises";
1079
+ import { homedir } from "node:os";
1080
+ import { join as join2 } from "node:path";
1081
+ function agentBrowserSidecarDir() {
1082
+ const runtime = process.env.XDG_RUNTIME_DIR;
1083
+ return runtime ? join2(runtime, "agent-browser") : join2(homedir(), ".agent-browser");
1084
+ }
1085
+ function defaultIsPidAlive(pid) {
1086
+ try {
1087
+ process.kill(pid, 0);
1088
+ return true;
1089
+ } catch {
1090
+ return false;
1091
+ }
1092
+ }
1093
+ var FsBrowserDiscovery = class {
1094
+ dir;
1095
+ isPidAlive;
1096
+ constructor(opts = {}) {
1097
+ this.dir = opts.dir ?? agentBrowserSidecarDir();
1098
+ this.isPidAlive = opts.isPidAlive ?? defaultIsPidAlive;
1099
+ }
1100
+ async listSessions() {
1101
+ let entries;
1102
+ try {
1103
+ entries = await readdir(this.dir);
1104
+ } catch {
1105
+ return [];
1106
+ }
1107
+ const sessions = [];
1108
+ for (const entry of entries) {
1109
+ if (!entry.endsWith(".stream")) continue;
1110
+ const name = entry.slice(0, -".stream".length);
1111
+ const streamPort = await readPositiveInt(join2(this.dir, entry));
1112
+ if (streamPort === void 0) continue;
1113
+ const pid = await readPositiveInt(join2(this.dir, `${name}.pid`));
1114
+ if (pid === void 0 || !this.isPidAlive(pid)) continue;
1115
+ sessions.push({ name, streamPort });
1116
+ }
1117
+ return sessions;
1118
+ }
1119
+ };
1120
+ async function readPositiveInt(path) {
1121
+ let raw;
1122
+ try {
1123
+ raw = await readFile8(path, "utf8");
1124
+ } catch {
1125
+ return void 0;
1126
+ }
1127
+ const trimmed = raw.trim();
1128
+ return /^\d+$/.test(trimmed) ? Number(trimmed) : void 0;
1129
+ }
1130
+
1131
+ // src/infrastructure/agent-browser-cli.ts
1132
+ import { execFile as execFile3 } from "node:child_process";
1133
+ import { promisify as promisify3 } from "node:util";
1134
+
1135
+ // src/infrastructure/agent-browser-session-env.ts
1136
+ import { readFile as readFile9 } from "node:fs/promises";
1137
+ import { join as join3 } from "node:path";
1138
+ var NOT_REPLAYED = /* @__PURE__ */ new Set([
1139
+ "AGENT_BROWSER_DAEMON",
1140
+ "AGENT_BROWSER_SESSION",
1141
+ "AGENT_BROWSER_STREAM_PORT",
1142
+ "AGENT_BROWSER_NAMESPACE",
1143
+ "AGENT_BROWSER_SOCKET_DIR"
1144
+ ]);
1145
+ function procSessionLaunchEnv(opts = {}) {
1146
+ const dir = opts.dir ?? agentBrowserSidecarDir();
1147
+ const procRoot = opts.procRoot ?? "/proc";
1148
+ return async (session) => {
1149
+ if (session === "" || session.includes("/") || session.includes("..")) return null;
1150
+ let environ;
1151
+ try {
1152
+ const pid = (await readFile9(join3(dir, `${session}.pid`), "utf8")).trim();
1153
+ if (!/^\d+$/.test(pid)) return null;
1154
+ environ = await readFile9(join3(procRoot, pid, "environ"), "utf8");
1155
+ } catch {
1156
+ return null;
1157
+ }
1158
+ const vars = /* @__PURE__ */ new Map();
1159
+ for (const entry of environ.split("\0")) {
1160
+ const eq = entry.indexOf("=");
1161
+ if (eq > 0) vars.set(entry.slice(0, eq), entry.slice(eq + 1));
1162
+ }
1163
+ if (vars.get("AGENT_BROWSER_DAEMON") !== "1" || vars.get("AGENT_BROWSER_SESSION") !== session) {
1164
+ return null;
1165
+ }
1166
+ const env = {};
1167
+ for (const [name, value] of vars) {
1168
+ if (name.startsWith("AGENT_BROWSER_") && !NOT_REPLAYED.has(name) || name === "DISPLAY") {
1169
+ env[name] = value;
1170
+ }
1171
+ }
1172
+ return env;
1173
+ };
1174
+ }
1175
+
1176
+ // src/infrastructure/agent-browser-cli.ts
1177
+ var exec3 = promisify3(execFile3);
1178
+ var execFileRunner2 = async (cmd, args, launchEnv) => {
1179
+ const env = childEnv();
1180
+ if (launchEnv) {
1181
+ for (const name of Object.keys(env)) {
1182
+ if (name.startsWith("AGENT_BROWSER_")) delete env[name];
1183
+ }
1184
+ Object.assign(env, launchEnv);
1185
+ }
1186
+ await exec3(cmd, args, { env });
1187
+ };
1188
+ function isHttpUrl(url) {
1189
+ let parsed;
1190
+ try {
1191
+ parsed = new URL(url);
1192
+ } catch {
1193
+ return false;
1194
+ }
1195
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
1196
+ }
1197
+ var AgentBrowserCli = class {
1198
+ constructor(run = execFileRunner2, launchEnv = procSessionLaunchEnv()) {
1199
+ this.run = run;
1200
+ this.launchEnv = launchEnv;
1201
+ }
1202
+ run;
1203
+ launchEnv;
1204
+ async agentBrowser(session, args) {
1205
+ await this.run("agent-browser", ["--session", session, ...args], await this.launchEnv(session));
1206
+ }
1207
+ async start(session) {
1208
+ await this.agentBrowser(session, ["open", "about:blank"]);
1209
+ await this.setPhoneViewport(session);
1210
+ }
1211
+ // A phone-sized viewport renders ~1:1 on the phone canvas, so taps land precisely
1212
+ // (a 1280px page scaled to a 390px screen needs sub-finger accuracy). Best-effort:
1213
+ // an older CLI without `set viewport` still yields a usable session.
1214
+ async setPhoneViewport(session) {
1215
+ try {
1216
+ await this.agentBrowser(session, ["set", "viewport", "390", "700"]);
1217
+ } catch {
1218
+ }
1219
+ }
1220
+ async navigate(session, url) {
1221
+ if (!isHttpUrl(url)) {
1222
+ throw new Error(`refusing to open non-http(s) URL: ${url}`);
1223
+ }
1224
+ await this.agentBrowser(session, ["open", url]);
1225
+ }
1226
+ async back(session) {
1227
+ await this.agentBrowser(session, ["back"]);
1228
+ }
1229
+ async forward(session) {
1230
+ await this.agentBrowser(session, ["forward"]);
1231
+ }
1232
+ async stop(session) {
1233
+ await this.agentBrowser(session, ["close"]);
1234
+ }
1235
+ async setViewport(session, width, height) {
1236
+ if (!Number.isFinite(width) || !Number.isFinite(height)) {
1237
+ throw new Error("viewport dimensions must be finite");
1238
+ }
1239
+ const clamp = (n) => String(Math.min(4096, Math.max(200, Math.round(n))));
1240
+ await this.agentBrowser(session, ["set", "viewport", clamp(width), clamp(height)]);
1241
+ }
1242
+ };
1243
+
1244
+ // src/infrastructure/ws-browser-stream-socket.ts
1245
+ import { WebSocket } from "ws";
1246
+ function wsBrowserStreamSocket(socket) {
1247
+ return {
1248
+ send: (data) => socket.send(data),
1249
+ bufferedAmount: () => socket.bufferedAmount,
1250
+ onMessage: (listener) => {
1251
+ socket.on("message", (raw) => listener(raw.toString()));
1252
+ },
1253
+ onClose: (listener) => {
1254
+ socket.on("close", listener);
1255
+ },
1256
+ close: () => socket.close()
1257
+ };
1258
+ }
1259
+ var connectBrowserUpstream = (port) => new Promise((resolve, reject) => {
1260
+ const socket = new WebSocket(`ws://127.0.0.1:${port}`);
1261
+ socket.once("error", reject);
1262
+ socket.once("open", () => {
1263
+ socket.removeListener("error", reject);
1264
+ socket.on("error", () => void 0);
1265
+ resolve(wsBrowserStreamSocket(socket));
1266
+ });
1267
+ });
1268
+
1269
+ // src/server/ws-server.ts
1270
+ import { createServer } from "node:http";
1271
+
1272
+ // src/ports/device-gate-port.ts
1273
+ var DeviceGateBusy = class extends Error {
1274
+ constructor() {
1275
+ super("device gate busy");
1276
+ }
1277
+ };
1278
+
1279
+ // src/server/noise-client-connection.ts
1280
+ var SECURE_CHANNEL_FAILED = 4002;
1281
+ var BUSY = IN_BAND_REFUSAL + 429;
1282
+ var AUTH_TIMEOUT_MS = 1e4;
1283
+ var PRE_AUTH_MAX_MESSAGE_BYTES = 8 * 1024;
1284
+ var MAX_AUTH_ATTEMPTS = 3;
1285
+ var MAX_HELD_MESSAGES = 16;
1286
+ var PAIRING_SECRET = /^[A-Za-z0-9_-]{43}$/;
1287
+ var NoiseClientConnection = class {
1288
+ constructor(socket, staticPrivate, options) {
1289
+ this.socket = socket;
1290
+ this.staticPrivate = staticPrivate;
1291
+ this.options = options;
1292
+ this.cancelDeadline = (options.startTimer ?? startTimer)(() => this.fail("authentication timeout"), AUTH_TIMEOUT_MS);
1293
+ socket.onFrame((frame) => this.receive(frame));
1294
+ socket.onClose(() => this.cancelDeadline());
1295
+ }
1296
+ socket;
1297
+ staticPrivate;
1298
+ options;
1299
+ session = null;
1300
+ state = { kind: "handshake" };
1301
+ deviceKey = null;
1302
+ // What authenticates this client by its key: enrolled at the handshake, or paired just now.
1303
+ keyAuth = null;
1304
+ // While an enrolment is being written, what the handler sends waits behind its authResult.
1305
+ outbox = null;
1306
+ // Messages that arrived while a pairing secret was being redeemed, in order.
1307
+ held = null;
1308
+ authAttempts = 0;
1309
+ // Set when the phone is served at once, until its first frame. Message 1 can be replayed by
1310
+ // whoever saw it, so a pushed authResult does not end the deadline nor free the socket's
1311
+ // unauthenticated slot until the phone has proven it holds the session keys by sending something.
1312
+ awaitingPhone = false;
1313
+ // The pushed authResult went out while awaiting the phone: authenticated once it speaks.
1314
+ deferredAuth = false;
1315
+ listeners = [];
1316
+ keyAuthenticatedListeners = [];
1317
+ cancelDeadline;
1318
+ // The phone's X25519 device key, once its IK handshake has been read.
1319
+ get device() {
1320
+ return this.deviceKey;
1321
+ }
1322
+ get keyCredential() {
1323
+ return this.keyAuth !== null && this.state.kind !== "failed" ? this.keyAuth : void 0;
1324
+ }
1325
+ // An accepted `auth` is answered with whether the device key is now enrolled: the phone drops
1326
+ // its token or pairing secret on `enrolled`, so for a new device the answer waits for the
1327
+ // roster to be written.
1328
+ send(message) {
1329
+ if (!this.session || this.state.kind === "failed") return;
1330
+ if (this.outbox) {
1331
+ this.outbox.push(message);
1332
+ return;
1333
+ }
1334
+ if (message.type === "authResult" && message.ok) {
1335
+ const accepted = { ...message, agent: { protocol: PROTOCOL_VERSION, ...this.options.agentVersion ? { version: this.options.agentVersion } : {} } };
1336
+ message = accepted;
1337
+ if (!this.awaitingPhone) this.cancelDeadline();
1338
+ this.session.raiseCap(MAX_MESSAGE_BYTES);
1339
+ if (this.state.kind === "enrolling") {
1340
+ const hello = this.state.hello;
1341
+ this.outbox = [];
1342
+ this.held ??= [];
1343
+ void this.enrol(hello).then(async (outcome) => {
1344
+ if (outcome === "declined") {
1345
+ this.held = null;
1346
+ this.fail(await this.declinedReason(), DEVICE_REFUSED);
1347
+ return;
1348
+ }
1349
+ const held = this.outbox ?? [];
1350
+ this.outbox = null;
1351
+ this.seal({ ...accepted, enrolled: outcome === "enrolled" });
1352
+ this.options.onAuthenticated?.();
1353
+ held.forEach((m) => this.send(m));
1354
+ const incoming = this.held ?? [];
1355
+ this.held = null;
1356
+ incoming.forEach((raw) => this.drain(raw));
1357
+ });
1358
+ return;
1359
+ }
1360
+ if (this.keyAuth === "device") message = { ...accepted, enrolled: true };
1361
+ this.seal(message);
1362
+ if (this.awaitingPhone) this.deferredAuth = true;
1363
+ else this.options.onAuthenticated?.();
1364
+ return;
1365
+ }
1366
+ this.seal(message);
1367
+ }
1368
+ seal(message) {
1369
+ if (!this.session || this.state.kind === "failed") return;
1370
+ for (const frame of this.session.seal(new TextEncoder().encode(JSON.stringify(message)))) {
1371
+ this.socket.send(frame);
1372
+ }
1373
+ }
1374
+ close() {
1375
+ this.socket.close();
1376
+ }
1377
+ refuse(reason) {
1378
+ this.fail(reason, DEVICE_REFUSED);
1379
+ }
1380
+ onMessage(listener) {
1381
+ this.listeners.push(listener);
1382
+ }
1383
+ onClose(listener) {
1384
+ this.socket.onClose(listener);
1385
+ }
1386
+ onKeyAuthenticated(listener) {
1387
+ this.keyAuthenticatedListeners.push(listener);
1388
+ }
1389
+ receive(frame) {
1390
+ switch (this.state.kind) {
1391
+ case "failed":
1392
+ return;
1393
+ case "checking":
1394
+ this.fail("secure channel failed");
1395
+ return;
1396
+ case "handshake":
1397
+ this.handshake(frame);
1398
+ return;
1399
+ default:
1400
+ this.read(frame);
1401
+ }
1402
+ }
1403
+ handshake(frame) {
1404
+ const pattern = handshakePattern(frame);
1405
+ if (pattern !== "ik") {
1406
+ this.fail("secure channel failed");
1407
+ return;
1408
+ }
1409
+ let responder;
1410
+ let hello;
1411
+ try {
1412
+ responder = new IkResponder(this.staticPrivate);
1413
+ hello = parseDeviceHello(responder.readMessage1(readHandshakeFrame(frame, "ik")));
1414
+ } catch (err) {
1415
+ if (err instanceof WrongStaticKeyError) this.fail(REFUSAL.wrongAgentKey, DEVICE_REFUSED);
1416
+ else this.fail("secure channel failed");
1417
+ return;
1418
+ }
1419
+ if (!hello) {
1420
+ this.fail("secure channel failed");
1421
+ return;
1422
+ }
1423
+ const key = b64url.encode(responder.remoteStatic());
1424
+ this.deviceKey = key;
1425
+ this.state = { kind: "checking" };
1426
+ this.options.devices.check(key).then(
1427
+ ({ standing, enrolment }) => this.admit(responder, hello, key, standing, enrolment),
1428
+ (err) => err instanceof DeviceGateBusy ? this.fail(REFUSAL.tooManyPending, BUSY) : this.fail("device roster unreadable")
1429
+ );
1430
+ }
1431
+ admit(responder, hello, key, standing, enrolment) {
1432
+ if (this.state.kind !== "checking") return;
1433
+ const line = `phone: device ${deviceFingerprint(key)} "${hello.name}" ${standing}`;
1434
+ if (standing === "enrolled") this.options.log?.(line);
1435
+ else this.logRefusal(`handshake:${standing}`, line);
1436
+ if (standing === "revoked") {
1437
+ this.fail(REFUSAL.revoked, DEVICE_REFUSED);
1438
+ return;
1439
+ }
1440
+ if (standing === "unknown" && enrolment === "closed") {
1441
+ this.fail(REFUSAL.notEnrolled, DEVICE_REFUSED);
1442
+ return;
1443
+ }
1444
+ try {
1445
+ this.socket.send(handshakeFrame(responder.writeMessage2(), "ik"));
1446
+ const { send, recv } = responder.split();
1447
+ this.session = new SecureSession(send, recv, { maxMessageBytes: PRE_AUTH_MAX_MESSAGE_BYTES });
1448
+ } catch {
1449
+ this.fail("secure channel failed");
1450
+ return;
1451
+ }
1452
+ this.keyAuth = standing === "enrolled" ? "device" : null;
1453
+ this.state = standing === "enrolled" ? { kind: "open" } : { kind: "enrolling", hello, tokenEnrols: enrolment === "on" };
1454
+ if (standing === "enrolled" && hello.push && this.keyAuthenticatedListeners.length > 0 && this.options.pushBudget?.take(key)) {
1455
+ this.awaitingPhone = true;
1456
+ this.keyAuthenticatedListeners.forEach((l) => l());
1457
+ }
1458
+ }
1459
+ read(frame) {
1460
+ let message;
1461
+ try {
1462
+ message = this.session.open(frame);
1463
+ } catch {
1464
+ this.fail("secure channel failed");
1465
+ return;
1466
+ }
1467
+ if (this.awaitingPhone) {
1468
+ this.awaitingPhone = false;
1469
+ if (this.deferredAuth) {
1470
+ this.cancelDeadline();
1471
+ this.options.onAuthenticated?.();
1472
+ }
1473
+ }
1474
+ if (!message) return;
1475
+ const text = new TextDecoder().decode(message);
1476
+ let raw = text;
1477
+ try {
1478
+ raw = JSON.parse(text);
1479
+ } catch {
1480
+ }
1481
+ this.dispatch(raw);
1482
+ }
1483
+ // Attempts and the queue are counted as messages arrive, so a sender cannot stack them up
1484
+ // behind a pending credential check; a held message is routed, not dispatched, when it drains.
1485
+ dispatch(raw) {
1486
+ if (this.state.kind === "failed") return;
1487
+ const message = raw;
1488
+ if (message?.type === "auth" && ++this.authAttempts > MAX_AUTH_ATTEMPTS) {
1489
+ this.fail("too many auth attempts");
1490
+ return;
1491
+ }
1492
+ if (this.held) {
1493
+ if (this.held.length >= MAX_HELD_MESSAGES) {
1494
+ this.fail("too many messages before auth");
1495
+ return;
1496
+ }
1497
+ this.held.push(raw);
1498
+ return;
1499
+ }
1500
+ this.route(raw);
1501
+ }
1502
+ // A held message, once its wait is over: routed, unless routing one before it opened another
1503
+ // wait (an accepted auth holds what follows until the enrolment is written). Counted already.
1504
+ drain(raw) {
1505
+ if (this.held) this.held.push(raw);
1506
+ else this.route(raw);
1507
+ }
1508
+ route(raw) {
1509
+ if (this.state.kind === "failed") return;
1510
+ const message = raw;
1511
+ if (this.state.kind === "enrolling" && (message?.type !== "auth" || message.token === "")) {
1512
+ this.fail(REFUSAL.notEnrolled, DEVICE_REFUSED);
1513
+ return;
1514
+ }
1515
+ const pairing = message?.type === "auth" && typeof message.token === "string" && PAIRING_SECRET.test(message.token);
1516
+ if (pairing && (this.state.kind === "enrolling" || this.keyAuth !== null)) {
1517
+ this.spendPairing(message.token, raw);
1518
+ return;
1519
+ }
1520
+ if (this.refusesToken()) return;
1521
+ this.listeners.forEach((l) => l(raw));
1522
+ }
1523
+ spendPairing(secret, auth) {
1524
+ this.held = [];
1525
+ const key = this.deviceKey;
1526
+ const fingerprint = deviceFingerprint(key);
1527
+ const resume = (redemption) => {
1528
+ if (redemption.redeemed && this.keyAuth === null) this.keyAuth = "pairing";
1529
+ if (redemption.redeemed && redemption.fresh) this.options.log?.(`phone: pairing code used by device ${fingerprint}`);
1530
+ const held = this.held ?? [];
1531
+ this.held = null;
1532
+ if (this.state.kind === "failed") return;
1533
+ if (!redemption.redeemed && redemption.reason) {
1534
+ this.logRefusal(redemption.reason, `phone: device ${fingerprint} refused: ${redemption.reason}`);
1535
+ this.fail(redemption.reason, DEVICE_REFUSED);
1536
+ return;
1537
+ }
1538
+ if (!redemption.redeemed && this.refusesToken()) return;
1539
+ this.listeners.forEach((l) => l(auth));
1540
+ held.forEach((raw) => this.drain(raw));
1541
+ };
1542
+ const failed = () => resume({ redeemed: false });
1543
+ if (this.state.kind === "enrolling") {
1544
+ this.options.devices.redeemPairing(secret, key).then(resume, (err) => err instanceof DeviceGateBusy ? this.fail(REFUSAL.tooManyPending, BUSY) : failed());
1545
+ } else {
1546
+ this.options.devices.burnPairing(secret, key).then(
1547
+ (burned) => resume({ redeemed: true, fresh: burned }),
1548
+ () => resume({ redeemed: true, fresh: false })
1549
+ );
1550
+ }
1551
+ }
1552
+ // An unknown device whose `auth` is about to be checked as a token, while only a pairing code
1553
+ // may enrol it: refused here, so the token never gets as far as the handler.
1554
+ refusesToken() {
1555
+ if (this.state.kind !== "enrolling" || this.state.tokenEnrols || this.keyAuth !== null) return false;
1556
+ this.logRefusal("token-off", `phone: device ${deviceFingerprint(this.deviceKey)} refused: token enrolment is off`);
1557
+ this.fail(REFUSAL.enrolmentOff, DEVICE_REFUSED);
1558
+ return true;
1559
+ }
1560
+ // `declined`: the roster would not take the device (enrolment turned off or closed since the
1561
+ // handshake, or its id taken), so the credential that authenticated this session no longer
1562
+ // admits it. `failed`: the write itself failed; the session goes on unenrolled, and the phone
1563
+ // keeps its credential to try again.
1564
+ async enrol(hello) {
1565
+ const key = this.deviceKey;
1566
+ const via = this.keyAuth === "pairing" ? "pairing" : "token";
1567
+ this.state = { kind: "open" };
1568
+ try {
1569
+ const enrolled = await this.options.devices.enrol({ x25519: key, ed25519: hello.sig, name: hello.name }, via);
1570
+ this.options.log?.(
1571
+ enrolled ? `phone: enrolled device ${deviceFingerprint(key)} "${hello.name}"` : `phone: device ${deviceFingerprint(key)} not enrolled (enrolment changed meanwhile, or its id is taken); closing its session`
1572
+ );
1573
+ return enrolled ? "enrolled" : "declined";
1574
+ } catch (err) {
1575
+ this.options.log?.(`phone: enrolling ${deviceFingerprint(key)} failed: ${String(err)}`);
1576
+ return "failed";
1577
+ }
1578
+ }
1579
+ async declinedReason() {
1580
+ try {
1581
+ const { enrolment } = await this.options.devices.check(this.deviceKey);
1582
+ if (enrolment === "closed") return REFUSAL.enrolmentClosed;
1583
+ if (enrolment === "off" && this.keyAuth !== "pairing") return REFUSAL.enrolmentOff;
1584
+ } catch {
1585
+ }
1586
+ return REFUSAL.enrolmentRefused;
1587
+ }
1588
+ logRefusal(key, line) {
1589
+ if (this.options.refusalLog) this.options.refusalLog.line(key, line);
1590
+ else this.options.log?.(line);
1591
+ }
1592
+ fail(reason, code = SECURE_CHANNEL_FAILED) {
1593
+ if (this.state.kind === "failed") return;
1594
+ this.state = { kind: "failed" };
1595
+ this.cancelDeadline();
1596
+ this.socket.close(code, reason);
1597
+ }
1598
+ };
1599
+
1600
+ // src/application/resume-command.ts
1601
+ function resumeCommand(command, sessionId) {
1602
+ if (!sessionId || command.trim().length === 0) {
1603
+ return command;
1604
+ }
1605
+ const base = stripResumeFlag(unwrapResume(command));
1606
+ return `${base} --resume ${sessionId} || exec ${base}`;
1607
+ }
1608
+ function unwrapResume(command) {
1609
+ const at = command.indexOf(" || exec ");
1610
+ if (at < 0) return command;
1611
+ const fallback = command.slice(at + " || exec ".length).trim();
1612
+ return stripResumeFlag(command.slice(0, at)) === stripResumeFlag(fallback) ? fallback : command;
1613
+ }
1614
+ function stripResumeFlag(command) {
1615
+ const parts = command.split(/\s+/).filter((p) => p.length > 0);
1616
+ const out = [];
1617
+ let skipNext = false;
1618
+ for (const part of parts) {
1619
+ if (skipNext) {
1620
+ skipNext = false;
1621
+ continue;
1622
+ }
1623
+ if (part === "--resume") {
1624
+ skipNext = true;
1625
+ continue;
1626
+ }
1627
+ out.push(part);
1628
+ }
1629
+ return out.join(" ");
1630
+ }
1631
+
1632
+ // src/application/unpark-workspace.ts
1633
+ async function unparkWorkspace(deps, id) {
1634
+ const record = await deps.parked.get(id);
1635
+ if (!record) {
1636
+ return "not-parked";
1637
+ }
1638
+ if (!await deps.isDirectory(record.projectPath)) {
1639
+ return "missing-directory";
1640
+ }
1641
+ await deps.tmux.createSession({
1642
+ name: record.id,
1643
+ cwd: record.projectPath,
1644
+ command: resumeCommand(record.command, record.claudeSessionId),
1645
+ // Recorded bare, so parking this workspace again resumes it instead of re-wrapping the
1646
+ // `|| exec` fallback, whose first arm would then start a fresh session.
1647
+ storedCommand: record.command,
1648
+ ...record.parent ? { parent: record.parent } : {}
1649
+ });
1650
+ await restoreName(deps.tmux, record.id, record.name).catch(() => void 0);
1651
+ await deps.parked.remove(record.id);
1652
+ deps.status.set(record.id, "idle");
1653
+ return "unparked";
1654
+ }
1655
+ async function restoreName(tmux, id, name) {
1656
+ if (tmux.lockPaneTitle && await tmux.supportsTitleLock?.()) await tmux.lockPaneTitle(id);
1657
+ await tmux.setPaneTitle?.(id, `\u2733 ${name}`);
1658
+ }
1659
+
1660
+ // src/application/close-workspace.ts
1661
+ async function closeWorkspace(deps, id) {
1662
+ const scope = await paneScope(deps, id);
1663
+ await deps.tmux.killSession(id);
1664
+ return scope;
1665
+ }
1666
+ async function paneScope(deps, id) {
1667
+ if (!deps.scopes || !deps.tmux.panePid) return null;
1668
+ try {
1669
+ const pid = await deps.tmux.panePid(id);
1670
+ return pid === null ? null : await deps.scopes.scopeForPid(pid);
1671
+ } catch {
1672
+ return null;
1673
+ }
1674
+ }
1675
+
1676
+ // src/application/browser-session-owner.ts
1677
+ var DELIMITER = "__";
1678
+ function browserSessionsForWorkspace(sessionNames, workspaceId) {
1679
+ return sessionNames.filter(
1680
+ (name) => name === workspaceId || name.startsWith(workspaceId + DELIMITER)
1681
+ );
1682
+ }
1683
+
1684
+ // src/application/close-workspace-browsers.ts
1685
+ async function closeWorkspaceBrowserSessions(deps, workspaceId) {
1686
+ let names;
1687
+ try {
1688
+ const sessions = await deps.discovery.listSessions();
1689
+ names = browserSessionsForWorkspace(
1690
+ sessions.map((s) => s.name),
1691
+ workspaceId
1692
+ );
1693
+ } catch {
1694
+ return;
1695
+ }
1696
+ for (const name of names) {
1697
+ await deps.commands.stop(name).catch(() => void 0);
1698
+ }
1699
+ }
1700
+
1701
+ // src/application/reap-workspace-scope.ts
1702
+ async function reapWorkspaceScope(deps, scope) {
1703
+ if (!deps.scopes || !scope) return;
1704
+ try {
1705
+ await deps.scopes.stopScope(scope);
1706
+ } catch {
1707
+ }
1708
+ }
1709
+
1710
+ // src/application/record-workspace-closed.ts
1711
+ async function recordWorkspaceClosed(deps, workspace, reason) {
1712
+ try {
1713
+ await deps.workspaceLog.append({
1714
+ id: workspace.id,
1715
+ name: workspace.name,
1716
+ projectPath: workspace.projectPath,
1717
+ machineId: workspace.machineId,
1718
+ createdAt: workspace.createdAt,
1719
+ closedAt: deps.clock.now(),
1720
+ reason,
1721
+ lastStatus: deps.status.get(workspace.id),
1722
+ claudeSessionId: deps.status.getClaudeSessionId(workspace.id)
1723
+ });
1724
+ } catch {
1725
+ return;
1726
+ }
1727
+ }
1728
+
1729
+ // src/application/retire-workspace.ts
1730
+ async function retireWorkspace(deps, id) {
1731
+ const parkedRecord = await deps.parked.get(id);
1732
+ const workspace = deps.forgetWorkspace(id, true) ?? (parkedRecord ? {
1733
+ machineId: parkedRecord.machineId,
1734
+ id: parkedRecord.id,
1735
+ name: parkedRecord.name,
1736
+ projectPath: parkedRecord.projectPath,
1737
+ command: parkedRecord.command,
1738
+ createdAt: parkedRecord.createdAt,
1739
+ lastActivityAt: parkedRecord.parkedAt,
1740
+ status: "parked"
1741
+ } : await deps.snapshot(id));
1742
+ const lastStatus = deps.status.get(id);
1743
+ const claudeSessionId = parkedRecord ? parkedRecord.claudeSessionId : deps.status.getClaudeSessionId(id);
1744
+ if (parkedRecord) {
1745
+ await deps.parked.remove(id);
1746
+ }
1747
+ const scope = await closeWorkspace({ tmux: deps.tmux, scopes: deps.scopes }, id);
1748
+ if (workspace) {
1749
+ await recordWorkspaceClosed(
1750
+ {
1751
+ workspaceLog: deps.workspaceLog,
1752
+ status: { get: () => lastStatus, getClaudeSessionId: () => claudeSessionId },
1753
+ clock: deps.clock
1754
+ },
1755
+ workspace,
1756
+ "closed-by-user"
1757
+ );
1758
+ }
1759
+ deps.dropWorkspaceState?.(id);
1760
+ return scope;
1761
+ }
1762
+ async function finishClose(deps, id, scope) {
1763
+ try {
1764
+ await reapWorkspaceScope({ scopes: deps.scopes }, scope);
1765
+ await closeWorkspaceBrowserSessions({ discovery: deps.browserDiscovery, commands: deps.browserCommands }, id);
1766
+ } catch {
1767
+ }
1768
+ }
1769
+
1770
+ // src/application/remember-launcher.ts
1771
+ var BUILTINS = /* @__PURE__ */ new Set(["bash", "claude", "codex"]);
1772
+ function normalizeCommand(command) {
1773
+ const trimmed = command.trim();
1774
+ return trimmed === "" || BUILTINS.has(trimmed) ? null : trimmed;
1775
+ }
1776
+
1777
+ // src/application/park-workspace.ts
1778
+ async function parkWorkspace(deps, workspace) {
1779
+ if (await deps.parked.get(workspace.id)) {
1780
+ return "already-parked";
1781
+ }
1782
+ const claudeSessionId = deps.status.getClaudeSessionId(workspace.id);
1783
+ if (!claudeSessionId) {
1784
+ return "not-restorable";
1785
+ }
1786
+ const hasCommand = workspace.command.trim().length > 0;
1787
+ const command = hasCommand ? workspace.command : "claude";
1788
+ await deps.parked.add({
1789
+ id: workspace.id,
1790
+ name: workspace.name,
1791
+ projectPath: workspace.projectPath,
1792
+ command,
1793
+ machineId: workspace.machineId,
1794
+ createdAt: workspace.createdAt,
1795
+ parkedAt: deps.clock.now(),
1796
+ claudeSessionId,
1797
+ ...workspace.parentId ? { parent: workspace.parentId } : {}
1798
+ });
1799
+ await deps.tmux.killSession(workspace.id);
1800
+ deps.status.set(workspace.id, "parked");
1801
+ return "parked";
1802
+ }
1803
+
1804
+ // src/application/make-dir.ts
1805
+ import { join as join4 } from "node:path";
1806
+ function isValidFolderName(name) {
1807
+ return name.length > 0 && !name.includes("/") && name !== "." && name !== "..";
1808
+ }
1809
+ async function makeDir(deps, parent, name, allowedRoots) {
1810
+ const clean = name.trim();
1811
+ if (!isValidFolderName(clean)) {
1812
+ throw new Error(`invalid folder name: ${name}`);
1813
+ }
1814
+ const target = join4(parent, clean);
1815
+ if (!pathWithinRoots(target, allowedRoots)) {
1816
+ throw new Error(`path outside allowed roots: ${target}`);
1817
+ }
1818
+ await deps.lister.makeDir(target);
1819
+ return target;
1820
+ }
1821
+
1822
+ // src/application/classify-text-file.ts
1823
+ var SNIFF_BYTES = 8e3;
1824
+ function classifyTextFile(bytes, truncated) {
1825
+ if (isBinary(bytes, truncated)) return { binary: true, data: "" };
1826
+ return { binary: false, data: Buffer.from(bytes).toString("base64") };
1827
+ }
1828
+ function isBinary(bytes, truncated) {
1829
+ if (bytes.subarray(0, SNIFF_BYTES).includes(0)) return true;
1830
+ if (decodes(bytes)) return false;
1831
+ if (truncated) {
1832
+ for (let drop = 1; drop <= 3 && drop < bytes.length; drop += 1) {
1833
+ if (decodes(bytes.subarray(0, bytes.length - drop))) return false;
1834
+ }
1835
+ }
1836
+ return true;
1837
+ }
1838
+ function decodes(bytes) {
1839
+ try {
1840
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1841
+ return true;
1842
+ } catch {
1843
+ return false;
1844
+ }
1845
+ }
1846
+
1847
+ // src/application/read-file.ts
1848
+ var MAX_FILE_BYTES = 512 * 1024;
1849
+ async function readFile10(deps, path, allowedRoots) {
1850
+ if (!pathWithinRoots(path, allowedRoots)) {
1851
+ throw new Error(`path outside allowed roots: ${path}`);
1852
+ }
1853
+ const { bytes, truncated } = await deps.reader.read(path, MAX_FILE_BYTES);
1854
+ const { binary, data } = classifyTextFile(bytes, truncated);
1855
+ return { binary, data, truncated };
1856
+ }
1857
+
1858
+ // src/application/store-pasted-file.ts
1859
+ import { basename, extname, join as join5 } from "node:path";
1860
+ async function storePastedFile(deps, input) {
1861
+ const safeName = basename(input.name) || "pasted-file";
1862
+ const dir = join5(input.projectPath, ".tmp", "files");
1863
+ await deps.writer.ensureDir(dir);
1864
+ const finalName = await uniqueName(deps.writer, dir, safeName);
1865
+ await deps.writer.writeFile(join5(dir, finalName), input.bytes);
1866
+ return join5(".tmp", "files", finalName);
1867
+ }
1868
+ async function uniqueName(writer, dir, name) {
1869
+ if (!await writer.exists(join5(dir, name))) return name;
1870
+ const ext = extname(name);
1871
+ const stem = name.slice(0, name.length - ext.length);
1872
+ for (let i = 1; ; i += 1) {
1873
+ const candidate = `${stem}-${i}${ext}`;
1874
+ if (!await writer.exists(join5(dir, candidate))) return candidate;
1875
+ }
1876
+ }
1877
+
1878
+ // src/application/read-servable-file.ts
1879
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
1880
+ var MAX_PDF_BYTES = 20 * 1024 * 1024;
1881
+ var MAX_AUDIO_BYTES = 30 * 1024 * 1024;
1882
+ function servable(path) {
1883
+ const image = imageMediaType(path);
1884
+ if (image) return { mediaType: image, maxBytes: MAX_IMAGE_BYTES };
1885
+ const pdf = pdfMediaType(path);
1886
+ if (pdf) return { mediaType: pdf, maxBytes: MAX_PDF_BYTES };
1887
+ const audio = audioMediaType(path);
1888
+ if (audio) return { mediaType: audio, maxBytes: MAX_AUDIO_BYTES };
1889
+ return null;
1890
+ }
1891
+ async function readServableFile(deps, path, allowedRoots) {
1892
+ if (!pathWithinRoots(path, allowedRoots)) return { kind: "forbidden" };
1893
+ const match = servable(path);
1894
+ if (!match) return { kind: "not-servable" };
1895
+ const { bytes, truncated } = await deps.reader.read(path, match.maxBytes);
1896
+ if (truncated) return { kind: "too-large" };
1897
+ return { kind: "ok", mediaType: match.mediaType, bytes };
1898
+ }
1899
+
1900
+ // src/application/media-sender.ts
1901
+ var MEDIA_CHUNK_BYTES = 45 * 1024;
1902
+ var MEDIA_WINDOW = 4;
1903
+ var MediaSender = class {
1904
+ constructor(send) {
1905
+ this.send = send;
1906
+ }
1907
+ send;
1908
+ // The id of the latest request, set before its file is read so one that is superseded
1909
+ // while reading is never answered.
1910
+ latest = null;
1911
+ transfer = null;
1912
+ async request(id, read) {
1913
+ this.latest = id;
1914
+ this.transfer = null;
1915
+ let result;
1916
+ try {
1917
+ result = await read();
1918
+ } catch (err) {
1919
+ if (this.latest === id) {
1920
+ const missing = err?.code === "ENOENT";
1921
+ this.send({ type: "media", id, result: missing ? "not-found" : "error" });
1922
+ }
1923
+ return;
1924
+ }
1925
+ if (this.latest !== id) return;
1926
+ if (result.kind !== "ok") {
1927
+ this.send({ type: "media", id, result: RESULTS[result.kind] });
1928
+ return;
1929
+ }
1930
+ this.send({ type: "media", id, result: "ok", mediaType: result.mediaType, size: result.bytes.byteLength });
1931
+ this.transfer = {
1932
+ id,
1933
+ bytes: result.bytes,
1934
+ chunks: Math.ceil(result.bytes.byteLength / MEDIA_CHUNK_BYTES),
1935
+ sent: 0,
1936
+ acked: 0
1937
+ };
1938
+ this.pump();
1939
+ }
1940
+ // Acknowledgements arrive in order; anything else (a stale id, a repeat, a chunk never sent)
1941
+ // is ignored rather than trusted to open the window.
1942
+ ack(id, seq) {
1943
+ const t = this.transfer;
1944
+ if (!t || t.id !== id || seq !== t.acked || seq >= t.sent) return;
1945
+ t.acked += 1;
1946
+ if (t.acked === t.chunks) {
1947
+ this.transfer = null;
1948
+ return;
1949
+ }
1950
+ this.pump();
1951
+ }
1952
+ cancel(id) {
1953
+ if (this.latest === id) this.stop();
1954
+ }
1955
+ stop() {
1956
+ this.latest = null;
1957
+ this.transfer = null;
1958
+ }
1959
+ pump() {
1960
+ const t = this.transfer;
1961
+ while (t && t.sent < t.chunks && t.sent - t.acked < MEDIA_WINDOW) {
1962
+ const start = t.sent * MEDIA_CHUNK_BYTES;
1963
+ const data = Buffer.from(t.bytes.subarray(start, start + MEDIA_CHUNK_BYTES)).toString("base64");
1964
+ this.send({ type: "mediaChunk", id: t.id, seq: t.sent, data });
1965
+ t.sent += 1;
1966
+ }
1967
+ }
1968
+ };
1969
+ var RESULTS = {
1970
+ forbidden: "forbidden",
1971
+ "not-servable": "not-found",
1972
+ "too-large": "too-large"
1973
+ };
1974
+
1975
+ // src/server/browser-stream-handler.ts
1976
+ var FORWARDED_UPSTREAM_TYPES = /* @__PURE__ */ new Set(["frame", "tabs", "url", "status"]);
1977
+ var BrowserStreamHandler = class {
1978
+ constructor(deps) {
1979
+ this.deps = deps;
1980
+ deps.client.onMessage((data) => {
1981
+ void this.handleClientMessage(data);
1982
+ });
1983
+ deps.client.onDrain?.(() => {
1984
+ if (this.pendingFrame !== null && !this.backpressured()) {
1985
+ const frame = this.pendingFrame;
1986
+ this.pendingFrame = null;
1987
+ deps.client.send(frame);
1988
+ }
1989
+ });
1990
+ deps.client.onClose(() => {
1991
+ this.clientClosed = true;
1992
+ deps.registry.release(deps.sessionName, this.viewer);
1993
+ const upstream = this.upstream;
1994
+ this.upstream = null;
1995
+ upstream?.close();
1996
+ });
1997
+ }
1998
+ deps;
1999
+ authed = false;
2000
+ clientClosed = false;
2001
+ upstream = null;
2002
+ pendingFrame = null;
2003
+ // Kicked when another viewer acquires the same session: tell this client why and
2004
+ // tear down both sides. Registered after a successful auth + upstream dial.
2005
+ viewer = {
2006
+ detach: () => {
2007
+ const upstream = this.upstream;
2008
+ this.upstream = null;
2009
+ upstream?.close();
2010
+ this.end("opened-elsewhere");
2011
+ }
2012
+ };
2013
+ async handleClientMessage(data) {
2014
+ let message;
2015
+ try {
2016
+ message = parseBrowserClientMessage(JSON.parse(data));
2017
+ } catch {
2018
+ return;
2019
+ }
2020
+ if (!this.authed) {
2021
+ if (message.type !== "auth") {
2022
+ this.deps.client.close();
2023
+ return;
2024
+ }
2025
+ this.authed = true;
2026
+ this.send({ type: "authResult", ok: true });
2027
+ await this.dialUpstream();
2028
+ return;
2029
+ }
2030
+ switch (message.type) {
2031
+ case "auth":
2032
+ return;
2033
+ case "ping":
2034
+ this.send({ type: "pong" });
2035
+ return;
2036
+ case "navigate":
2037
+ await this.deps.commands.navigate(this.deps.sessionName, message.url).catch(() => void 0);
2038
+ return;
2039
+ case "back":
2040
+ await this.deps.commands.back(this.deps.sessionName).catch(() => void 0);
2041
+ return;
2042
+ case "forward":
2043
+ await this.deps.commands.forward(this.deps.sessionName).catch(() => void 0);
2044
+ return;
2045
+ case "setViewport":
2046
+ await this.deps.commands.setViewport(this.deps.sessionName, message.width, message.height).catch(() => void 0);
2047
+ return;
2048
+ case "closeSession":
2049
+ await this.deps.commands.stop(this.deps.sessionName).catch(() => void 0);
2050
+ return;
2051
+ case "input_mouse":
2052
+ case "input_keyboard":
2053
+ case "input_touch":
2054
+ this.upstream?.send(data);
2055
+ return;
2056
+ }
2057
+ }
2058
+ async dialUpstream() {
2059
+ let streamPort;
2060
+ try {
2061
+ const sessions = await this.deps.discovery.listSessions();
2062
+ streamPort = sessions.find((s) => s.name === this.deps.sessionName)?.streamPort;
2063
+ } catch {
2064
+ this.end("error");
2065
+ return;
2066
+ }
2067
+ if (streamPort === void 0) {
2068
+ this.end("not-found");
2069
+ return;
2070
+ }
2071
+ let upstream;
2072
+ try {
2073
+ upstream = await this.deps.connectUpstream(streamPort);
2074
+ } catch {
2075
+ this.end("error");
2076
+ return;
2077
+ }
2078
+ if (this.clientClosed) {
2079
+ upstream.close();
2080
+ return;
2081
+ }
2082
+ this.upstream = upstream;
2083
+ this.deps.registry.acquire(this.deps.sessionName, this.viewer);
2084
+ upstream.onMessage((data) => {
2085
+ this.forwardUpstreamMessage(data);
2086
+ });
2087
+ upstream.onClose(() => {
2088
+ if (this.upstream !== upstream) return;
2089
+ this.upstream = null;
2090
+ this.end("closed");
2091
+ });
2092
+ }
2093
+ forwardUpstreamMessage(data) {
2094
+ let type;
2095
+ try {
2096
+ type = parseBrowserAgentMessage(JSON.parse(data)).type;
2097
+ } catch {
2098
+ return;
2099
+ }
2100
+ if (!FORWARDED_UPSTREAM_TYPES.has(type)) return;
2101
+ if (type === "frame") {
2102
+ if (this.backpressured()) {
2103
+ this.pendingFrame = data;
2104
+ } else {
2105
+ this.pendingFrame = null;
2106
+ this.deps.client.send(data);
2107
+ }
2108
+ return;
2109
+ }
2110
+ if (this.pendingFrame !== null && !this.backpressured()) {
2111
+ this.deps.client.send(this.pendingFrame);
2112
+ this.pendingFrame = null;
2113
+ }
2114
+ this.deps.client.send(data);
2115
+ }
2116
+ // One message in flight: the phone's acknowledgement is the only backpressure a proxy in front of the phone port leaves,
2117
+ // and whatever is in flight is what terminal output waits behind. Frames arriving meanwhile
2118
+ // are replaced, not queued (latest wins).
2119
+ backpressured() {
2120
+ return (this.deps.client.bufferedAmount?.() ?? 0) > 0;
2121
+ }
2122
+ end(reason) {
2123
+ if (this.clientClosed) return;
2124
+ this.send({ type: "ended", reason });
2125
+ this.deps.client.close();
2126
+ }
2127
+ send(message) {
2128
+ this.deps.client.send(JSON.stringify(message));
2129
+ }
2130
+ };
2131
+
2132
+ // src/server/session-browser-streams.ts
2133
+ var MAX_BROWSER_STREAMS = 4;
2134
+ var SessionBrowserStreams = class {
2135
+ constructor(deps) {
2136
+ this.deps = deps;
2137
+ }
2138
+ deps;
2139
+ streams = /* @__PURE__ */ new Map();
2140
+ open(stream, session) {
2141
+ this.streams.get(stream)?.end();
2142
+ if (this.streams.size >= MAX_BROWSER_STREAMS) {
2143
+ this.deps.send({ type: "browserClosed", stream });
2144
+ return;
2145
+ }
2146
+ const socket = new StreamSocket(stream, this.deps.send, () => {
2147
+ if (this.streams.get(stream) === socket) this.streams.delete(stream);
2148
+ });
2149
+ this.streams.set(stream, socket);
2150
+ this.deps.serve(socket, session);
2151
+ }
2152
+ deliver(stream, data) {
2153
+ this.streams.get(stream)?.deliver(data);
2154
+ }
2155
+ ack(stream) {
2156
+ this.streams.get(stream)?.ack();
2157
+ }
2158
+ close(stream) {
2159
+ this.streams.get(stream)?.end();
2160
+ }
2161
+ closeAll() {
2162
+ for (const socket of [...this.streams.values()]) socket.end();
2163
+ }
2164
+ };
2165
+ var StreamSocket = class {
2166
+ constructor(stream, sendMessage2, forget) {
2167
+ this.stream = stream;
2168
+ this.sendMessage = sendMessage2;
2169
+ this.forget = forget;
2170
+ }
2171
+ stream;
2172
+ sendMessage;
2173
+ forget;
2174
+ closed = false;
2175
+ unacked = [];
2176
+ buffered = 0;
2177
+ messageListeners = [];
2178
+ closeListeners = [];
2179
+ drainListeners = [];
2180
+ send(data) {
2181
+ if (this.closed) return;
2182
+ this.unacked.push(data.length);
2183
+ this.buffered += data.length;
2184
+ this.sendMessage({ type: "browser", stream: this.stream, data });
2185
+ }
2186
+ bufferedAmount() {
2187
+ return this.buffered;
2188
+ }
2189
+ onDrain(listener) {
2190
+ this.drainListeners.push(listener);
2191
+ }
2192
+ onMessage(listener) {
2193
+ this.messageListeners.push(listener);
2194
+ }
2195
+ onClose(listener) {
2196
+ this.closeListeners.push(listener);
2197
+ }
2198
+ // The handler ends the stream: tell the phone.
2199
+ close() {
2200
+ if (this.closed) return;
2201
+ this.sendMessage({ type: "browserClosed", stream: this.stream });
2202
+ this.end();
2203
+ }
2204
+ deliver(data) {
2205
+ if (!this.closed) this.messageListeners.forEach((l) => l(data));
2206
+ }
2207
+ // An acknowledgement for nothing in flight is ignored, never allowed to open the window.
2208
+ ack() {
2209
+ const size = this.unacked.shift();
2210
+ if (size === void 0) return;
2211
+ this.buffered -= size;
2212
+ this.drainListeners.forEach((l) => l());
2213
+ }
2214
+ // The phone closed it or the connection went away: nobody to tell.
2215
+ end() {
2216
+ if (this.closed) return;
2217
+ this.closed = true;
2218
+ this.forget();
2219
+ this.closeListeners.forEach((l) => l());
2220
+ }
2221
+ };
2222
+
2223
+ // src/server/connection-handler.ts
2224
+ var CLIENT_LOG_MAX_BYTES = 512 * 1024;
2225
+ var ConnectionHandler = class {
2226
+ constructor(conn, deps) {
2227
+ this.conn = conn;
2228
+ this.deps = deps;
2229
+ this.media = new MediaSender((m) => conn.send(m));
2230
+ this.browsers = new SessionBrowserStreams({
2231
+ send: (m) => conn.send(m),
2232
+ serve: (client, sessionName) => new BrowserStreamHandler({
2233
+ client,
2234
+ sessionName,
2235
+ discovery: deps.browserDiscovery,
2236
+ commands: deps.browserCommands,
2237
+ connectUpstream: deps.connectUpstream,
2238
+ registry: deps.browserViewers
2239
+ })
2240
+ });
2241
+ conn.onMessage((raw) => {
2242
+ void this.handle(raw);
2243
+ });
2244
+ conn.onKeyAuthenticated?.(() => void this.serveAtOnce());
2245
+ conn.onClose(() => {
2246
+ this.onClose();
2247
+ });
2248
+ }
2249
+ conn;
2250
+ deps;
2251
+ authed = false;
2252
+ // The dictation this phone is streaming; chunks that arrive before its checks pass wait in `queue`.
2253
+ voice = null;
2254
+ attachment = null;
2255
+ media;
2256
+ browsers;
2257
+ pushWorkspaceUpdated(workspace) {
2258
+ if (this.authed) {
2259
+ this.conn.send({ type: "workspaceUpdated", workspace });
2260
+ }
2261
+ }
2262
+ pushClosed(workspaceId) {
2263
+ if (this.authed) {
2264
+ this.conn.send({ type: "closed", workspaceId });
2265
+ }
2266
+ }
2267
+ pushDeviceEnrolled(device) {
2268
+ if (this.authed && (device.x25519 === void 0 || this.conn.device !== device.x25519)) {
2269
+ this.conn.send({ type: "deviceEnrolled", id: device.id, name: device.name, at: device.at });
2270
+ }
2271
+ }
2272
+ pushStats(stats) {
2273
+ if (this.authed) {
2274
+ this.conn.send({ type: "stats", stats });
2275
+ }
2276
+ }
2277
+ // A question or menu opened in a workspace being dictated into: from now on its held spaces
2278
+ // would land on the menu, so the dictation ends (the phone stops recording on the reply).
2279
+ stopVoiceIn(workspaceId, reason) {
2280
+ if (this.voice?.workspaceId === workspaceId) this.cancelVoice(reason);
2281
+ }
2282
+ pushBrowserSessions(sessions) {
2283
+ if (this.authed) {
2284
+ this.conn.send({ type: "browserSessions", sessions });
2285
+ }
2286
+ }
2287
+ onClose() {
2288
+ this.cancelVoice("The phone disconnected");
2289
+ this.media.stop();
2290
+ this.browsers.closeAll();
2291
+ const att = this.attachment;
2292
+ if (!att) return;
2293
+ att.terminated = true;
2294
+ att.session.kill();
2295
+ this.deps.registry.release(att.workspaceId, att.viewer);
2296
+ this.attachment = null;
2297
+ }
2298
+ async handle(raw) {
2299
+ let message;
2300
+ try {
2301
+ message = parseClientMessage(raw);
2302
+ } catch (err) {
2303
+ this.conn.send({ type: "error", code: "bad_message", message: errMsg(err) });
2304
+ return;
2305
+ }
2306
+ if (message.type === "auth") {
2307
+ const token = this.deps.config.token;
2308
+ const isToken = token !== void 0 && constantTimeEqual(message.token, token);
2309
+ const byKey = this.conn.keyCredential;
2310
+ if (byKey === "device" && isToken && this.conn.refuse) {
2311
+ this.conn.refuse(REFUSAL.tokenNotNeeded);
2312
+ return;
2313
+ }
2314
+ if (this.authed) return;
2315
+ if (byKey || isToken) {
2316
+ this.authed = true;
2317
+ this.conn.send({ type: "authResult", ok: true, credential: byKey ?? "token" });
2318
+ } else {
2319
+ this.conn.send({ type: "authResult", ok: false });
2320
+ this.conn.close();
2321
+ }
2322
+ return;
2323
+ }
2324
+ if (!this.authed) {
2325
+ this.conn.send({ type: "error", code: "unauthorized", message: "authenticate first" });
2326
+ return;
2327
+ }
2328
+ try {
2329
+ await this.route(message);
2330
+ } catch (err) {
2331
+ this.conn.send({ type: "error", code: "internal", message: errMsg(err) });
2332
+ }
2333
+ }
2334
+ // The transport proved an enrolled device key and the phone asked to be served at once: answer
2335
+ // `authResult` and what the phone asks for on every connect, without waiting for its `auth`.
2336
+ async serveAtOnce() {
2337
+ this.authed = true;
2338
+ this.conn.send({ type: "authResult", ok: true, credential: "device", pushed: true });
2339
+ for (const type of ["list", "listKeyterms"]) {
2340
+ try {
2341
+ await this.route({ type });
2342
+ } catch (err) {
2343
+ this.conn.send({ type: "error", code: "internal", message: errMsg(err) });
2344
+ }
2345
+ }
2346
+ }
2347
+ async route(message) {
2348
+ const { config, tmux, recent, lister, reader, resolvePath, isDirectory, clock, ids, status, hiddenFolders, repoRemote, claudeSessions } = this.deps;
2349
+ switch (message.type) {
2350
+ case "list": {
2351
+ const hidden = await hiddenFolders.list();
2352
+ const workspaces = (await listWorkspaces({
2353
+ tmux,
2354
+ machineId: config.machineId,
2355
+ sessionPrefix: config.sessionPrefix,
2356
+ status,
2357
+ repoRemote,
2358
+ claudeSessions,
2359
+ parked: this.deps.parked,
2360
+ messagedLast: this.deps.messagedLast
2361
+ })).filter((w) => !isHiddenPath(w.projectPath, hidden));
2362
+ const claudeVoice = await this.deps.claudeVoice?.available() ?? false;
2363
+ this.conn.send({ type: "workspaces", workspaces, claudeVoice });
2364
+ await this.sendBrowserSessions();
2365
+ return;
2366
+ }
2367
+ case "getRecentPaths": {
2368
+ const hidden = await hiddenFolders.list();
2369
+ this.conn.send({ type: "recentPaths", paths: await getRecentPaths({ recent, hidden }) });
2370
+ return;
2371
+ }
2372
+ case "browseDir": {
2373
+ const hidden = await hiddenFolders.list();
2374
+ const { subdirs, files } = await browseDir({ lister, hidden }, message.path, await this.allowedRoots());
2375
+ this.conn.send({ type: "dirEntries", path: message.path, subdirs, files });
2376
+ return;
2377
+ }
2378
+ case "makeDir": {
2379
+ const allowed = await this.allowedRoots();
2380
+ const hidden = await hiddenFolders.list();
2381
+ const created = await makeDir({ lister }, message.parent, message.name, allowed);
2382
+ const { subdirs, files } = await browseDir({ lister, hidden }, created, allowed);
2383
+ this.conn.send({ type: "dirEntries", path: created, subdirs, files });
2384
+ return;
2385
+ }
2386
+ case "listRoots": {
2387
+ this.conn.send({ type: "roots", roots: config.projectRoots });
2388
+ return;
2389
+ }
2390
+ // Sent by PWAs from before the keyboard bar lost its command shortcuts; answering it
2391
+ // with an error would show them a banner on every connect.
2392
+ case "listCommands":
2393
+ return;
2394
+ case "listLaunchers":
2395
+ await this.sendLaunchers();
2396
+ return;
2397
+ case "forgetLauncher":
2398
+ await this.deps.launchers.forget(message.command);
2399
+ await this.sendLaunchers();
2400
+ return;
2401
+ case "listKeyterms": {
2402
+ this.conn.send({ type: "keyterms", keyterms: await this.deps.keyterms.list() });
2403
+ return;
2404
+ }
2405
+ case "readFile": {
2406
+ const { binary, data, truncated } = await readFile10({ reader }, message.path, await this.allowedRoots());
2407
+ this.conn.send({ type: "fileContents", path: message.path, data, truncated, binary });
2408
+ return;
2409
+ }
2410
+ case "create": {
2411
+ const workspace = await createWorkspace(
2412
+ { tmux, recent, resolvePath, isDirectory, clock, ids, machineId: config.machineId },
2413
+ { projectPath: message.projectPath, command: message.command }
2414
+ );
2415
+ this.conn.send({ type: "workspaceUpdated", workspace });
2416
+ const remembered = message.remember ? normalizeCommand(message.command) : null;
2417
+ if (remembered) await this.deps.launchers.remember(remembered).catch(() => void 0);
2418
+ return;
2419
+ }
2420
+ case "close": {
2421
+ const scope = await retireWorkspace(
2422
+ { ...this.deps, tmux, snapshot: (id) => this.snapshotWorkspace(id) },
2423
+ message.workspaceId
2424
+ );
2425
+ this.conn.send({ type: "closed", workspaceId: message.workspaceId });
2426
+ this.deps.broadcastClosed(message.workspaceId);
2427
+ void finishClose(this.deps, message.workspaceId, scope);
2428
+ return;
2429
+ }
2430
+ case "setStatus": {
2431
+ if (!isWorkspaceStatus(message.status)) {
2432
+ return;
2433
+ }
2434
+ if (message.status === "parked") {
2435
+ const workspace = this.deps.forgetWorkspace(message.workspaceId) ?? await this.snapshotWorkspace(message.workspaceId);
2436
+ if (workspace) {
2437
+ const result = await parkWorkspace(
2438
+ {
2439
+ parked: this.deps.parked,
2440
+ tmux: this.deps.tmux,
2441
+ status: this.deps.status,
2442
+ clock: this.deps.clock
2443
+ },
2444
+ workspace
2445
+ );
2446
+ if (result === "not-restorable") {
2447
+ this.conn.send({
2448
+ type: "error",
2449
+ code: "not-restorable",
2450
+ message: "Can't park yet: Tarfa hasn't seen a Claude session id for this workspace. Send it a message first, then park."
2451
+ });
2452
+ }
2453
+ }
2454
+ return;
2455
+ }
2456
+ status.set(message.workspaceId, message.status);
2457
+ return;
2458
+ }
2459
+ case "setUrgent": {
2460
+ status.setUrgent(message.workspaceId, message.urgent);
2461
+ return;
2462
+ }
2463
+ case "attach": {
2464
+ const result = await unparkWorkspace(
2465
+ {
2466
+ parked: this.deps.parked,
2467
+ tmux: this.deps.tmux,
2468
+ isDirectory: this.deps.isDirectory,
2469
+ status: this.deps.status
2470
+ },
2471
+ message.workspaceId
2472
+ );
2473
+ if (result === "missing-directory") {
2474
+ this.conn.send({
2475
+ type: "error",
2476
+ code: "missing-directory",
2477
+ message: "the project directory no longer exists"
2478
+ });
2479
+ return;
2480
+ }
2481
+ await tmux.ensureHyperlinks?.();
2482
+ this.attach(message.workspaceId, message.cols, message.rows);
2483
+ return;
2484
+ }
2485
+ case "detach": {
2486
+ if (this.attachment?.workspaceId === message.workspaceId) {
2487
+ this.teardownAttachment("closed");
2488
+ }
2489
+ return;
2490
+ }
2491
+ case "input": {
2492
+ if (this.attachment?.workspaceId === message.workspaceId) {
2493
+ this.attachment.session.write(Buffer.from(message.data, "base64").toString("utf8"));
2494
+ }
2495
+ return;
2496
+ }
2497
+ case "resize": {
2498
+ if (this.attachment?.workspaceId === message.workspaceId) {
2499
+ this.attachment.session.resize(message.cols, message.rows);
2500
+ }
2501
+ return;
2502
+ }
2503
+ case "putFile": {
2504
+ const projectPath = await this.workspacePath(message.workspaceId);
2505
+ if (!projectPath) {
2506
+ this.conn.send({ type: "error", code: "unknown_workspace", message: `unknown workspace: ${message.workspaceId}` });
2507
+ return;
2508
+ }
2509
+ const bytes = new Uint8Array(Buffer.from(message.data, "base64"));
2510
+ const path = await storePastedFile({ writer: this.deps.writer }, { projectPath, name: message.name, bytes });
2511
+ this.conn.send({ type: "fileStored", workspaceId: message.workspaceId, path });
2512
+ return;
2513
+ }
2514
+ case "voiceStart": {
2515
+ const { workspaceId, id } = message;
2516
+ this.cancelVoice("Superseded by a newer dictation");
2517
+ const voice = { id, workspaceId, session: null, queue: [], ended: false, cancelled: null };
2518
+ this.voice = voice;
2519
+ const reply = (error) => {
2520
+ if (this.voice === voice) this.voice = null;
2521
+ this.conn.send(error ? { type: "voiceDone", id, ok: false, error } : { type: "voiceDone", id, ok: true });
2522
+ };
2523
+ if (!this.deps.claudeVoice) return reply("Claude voice needs a Linux machine with the voice loopback set up");
2524
+ if (this.attachment?.workspaceId !== workspaceId) return reply("Open the workspace before dictating");
2525
+ const tmuxSession = (await this.deps.tmux.listSessions(this.deps.config.sessionPrefix)).find((s) => s.name === workspaceId);
2526
+ if (!tmuxSession || !runsClaude(tmuxSession.command)) return reply("Claude voice works only in a Claude Code workspace");
2527
+ if (tmuxSession.program?.state === "blocked") return reply("Answer Claude\u2019s question first, then dictate");
2528
+ if (voice.cancelled) return reply(voice.cancelled);
2529
+ const session = this.deps.claudeVoice.start(() => {
2530
+ if (this.attachment?.workspaceId === workspaceId) this.attachment.session.write(" ");
2531
+ });
2532
+ voice.session = session;
2533
+ for (const pcm of voice.queue.splice(0)) session.write(pcm);
2534
+ if (voice.ended) session.end();
2535
+ void session.done.then(reply);
2536
+ return;
2537
+ }
2538
+ case "voiceChunk": {
2539
+ const voice = this.voice;
2540
+ if (voice?.id !== message.id || voice.ended) return;
2541
+ const pcm = new Uint8Array(Buffer.from(message.data, "base64"));
2542
+ if (voice.session) voice.session.write(pcm);
2543
+ else voice.queue.push(pcm);
2544
+ return;
2545
+ }
2546
+ case "voiceEnd": {
2547
+ const voice = this.voice;
2548
+ if (voice?.id !== message.id) return;
2549
+ voice.ended = true;
2550
+ voice.session?.end();
2551
+ return;
2552
+ }
2553
+ case "ping": {
2554
+ this.conn.send({ type: "pong" });
2555
+ return;
2556
+ }
2557
+ case "startBrowser": {
2558
+ try {
2559
+ await this.deps.browserCommands.start(message.workspaceId);
2560
+ } catch (err) {
2561
+ this.conn.send({ type: "error", code: "start-browser-failed", message: errMsg(err) });
2562
+ return;
2563
+ }
2564
+ await this.sendBrowserSessions();
2565
+ return;
2566
+ }
2567
+ case "readMedia": {
2568
+ const { id, path } = message;
2569
+ await this.media.request(
2570
+ id,
2571
+ async () => readServableFile({ reader: this.deps.reader }, path, await this.allowedRoots())
2572
+ );
2573
+ return;
2574
+ }
2575
+ case "mediaAck":
2576
+ this.media.ack(message.id, message.seq);
2577
+ return;
2578
+ case "mediaCancel":
2579
+ this.media.cancel(message.id);
2580
+ return;
2581
+ case "browserOpen":
2582
+ this.browsers.open(message.stream, message.session);
2583
+ return;
2584
+ case "browser":
2585
+ this.browsers.deliver(message.stream, message.data);
2586
+ return;
2587
+ case "browserAck":
2588
+ this.browsers.ack(message.stream);
2589
+ return;
2590
+ case "browserClose":
2591
+ this.browsers.close(message.stream);
2592
+ return;
2593
+ case "clientLog": {
2594
+ let ok = JSON.stringify(message.batch).length <= CLIENT_LOG_MAX_BYTES;
2595
+ if (ok) ok = await this.deps.clientLog.append(message.batch).then(() => true, () => false);
2596
+ this.conn.send({ type: "clientLogAck", id: message.id, ok });
2597
+ return;
2598
+ }
2599
+ default: {
2600
+ const exhaustive = message;
2601
+ void exhaustive;
2602
+ this.conn.send({ type: "error", code: "unsupported", message: "unhandled message" });
2603
+ }
2604
+ }
2605
+ }
2606
+ // Names only — the stream port stays agent-side (the PWA reaches a stream through a
2607
+ // `browserOpen` side channel in its session, never the upstream port directly). Discovery failure degrades to an
2608
+ // empty list so a broken agent-browser install can never break the `list` reply.
2609
+ async sendBrowserSessions() {
2610
+ let sessions = [];
2611
+ try {
2612
+ sessions = (await this.deps.browserDiscovery.listSessions()).map(({ name }) => ({ name }));
2613
+ } catch {
2614
+ }
2615
+ this.conn.send({ type: "browserSessions", sessions });
2616
+ }
2617
+ // The set of paths a client may browse/read: the configured roots ∪ the live workspaces'
2618
+ // project paths, so a workspace's own tree is reachable even outside TARFA_PROJECT_ROOTS.
2619
+ async sendLaunchers() {
2620
+ const [detected, custom] = await Promise.all([this.deps.detectInstalled(KNOWN_CLIS), this.deps.launchers.list()]);
2621
+ this.conn.send({ type: "launchers", detected, custom });
2622
+ }
2623
+ async allowedRoots() {
2624
+ const { config, tmux, status } = this.deps;
2625
+ const workspaces = await listWorkspaces({
2626
+ tmux,
2627
+ machineId: config.machineId,
2628
+ sessionPrefix: config.sessionPrefix,
2629
+ status,
2630
+ messagedLast: this.deps.messagedLast
2631
+ });
2632
+ return [...config.projectRoots, ...workspaces.map((w) => w.projectPath)];
2633
+ }
2634
+ // The live workspace matching this id, freshly queried from tmux — used as a fallback
2635
+ // when no poll has observed this workspace yet (e.g. it was created and closed within
2636
+ // the same title-poll tick).
2637
+ async snapshotWorkspace(workspaceId) {
2638
+ const { config, tmux, status } = this.deps;
2639
+ const workspaces = await listWorkspaces({
2640
+ tmux,
2641
+ machineId: config.machineId,
2642
+ sessionPrefix: config.sessionPrefix,
2643
+ status,
2644
+ messagedLast: this.deps.messagedLast
2645
+ });
2646
+ return workspaces.find((w) => w.id === workspaceId);
2647
+ }
2648
+ // The cwd a workspace runs in (its projectPath), or undefined if no live session has that
2649
+ // id. Used to anchor a pasted file under the workspace's own `.tmp/files/`.
2650
+ async workspacePath(workspaceId) {
2651
+ return (await this.snapshotWorkspace(workspaceId))?.projectPath;
2652
+ }
2653
+ attach(workspaceId, cols, rows) {
2654
+ if (this.attachment) {
2655
+ this.teardownAttachment("closed");
2656
+ }
2657
+ const session = this.deps.pty.spawn({
2658
+ command: "tmux",
2659
+ args: ["attach-session", "-t", exactTarget(workspaceId)],
2660
+ cols,
2661
+ rows
2662
+ });
2663
+ const viewer = {
2664
+ detach: () => {
2665
+ if (this.attachment?.session !== session) return;
2666
+ this.cancelVoice("The workspace was opened on another device");
2667
+ this.attachment.terminated = true;
2668
+ session.kill();
2669
+ this.attachment = null;
2670
+ this.conn.send({ type: "detached", workspaceId, reason: "opened-elsewhere" });
2671
+ }
2672
+ };
2673
+ const att = { workspaceId, session, terminated: false, viewer };
2674
+ this.attachment = att;
2675
+ this.deps.registry.acquire(workspaceId, viewer);
2676
+ session.onData((data) => {
2677
+ this.conn.send({
2678
+ type: "output",
2679
+ workspaceId,
2680
+ data: Buffer.from(data, "utf8").toString("base64")
2681
+ });
2682
+ });
2683
+ session.onExit(() => {
2684
+ if (att.terminated) return;
2685
+ if (this.attachment === att) {
2686
+ this.cancelVoice("The workspace closed");
2687
+ this.attachment = null;
2688
+ }
2689
+ this.deps.registry.release(workspaceId, viewer);
2690
+ this.conn.send({ type: "detached", workspaceId, reason: "closed" });
2691
+ });
2692
+ this.conn.send({ type: "attached", workspaceId });
2693
+ }
2694
+ // A dictation lives only as long as its attachment: without one the held spaces go nowhere,
2695
+ // and the machine's one dictation slot would stay taken.
2696
+ cancelVoice(reason) {
2697
+ const voice = this.voice;
2698
+ if (!voice) return;
2699
+ this.voice = null;
2700
+ voice.cancelled = reason;
2701
+ voice.session?.abort(reason);
2702
+ }
2703
+ teardownAttachment(reason) {
2704
+ const att = this.attachment;
2705
+ if (!att) return;
2706
+ this.cancelVoice("The workspace was closed");
2707
+ att.terminated = true;
2708
+ att.session.kill();
2709
+ this.deps.registry.release(att.workspaceId, att.viewer);
2710
+ this.attachment = null;
2711
+ this.conn.send({ type: "detached", workspaceId: att.workspaceId, reason });
2712
+ }
2713
+ };
2714
+ function errMsg(err) {
2715
+ return err instanceof Error ? err.message : String(err);
2716
+ }
2717
+
2718
+ // src/server/viewer-registry.ts
2719
+ var ViewerRegistry = class {
2720
+ holders = /* @__PURE__ */ new Map();
2721
+ acquire(workspaceId, viewer) {
2722
+ const current = this.holders.get(workspaceId);
2723
+ if (current && current !== viewer) {
2724
+ current.detach();
2725
+ }
2726
+ this.holders.set(workspaceId, viewer);
2727
+ }
2728
+ release(workspaceId, viewer) {
2729
+ if (this.holders.get(workspaceId) === viewer) {
2730
+ this.holders.delete(workspaceId);
2731
+ }
2732
+ }
2733
+ };
2734
+
2735
+ // src/application/attention-decision.ts
2736
+ var WORK_TYPES = /* @__PURE__ */ new Set(["shell", "subagent", "workflow", "teammate", "cloud session"]);
2737
+ function isWaitedOnTask(task) {
2738
+ if (typeof task !== "object" || task === null) return false;
2739
+ const { status, type, server } = task;
2740
+ if (status !== "running" && status !== "pending") return false;
2741
+ return typeof type === "string" && WORK_TYPES.has(type) || typeof server === "string";
2742
+ }
2743
+ function hasRunningBackgroundTask(claude) {
2744
+ if (typeof claude !== "object" || claude === null) return false;
2745
+ const tasks = claude.background_tasks;
2746
+ return Array.isArray(tasks) && tasks.some(isWaitedOnTask);
2747
+ }
2748
+ function isIdlePrompt(claude) {
2749
+ if (typeof claude !== "object" || claude === null) return false;
2750
+ return claude.notification_type === "idle_prompt";
2751
+ }
2752
+ function isSubagentToolCall(claude) {
2753
+ if (typeof claude !== "object" || claude === null) return false;
2754
+ return typeof claude.agent_id === "string";
2755
+ }
2756
+ var BLOCKING_TOOLS = /* @__PURE__ */ new Set(["AskUserQuestion", "ExitPlanMode"]);
2757
+ function isBlockingTool(claude) {
2758
+ if (typeof claude !== "object" || claude === null) return false;
2759
+ const name = claude.tool_name;
2760
+ return typeof name === "string" && BLOCKING_TOOLS.has(name);
2761
+ }
2762
+ function isCodexQuestion(claude) {
2763
+ if (typeof claude !== "object" || claude === null) return false;
2764
+ const name = claude.tool_name;
2765
+ return typeof name === "string" && name.startsWith("request_user_input");
2766
+ }
2767
+ function isPeerMessage(claude) {
2768
+ if (typeof claude !== "object" || claude === null) return false;
2769
+ return claude.tool_name === "SendMessage";
2770
+ }
2771
+ var NEW_SESSION_SOURCES = /* @__PURE__ */ new Set(["startup", "resume", "clear", "fork"]);
2772
+ function startsNewSession(claude) {
2773
+ if (typeof claude !== "object" || claude === null) return false;
2774
+ const source = claude.source;
2775
+ return typeof source === "string" && NEW_SESSION_SOURCES.has(source);
2776
+ }
2777
+ var DROP_MARKS = { deferral: "clear", messagedLast: "clear", question: "clear" };
2778
+ function attentionDecision(input) {
2779
+ const endsQuestion = input.questionPending ? { question: "clear" } : {};
2780
+ const holdWorking = input.endedByOsc ? "working" : null;
2781
+ switch (input.event) {
2782
+ case "Stop":
2783
+ if (hasRunningBackgroundTask(input.claude)) {
2784
+ return { reason: "stop-background", status: holdWorking, deferral: "set", ...endsQuestion };
2785
+ }
2786
+ return input.messagedLast ? { reason: "stop-messaged", status: holdWorking, deferral: "set", ...endsQuestion } : { reason: "stop", status: "needs-feedback", deferral: "clear", ...endsQuestion };
2787
+ // Fired instead of Stop when an API error ends the turn.
2788
+ case "StopFailure":
2789
+ return { reason: "stop-failure", status: "needs-feedback", ...DROP_MARKS };
2790
+ case "SessionStart":
2791
+ if (!startsNewSession(input.claude)) return { reason: "compact", status: null, deferral: "keep" };
2792
+ return {
2793
+ reason: "session-start",
2794
+ status: input.currentStatus === "working" ? "needs-feedback" : null,
2795
+ ...DROP_MARKS
2796
+ };
2797
+ case "Notification":
2798
+ return isIdlePrompt(input.claude) && input.wasDeferred ? { reason: "idle-suppressed", status: null, deferral: "keep" } : { reason: "notification", status: "needs-feedback", deferral: "clear" };
2799
+ case "UserPromptSubmit":
2800
+ return { reason: "prompt", status: "working", deferral: "clear", messagedLast: "clear", ...endsQuestion };
2801
+ // Codex asks before showing an approval prompt. A subagent's request still asks the user.
2802
+ // With an automatic reviewer it fires too, and PostToolUse puts the status back a moment later.
2803
+ case "PermissionRequest":
2804
+ return { reason: "permission", status: "needs-feedback", deferral: "keep" };
2805
+ // A background subagent's own tool calls fire these in the parent session after its Stop,
2806
+ // so they must not clear the deferral — and must not speak for the parent's status at all.
2807
+ case "PreToolUse":
2808
+ case "PostToolUse":
2809
+ if (isSubagentToolCall(input.claude)) return { reason: "subagent-tool", status: null, deferral: "keep" };
2810
+ if (input.questionPending) return { reason: "question-pending", status: null, deferral: "keep" };
2811
+ if (input.event === "PreToolUse" && isCodexQuestion(input.claude)) {
2812
+ return { reason: "question", status: "needs-feedback", deferral: "keep", question: "set" };
2813
+ }
2814
+ if (input.event === "PreToolUse" && isBlockingTool(input.claude)) {
2815
+ return { reason: "blocking-tool", status: "needs-feedback", deferral: "clear" };
2816
+ }
2817
+ if (input.event === "PostToolUse") {
2818
+ if (input.sentDuringTool && !isPeerMessage(input.claude)) {
2819
+ return { reason: "tarfa-send", status: "working", deferral: "keep" };
2820
+ }
2821
+ return {
2822
+ reason: "tool",
2823
+ status: "working",
2824
+ deferral: "keep",
2825
+ messagedLast: isPeerMessage(input.claude) ? "set" : "clear"
2826
+ };
2827
+ }
2828
+ return { reason: "tool", status: "working", deferral: "keep", ...input.sentDuringTool ? {} : { messagedLast: "clear" } };
2829
+ default:
2830
+ return { reason: "unknown-event", status: null, deferral: "keep" };
2831
+ }
2832
+ }
2833
+
2834
+ // src/application/apply-hook-event.ts
2835
+ function field(claude, key) {
2836
+ if (typeof claude !== "object" || claude === null) return void 0;
2837
+ const value = claude[key];
2838
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2839
+ }
2840
+ function extractSessionId(claude) {
2841
+ return field(claude, "session_id");
2842
+ }
2843
+ var MAX_LOGGED_TASKS = 20;
2844
+ var MAX_LOGGED_CHARS = 200;
2845
+ function trim(value) {
2846
+ return value.length > MAX_LOGGED_CHARS ? `${value.slice(0, MAX_LOGGED_CHARS)}\u2026` : value;
2847
+ }
2848
+ function errorField(claude) {
2849
+ if (typeof claude !== "object" || claude === null) return void 0;
2850
+ const value = claude.error;
2851
+ if (value === void 0 || value === null) return void 0;
2852
+ return typeof value === "string" ? value : JSON.stringify(value);
2853
+ }
2854
+ function loggedTasks(claude) {
2855
+ if (typeof claude !== "object" || claude === null) return void 0;
2856
+ const tasks = claude.background_tasks;
2857
+ if (!Array.isArray(tasks)) return void 0;
2858
+ return tasks.slice(0, MAX_LOGGED_TASKS).map(
2859
+ (task) => typeof task === "object" && task !== null ? Object.fromEntries(Object.entries(task).map(([k, v]) => [k, typeof v === "string" ? trim(v) : v])) : task
2860
+ );
2861
+ }
2862
+ function logEntry(input, decision, before, after) {
2863
+ const { claude } = input;
2864
+ const error = errorField(claude);
2865
+ const entry = {
2866
+ workspace: input.sessionName,
2867
+ event: input.event,
2868
+ agent: input.agent,
2869
+ sessionId: field(claude, "session_id"),
2870
+ tool: field(claude, "tool_name"),
2871
+ notificationType: field(claude, "notification_type"),
2872
+ source: field(claude, "source"),
2873
+ subagent: field(claude, "agent_id") ? true : void 0,
2874
+ backgroundTasks: loggedTasks(claude),
2875
+ error: error && trim(error),
2876
+ reason: decision.reason,
2877
+ before,
2878
+ after
2879
+ };
2880
+ return Object.fromEntries(Object.entries(entry).filter(([, v]) => v !== void 0));
2881
+ }
2882
+ function markSent(marks2, id) {
2883
+ marks2.sentDuringTool.add(id);
2884
+ marks2.messagedLast.add(id);
2885
+ }
2886
+ function state(marks2, status, id) {
2887
+ return {
2888
+ ...status ? { status } : {},
2889
+ deferred: marks2.deferred.has(id),
2890
+ messagedLast: marks2.messagedLast.has(id),
2891
+ questionPending: marks2.questionPending.has(id)
2892
+ };
2893
+ }
2894
+ function applyHookEvent(deps, input) {
2895
+ const sessionId = input.agent === "codex" ? void 0 : extractSessionId(input.claude);
2896
+ if (sessionId) {
2897
+ deps.store.setClaudeSessionId(input.sessionName, sessionId);
2898
+ }
2899
+ const currentStatus = deps.store.get(input.sessionName);
2900
+ const before = state(deps, currentStatus, input.sessionName);
2901
+ const decision = attentionDecision({
2902
+ event: input.event,
2903
+ claude: input.claude,
2904
+ wasDeferred: before.deferred,
2905
+ messagedLast: before.messagedLast,
2906
+ sentDuringTool: deps.sentDuringTool.has(input.sessionName),
2907
+ questionPending: before.questionPending,
2908
+ currentStatus,
2909
+ endedByOsc: deps.oscTurnEnded?.has(input.sessionName)
2910
+ });
2911
+ if (decision.deferral === "set") {
2912
+ deps.deferred.add(input.sessionName);
2913
+ } else if (decision.deferral === "clear") {
2914
+ deps.deferred.delete(input.sessionName);
2915
+ }
2916
+ const startsCodexCall = input.agent === "codex" && input.event === "PreToolUse" && decision.reason === "tool";
2917
+ if (startsCodexCall || decision.reason === "tarfa-send" && input.agent !== "codex") {
2918
+ deps.sentDuringTool.delete(input.sessionName);
2919
+ }
2920
+ if (startsCodexCall) deps.messagedLast.delete(input.sessionName);
2921
+ if (decision.messagedLast === "set") {
2922
+ deps.messagedLast.add(input.sessionName);
2923
+ deps.sentDuringTool.delete(input.sessionName);
2924
+ } else if (decision.messagedLast === "clear") {
2925
+ deps.messagedLast.delete(input.sessionName);
2926
+ deps.sentDuringTool.delete(input.sessionName);
2927
+ }
2928
+ if (decision.question === "set") {
2929
+ deps.questionPending.add(input.sessionName);
2930
+ } else if (decision.question === "clear") {
2931
+ deps.questionPending.delete(input.sessionName);
2932
+ }
2933
+ if (decision.status) {
2934
+ deps.store.set(input.sessionName, decision.status);
2935
+ deps.oscTurnEnded?.delete(input.sessionName);
2936
+ }
2937
+ if (deps.log) {
2938
+ const after = state(deps, decision.status ?? currentStatus, input.sessionName);
2939
+ deps.log.append(logEntry(input, decision, before, after)).catch(() => void 0);
2940
+ }
2941
+ return decision.status ? { workspaceId: input.sessionName, status: decision.status } : null;
2942
+ }
2943
+
2944
+ // src/application/send-message.ts
2945
+ import { randomBytes } from "node:crypto";
2946
+ function headerSafe(name) {
2947
+ return name.replace(/[\p{Cc}"]/gu, " ").replace(/\s+/g, " ").trim();
2948
+ }
2949
+ async function sendMessage(deps, req) {
2950
+ const bytes = Buffer.byteLength(req.body, "utf8");
2951
+ let from = "";
2952
+ let relation = "peer";
2953
+ let cli = null;
2954
+ const refuse = async (reason) => {
2955
+ await record("refused", reason);
2956
+ return { result: "refused", reason };
2957
+ };
2958
+ const record = async (outcome, reason) => {
2959
+ try {
2960
+ await deps.log.append({
2961
+ at: deps.now(),
2962
+ from,
2963
+ to: req.to,
2964
+ relation,
2965
+ cli,
2966
+ outcome,
2967
+ ...reason !== void 0 ? { reason } : {},
2968
+ bytes
2969
+ });
2970
+ } catch {
2971
+ }
2972
+ };
2973
+ if (req.body.trim() === "") return refuse("empty message");
2974
+ const sender = req.codexThread !== void 0 ? sessionOfCodexThread(await deps.tmux.listSessions(deps.sessionPrefix), req.codexThread) : await resolveSender(deps, req.pane, req.pid);
2975
+ if (!sender) return refuse("not run from a Tarfa workspace");
2976
+ from = sender.name;
2977
+ if (req.to === sender.name) return refuse("cannot message yourself");
2978
+ const sessions = await deps.tmux.listSessions(deps.sessionPrefix);
2979
+ const recipient = sessions.find((s) => s.name === req.to);
2980
+ if (!recipient) {
2981
+ if (await deps.parked.get(req.to)) return refuse(`${req.to} is parked; run tarfa unpark ${req.to} first`);
2982
+ return refuse(`no workspace ${req.to} on this machine`);
2983
+ }
2984
+ relation = relationOf(
2985
+ { id: sender.name, parentId: sender.parent },
2986
+ { id: recipient.name, parentId: recipient.parent }
2987
+ );
2988
+ cli = cliOfCommand(recipient.command);
2989
+ if (!cli) return refuse(`${req.to} runs no claude, codex or pi`);
2990
+ if (isNoDaemonCodex(recipient.command)) return refuse(`${req.to} was started with --no-daemon`);
2991
+ const text = formatAgentMessage({
2992
+ from: {
2993
+ id: sender.name,
2994
+ name: headerSafe(deriveWorkspaceName(sender.title, sender.startPath, sender.command, sender.name)),
2995
+ cli: cliOfCommand(sender.command) ?? "shell"
2996
+ },
2997
+ relation,
2998
+ machineId: deps.machineId,
2999
+ msgId: (deps.messageId ?? (() => randomBytes(4).toString("hex")))(),
3000
+ body: req.body
3001
+ });
3002
+ const delivered = await deps.delivery.deliver(
3003
+ {
3004
+ id: recipient.name,
3005
+ from: sender.name,
3006
+ cli,
3007
+ command: recipient.command,
3008
+ ...recipient.codexThread ? { codexThread: recipient.codexThread } : {}
3009
+ },
3010
+ text
3011
+ );
3012
+ if (!delivered.ok) return refuse(`delivery failed: ${delivered.reason}`);
3013
+ await record("delivered");
3014
+ deps.onSent(sender.name);
3015
+ return { result: "delivered" };
3016
+ }
3017
+ async function resolveSender(deps, pane, pid) {
3018
+ if (!deps.tmux.sessionOfPane || !deps.tmux.pidOfPane) return null;
3019
+ const name = await deps.tmux.sessionOfPane(pane);
3020
+ if (!name) return null;
3021
+ const session = (await deps.tmux.listSessions(deps.sessionPrefix)).find((s) => s.name === name);
3022
+ if (!session) return null;
3023
+ const panePid = await deps.tmux.pidOfPane(pane);
3024
+ if (panePid === null || !await deps.ancestry.isAncestor(panePid, pid)) return null;
3025
+ return session;
3026
+ }
3027
+
3028
+ // src/application/program-status-decision.ts
3029
+ function programStatusDecision(input) {
3030
+ if (input.currentStatus === "finished" || input.currentStatus === "parked") {
3031
+ return { reason: "osc-manual", status: null };
3032
+ }
3033
+ switch (input.state) {
3034
+ case "working":
3035
+ return { reason: "osc-working", status: "working" };
3036
+ case "blocked":
3037
+ return { reason: "osc-blocked", status: "needs-feedback" };
3038
+ case "done":
3039
+ case "error":
3040
+ if (input.deferred) return { reason: "osc-deferred", status: null };
3041
+ if (input.messagedLast) return { reason: "osc-messaged", status: null };
3042
+ return { reason: "osc-done", status: "needs-feedback" };
3043
+ // An Esc interrupt, or Codex's turn end (it says `idle`, not `done`): either way the session
3044
+ // waits on the user. Only a session still marked working is moved, so a hook's own
3045
+ // needs-feedback is never second-guessed.
3046
+ case "idle":
3047
+ return input.currentStatus === "working" ? { reason: "osc-idle", status: "needs-feedback", deferral: "clear" } : { reason: "osc-idle", status: null };
3048
+ default:
3049
+ return { reason: "osc-unknown", status: null };
3050
+ }
3051
+ }
3052
+
3053
+ // src/application/apply-program-status.ts
3054
+ function marks(deps, status, id) {
3055
+ return {
3056
+ ...status ? { status } : {},
3057
+ deferred: deps.deferred.has(id),
3058
+ messagedLast: deps.messagedLast.has(id),
3059
+ questionPending: deps.questionPending.has(id)
3060
+ };
3061
+ }
3062
+ function applyProgramStatus(deps, panes) {
3063
+ const changed = [];
3064
+ const present = /* @__PURE__ */ new Set();
3065
+ for (const { name, program } of panes) {
3066
+ if (!program) continue;
3067
+ present.add(name);
3068
+ const key = `${program.state}@${program.changedAt}`;
3069
+ const previous = deps.seen.get(name);
3070
+ deps.seen.set(name, key);
3071
+ if (previous === void 0 || previous === key) continue;
3072
+ const currentStatus = deps.store.get(name);
3073
+ const before = marks(deps, currentStatus, name);
3074
+ const decision = programStatusDecision({
3075
+ state: program.state,
3076
+ currentStatus,
3077
+ deferred: before.deferred,
3078
+ messagedLast: before.messagedLast
3079
+ });
3080
+ if (decision.deferral === "clear") deps.deferred.delete(name);
3081
+ const turnEnd = decision.reason === "osc-done" || decision.reason === "osc-idle";
3082
+ if (decision.status && !turnEnd) deps.oscTurnEnded.delete(name);
3083
+ if (decision.status && decision.status !== currentStatus) {
3084
+ deps.store.set(name, decision.status);
3085
+ if (turnEnd) deps.oscTurnEnded.add(name);
3086
+ changed.push(name);
3087
+ }
3088
+ deps.log?.append({
3089
+ workspace: name,
3090
+ event: "OSC7501",
3091
+ programState: program.state,
3092
+ reason: decision.reason,
3093
+ before,
3094
+ after: marks(deps, decision.status ?? currentStatus, name)
3095
+ }).catch(() => void 0);
3096
+ }
3097
+ for (const name of deps.seen.keys()) {
3098
+ if (!present.has(name)) deps.seen.delete(name);
3099
+ }
3100
+ return changed;
3101
+ }
3102
+
3103
+ // src/server/ws-server.ts
3104
+ var DEFAULT_TITLE_POLL_MS = 2e3;
3105
+ var DEFAULT_TICK_DEADLINE_MS = 3e4;
3106
+ var DEFAULT_STATS_POLL_MS = 5e3;
3107
+ var CLOSING_MS = 1e4;
3108
+ var LOOPBACK = "127.0.0.1";
3109
+ var HOOK_BODY_MAX_BYTES = 1024 * 1024;
3110
+ var WsServer = class {
3111
+ constructor(deps) {
3112
+ this.deps = deps;
3113
+ }
3114
+ deps;
3115
+ http = null;
3116
+ registry = new ViewerRegistry();
3117
+ // Separate registry for browser viewers: keys are session names, which could collide
3118
+ // with the terminal registry's workspace ids (sessions are named after workspaces).
3119
+ browserViewers = new ViewerRegistry();
3120
+ handlers = /* @__PURE__ */ new Set();
3121
+ phoneClients = /* @__PURE__ */ new Set();
3122
+ titlePoll = null;
3123
+ tickRunning = false;
3124
+ statsPoll = null;
3125
+ lastWorkspaces = /* @__PURE__ */ new Map();
3126
+ // Workspaces an explicit close has forgotten while tmux may still list them (the kill is async),
3127
+ // with when the mark lapses: pollTitles must not take them back meanwhile. Dropped once tmux no
3128
+ // longer lists them, or after CLOSING_MS, so a kill that never lands cannot hide one for good.
3129
+ closing = /* @__PURE__ */ new Map();
3130
+ lastBrowserNames = /* @__PURE__ */ new Set();
3131
+ // Sessions parked on background work or on a peer session's reply, so the idle notification
3132
+ // that follows their Stop can be told apart from a genuine one. In-memory on purpose — see
3133
+ // applyHookEvent.
3134
+ deferred = /* @__PURE__ */ new Set();
3135
+ // Sessions whose last main-loop tool call was SendMessage — see applyHookEvent. Kept in the
3136
+ // status store rather than here, so it survives a restart and `tarfa ls` can read it.
3137
+ messagedLast = {
3138
+ has: (id) => this.deps.status.getMessagedLast(id),
3139
+ add: (id) => this.deps.status.setMessagedLast(id, true),
3140
+ delete: (id) => this.deps.status.setMessagedLast(id, false)
3141
+ };
3142
+ // Sessions that ran tarfa send inside their current tool call — see markSent.
3143
+ sentDuringTool = /* @__PURE__ */ new Set();
3144
+ // Codex sessions with a question waiting on the user — see applyHookEvent.
3145
+ questionPending = /* @__PURE__ */ new Set();
3146
+ // Sessions whose needs-feedback an OSC 7501 turn end set — see applyProgramStatus. In-memory:
3147
+ // losing it costs one race won by needs-feedback, never a hidden prompt.
3148
+ oscTurnEnded = /* @__PURE__ */ new Set();
3149
+ // Hooks posted by Codex thread, applied one after another in arrival order.
3150
+ threadHooks = Promise.resolve();
3151
+ // The panes showing a question or menu at the last poll.
3152
+ asking = /* @__PURE__ */ new Set();
3153
+ // The last OSC 7501 record seen per pane, so only a change is applied (applyProgramStatus).
3154
+ programSeen = /* @__PURE__ */ new Map();
3155
+ // Removes and returns the last-known snapshot for a workspace id, if the poll has ever
3156
+ // observed it. Used by ConnectionHandler on an explicit close, both to build the log
3157
+ // entry (the session is about to be killed and tmux won't be able to answer afterward)
3158
+ // and to keep the disappearance poll from separately logging it as `exited`. The tmux kill
3159
+ // is async, so a poll tick in that gap still lists the session: `closing` keeps the poll
3160
+ // from taking it back, which would log a duplicate `exited` once the kill lands. Only a close
3161
+ // sets it: a park also forgets the workspace, but its parked record keeps it listed and an
3162
+ // unpark brings the same id back, which the poll must go on following.
3163
+ forgetWorkspace(id, closing = false) {
3164
+ const workspace = this.lastWorkspaces.get(id);
3165
+ this.lastWorkspaces.delete(id);
3166
+ if (closing) this.closing.set(id, this.deps.clock.now() + CLOSING_MS);
3167
+ void this.deps.liveSnapshot?.remove(id).catch(() => void 0);
3168
+ return workspace;
3169
+ }
3170
+ // Called once a workspace has stopped and its close is logged. Ids are reused, so nothing
3171
+ // keyed by this one may reach the next workspace that takes it: a stale `parked` status, an
3172
+ // old Claude session id that unpark would resume, a deferral that would hide a "Needs you".
3173
+ dropWorkspaceState(id) {
3174
+ this.closing.delete(id);
3175
+ this.deps.status.forget(id);
3176
+ this.deferred.delete(id);
3177
+ this.questionPending.delete(id);
3178
+ this.sentDuringTool.delete(id);
3179
+ this.oscTurnEnded.delete(id);
3180
+ this.programSeen.delete(id);
3181
+ }
3182
+ // pollTitles logs and drops a workspace it saw go, but it runs only while a phone is connected
3183
+ // and never follows a hidden folder. This drops the rest, so an exited workspace's number is
3184
+ // free again without an agent restart. The ids are read before tmux is listed: an entry a hook
3185
+ // writes in between belongs to a session that already exists.
3186
+ async dropGoneWorkspaces() {
3187
+ const known = /* @__PURE__ */ new Set([...this.deps.status.ids(), ...this.deferred, ...this.questionPending, ...this.oscTurnEnded]);
3188
+ if (known.size === 0) return;
3189
+ let present;
3190
+ try {
3191
+ const sessions = await this.deps.tmux.listSessions(this.deps.config.sessionPrefix);
3192
+ const parked = await this.deps.parked.list();
3193
+ present = /* @__PURE__ */ new Set([...sessions.map((s) => s.name), ...parked.map((p) => p.id)]);
3194
+ } catch {
3195
+ return;
3196
+ }
3197
+ for (const id of known) {
3198
+ if (!present.has(id) && !this.lastWorkspaces.has(id)) this.dropWorkspaceState(id);
3199
+ }
3200
+ }
3201
+ // A device was just enrolled: every phone connected here hears of it, except that device.
3202
+ broadcastDeviceEnrolled(device) {
3203
+ for (const handler of this.handlers) handler.pushDeviceEnrolled(device);
3204
+ }
3205
+ // Pushes a `closed` message to every connected device — used when a workspace is closed
3206
+ // explicitly, so devices other than the requester learn about it immediately instead of
3207
+ // waiting for the next title-poll tick (which no longer fires it for this id, since
3208
+ // forgetWorkspace already removed it from the tracking map before the poll runs).
3209
+ broadcastClosed(id) {
3210
+ for (const handler of this.handlers) {
3211
+ handler.pushClosed(id);
3212
+ }
3213
+ }
3214
+ // Samples the machine's load and pushes it to every connected device. Skipped entirely
3215
+ // while nobody is watching, and best-effort throughout: the interval is fire-and-forget,
3216
+ // so a rejection here would be both silent and permanent.
3217
+ async pollStats() {
3218
+ if (this.handlers.size === 0) return;
3219
+ let stats;
3220
+ try {
3221
+ stats = await this.deps.systemStats?.read();
3222
+ } catch {
3223
+ return;
3224
+ }
3225
+ if (!stats) return;
3226
+ for (const handler of this.handlers) {
3227
+ handler.pushStats(stats);
3228
+ }
3229
+ }
3230
+ start() {
3231
+ if (this.http) {
3232
+ return Promise.reject(new Error("server already started"));
3233
+ }
3234
+ this.deps.status.onChange((workspaceId) => {
3235
+ void this.broadcastWorkspace(workspaceId);
3236
+ });
3237
+ this.titlePoll = setInterval(() => void this.pollTick(), this.deps.titlePollMs ?? DEFAULT_TITLE_POLL_MS);
3238
+ this.titlePoll.unref?.();
3239
+ if (this.deps.systemStats) {
3240
+ this.statsPoll = setInterval(() => {
3241
+ void this.pollStats();
3242
+ }, this.deps.statsPollMs ?? DEFAULT_STATS_POLL_MS);
3243
+ this.statsPoll.unref?.();
3244
+ }
3245
+ return new Promise((resolve, reject) => {
3246
+ const http = createServer((req, res) => {
3247
+ this.handleHttp(req, res);
3248
+ });
3249
+ this.http = http;
3250
+ http.on("error", (err) => {
3251
+ this.http = null;
3252
+ reject(err);
3253
+ });
3254
+ http.listen(this.deps.config.port, LOOPBACK, () => {
3255
+ resolve();
3256
+ });
3257
+ });
3258
+ }
3259
+ // A socket a phone opened on the phone port, served only through the end-to-end Noise session
3260
+ // keyed by the agent's static X25519 key.
3261
+ acceptPhoneSocket(socket, staticPrivate, options) {
3262
+ const conn = new NoiseClientConnection(socket, staticPrivate, options);
3263
+ this.phoneClients.add(conn);
3264
+ conn.onClose(() => this.phoneClients.delete(conn));
3265
+ this.accept(conn);
3266
+ }
3267
+ // Closes the phone sessions of devices that have since been revoked. Only revocation closes a
3268
+ // live session: a device that is merely unknown here may be one whose enrolment is still being
3269
+ // written.
3270
+ refuseRevokedDevices(isRevoked) {
3271
+ for (const conn of this.phoneClients) {
3272
+ if (conn.device && isRevoked(conn.device)) conn.refuse("device revoked");
3273
+ }
3274
+ }
3275
+ // Serves one client connection. In production every one is a phone's Noise session
3276
+ // (acceptPhoneSocket); tests hand in their own.
3277
+ accept(conn) {
3278
+ const handler = new ConnectionHandler(conn, {
3279
+ ...this.deps,
3280
+ registry: this.registry,
3281
+ browserViewers: this.browserViewers,
3282
+ forgetWorkspace: (id, closing) => this.forgetWorkspace(id, closing),
3283
+ dropWorkspaceState: (id) => this.dropWorkspaceState(id),
3284
+ broadcastClosed: (id) => this.broadcastClosed(id),
3285
+ messagedLast: this.messagedLast
3286
+ });
3287
+ this.handlers.add(handler);
3288
+ conn.onClose(() => {
3289
+ this.handlers.delete(handler);
3290
+ });
3291
+ }
3292
+ // Four routes, all behind the hooks secret: `/hooks` for Claude Code's hook events; `/unpark` for
3293
+ // `tarfa unpark` and `/close` for `tarfa close`, which must go through the agent because it holds
3294
+ // status.json in memory, writes parked.json itself and tells the phones; and `/message` for
3295
+ // `tarfa send`, whose marks live here too.
3296
+ handleHttp(req, res) {
3297
+ const routes = this.deps.messaging ? ["/hooks", "/unpark", "/close", "/message"] : ["/hooks", "/unpark", "/close"];
3298
+ if (req.method !== "POST" || !routes.includes(req.url ?? "")) {
3299
+ res.writeHead(404).end();
3300
+ return;
3301
+ }
3302
+ const route = req.url;
3303
+ const token = this.deps.hookToken;
3304
+ const authorization = req.headers.authorization ?? "";
3305
+ if (!token || !constantTimeEqual(authorization, `Bearer ${token}`)) {
3306
+ res.writeHead(401).end();
3307
+ return;
3308
+ }
3309
+ let body = "";
3310
+ let bytes = 0;
3311
+ let refused = false;
3312
+ req.on("data", (chunk) => {
3313
+ if (refused) return;
3314
+ bytes += chunk.length;
3315
+ if (bytes > HOOK_BODY_MAX_BYTES) {
3316
+ refused = true;
3317
+ res.writeHead(413, { connection: "close" }).end();
3318
+ req.destroy();
3319
+ return;
3320
+ }
3321
+ body += chunk;
3322
+ });
3323
+ req.on("end", () => {
3324
+ if (refused) return;
3325
+ if (route === "/unpark") {
3326
+ void this.handleUnpark(body, res);
3327
+ return;
3328
+ }
3329
+ if (route === "/close") {
3330
+ void this.handleClose(body, res);
3331
+ return;
3332
+ }
3333
+ if (route === "/message") {
3334
+ void this.handleMessage(body, res);
3335
+ return;
3336
+ }
3337
+ try {
3338
+ const parsed = JSON.parse(body);
3339
+ const { sessionName, thread, event, claude, agent } = parsed;
3340
+ if (typeof event !== "string") {
3341
+ res.writeHead(400).end();
3342
+ return;
3343
+ }
3344
+ if (typeof sessionName === "string") {
3345
+ this.applyHook(sessionName, event, claude, agent);
3346
+ res.writeHead(204).end();
3347
+ return;
3348
+ }
3349
+ if (typeof thread !== "string") {
3350
+ res.writeHead(400).end();
3351
+ return;
3352
+ }
3353
+ if (agent !== "codex") {
3354
+ res.writeHead(204).end();
3355
+ return;
3356
+ }
3357
+ this.threadHooks = this.threadHooks.then(() => this.applyHookByThread(thread, event, claude, agent, res));
3358
+ } catch {
3359
+ res.writeHead(400).end();
3360
+ }
3361
+ });
3362
+ }
3363
+ // Codex under its daemon runs hooks outside every pane, so tarfa-hook.sh names the thread
3364
+ // instead; one no workspace recorded is dropped.
3365
+ async applyHookByThread(thread, event, claude, agent, res) {
3366
+ try {
3367
+ const session = sessionOfCodexThread(await this.deps.tmux.listSessions(this.deps.config.sessionPrefix), thread);
3368
+ if (session) this.applyHook(session.name, event, claude, agent);
3369
+ res.writeHead(204).end();
3370
+ } catch {
3371
+ res.writeHead(500).end();
3372
+ }
3373
+ }
3374
+ applyHook(sessionName, event, claude, agent) {
3375
+ const statusBefore = this.deps.status.get(sessionName);
3376
+ const messagedBefore = this.messagedLast.has(sessionName);
3377
+ applyHookEvent(
3378
+ {
3379
+ store: this.deps.status,
3380
+ deferred: this.deferred,
3381
+ messagedLast: this.messagedLast,
3382
+ sentDuringTool: this.sentDuringTool,
3383
+ questionPending: this.questionPending,
3384
+ oscTurnEnded: this.oscTurnEnded,
3385
+ log: this.deps.hookEventLog
3386
+ },
3387
+ { sessionName, event, claude, agent: typeof agent === "string" ? agent : void 0 }
3388
+ );
3389
+ if (this.messagedLast.has(sessionName) !== messagedBefore && this.deps.status.get(sessionName) === statusBefore) {
3390
+ void this.broadcastWorkspace(sessionName).catch(() => void 0);
3391
+ }
3392
+ }
3393
+ async handleUnpark(body, res) {
3394
+ let id;
3395
+ try {
3396
+ id = JSON.parse(body).id;
3397
+ } catch {
3398
+ id = void 0;
3399
+ }
3400
+ if (typeof id !== "string" || id === "") {
3401
+ res.writeHead(400).end();
3402
+ return;
3403
+ }
3404
+ try {
3405
+ const { parked, tmux, isDirectory, status } = this.deps;
3406
+ const result = await unparkWorkspace({ parked, tmux, isDirectory, status }, id);
3407
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ result }));
3408
+ } catch (err) {
3409
+ res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: err.message }));
3410
+ }
3411
+ }
3412
+ async handleClose(body, res) {
3413
+ let id;
3414
+ try {
3415
+ id = JSON.parse(body).id;
3416
+ } catch {
3417
+ id = void 0;
3418
+ }
3419
+ if (typeof id !== "string" || id === "") {
3420
+ res.writeHead(400).end();
3421
+ return;
3422
+ }
3423
+ const json = (status, answer) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(answer));
3424
+ try {
3425
+ const { tmux, parked, config } = this.deps;
3426
+ const known = (await tmux.listSessions(config.sessionPrefix)).some((s) => s.name === id) || await parked.get(id);
3427
+ if (!known) {
3428
+ json(200, { result: "no-such-workspace" });
3429
+ return;
3430
+ }
3431
+ const scope = await retireWorkspace(
3432
+ {
3433
+ ...this.deps,
3434
+ forgetWorkspace: (workspace, closing) => this.forgetWorkspace(workspace, closing),
3435
+ dropWorkspaceState: (workspace) => this.dropWorkspaceState(workspace),
3436
+ snapshot: async (workspace) => (await listWorkspaces({
3437
+ tmux,
3438
+ machineId: config.machineId,
3439
+ sessionPrefix: config.sessionPrefix,
3440
+ status: this.deps.status,
3441
+ messagedLast: this.messagedLast
3442
+ })).find((w) => w.id === workspace)
3443
+ },
3444
+ id
3445
+ );
3446
+ this.broadcastClosed(id);
3447
+ json(200, { result: "closed" });
3448
+ void finishClose(this.deps, id, scope);
3449
+ } catch (err) {
3450
+ json(500, { error: err.message });
3451
+ }
3452
+ }
3453
+ async handleMessage(body, res) {
3454
+ let req;
3455
+ try {
3456
+ req = JSON.parse(body);
3457
+ } catch {
3458
+ req = null;
3459
+ }
3460
+ const { pane, pid, to, body: text, codexThread } = req ?? {};
3461
+ if (typeof pane !== "string" || !Number.isInteger(pid) || typeof to !== "string" || typeof text !== "string" || codexThread !== void 0 && typeof codexThread !== "string") {
3462
+ res.writeHead(400).end();
3463
+ return;
3464
+ }
3465
+ try {
3466
+ const { ancestry, delivery, log } = this.deps.messaging;
3467
+ const result = await sendMessage(
3468
+ {
3469
+ tmux: this.deps.tmux,
3470
+ parked: this.deps.parked,
3471
+ ancestry,
3472
+ delivery,
3473
+ log,
3474
+ machineId: this.deps.config.machineId,
3475
+ sessionPrefix: this.deps.config.sessionPrefix,
3476
+ now: () => this.deps.clock.now(),
3477
+ onSent: (senderId) => {
3478
+ markSent({ sentDuringTool: this.sentDuringTool, messagedLast: this.messagedLast }, senderId);
3479
+ void this.broadcastWorkspace(senderId).catch(() => void 0);
3480
+ }
3481
+ },
3482
+ { pane, pid, to, body: text, ...codexThread !== void 0 ? { codexThread } : {} }
3483
+ );
3484
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
3485
+ } catch (err) {
3486
+ res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: err.message }));
3487
+ }
3488
+ }
3489
+ // A tick slower than the interval skips the next one rather than running beside it: a tick
3490
+ // that read tmux before a session went and finished after a newer one saw it gone would take
3491
+ // the workspace back, and it would be pushed closed and logged `exited` twice.
3492
+ async pollTick() {
3493
+ if (this.tickRunning) return;
3494
+ this.tickRunning = true;
3495
+ let deadline;
3496
+ try {
3497
+ await Promise.race([
3498
+ Promise.all([
3499
+ // Tracked first, so a workspace lost with its tmux server is already parked when the
3500
+ // title poll looks for it, rather than logged and pushed as closed.
3501
+ this.trackLive().then(() => this.pollTitles()).then(() => this.dropGoneWorkspaces()),
3502
+ this.pollProgramStatus(),
3503
+ this.pollBrowserSessions()
3504
+ ]),
3505
+ new Promise((resolve) => {
3506
+ deadline = setTimeout(resolve, this.deps.tickDeadlineMs ?? DEFAULT_TICK_DEADLINE_MS);
3507
+ deadline.unref?.();
3508
+ })
3509
+ ]);
3510
+ } catch {
3511
+ } finally {
3512
+ clearTimeout(deadline);
3513
+ this.tickRunning = false;
3514
+ }
3515
+ this.deps.onPollTick?.();
3516
+ }
3517
+ // The second status source beside the hooks, from a tmux patched to keep each pane's OSC 7501
3518
+ // status; a stock tmux reports none and this changes nothing. Runs whether or not a client is
3519
+ // connected: a stale `working` matters most when nobody is watching. Best-effort, like the tick.
3520
+ async pollProgramStatus() {
3521
+ let sessions;
3522
+ try {
3523
+ sessions = await this.deps.tmux.listSessions(this.deps.config.sessionPrefix);
3524
+ } catch {
3525
+ return;
3526
+ }
3527
+ applyProgramStatus(
3528
+ {
3529
+ store: this.deps.status,
3530
+ deferred: this.deferred,
3531
+ messagedLast: this.messagedLast,
3532
+ questionPending: this.questionPending,
3533
+ seen: this.programSeen,
3534
+ oscTurnEnded: this.oscTurnEnded,
3535
+ log: this.deps.hookEventLog
3536
+ },
3537
+ sessions
3538
+ );
3539
+ const asking = new Set(sessions.filter((s) => s.program?.state === "blocked").map((s) => s.name));
3540
+ const flipped = [...asking, ...this.asking].filter((name) => asking.has(name) !== this.asking.has(name));
3541
+ this.asking = asking;
3542
+ for (const name of flipped) {
3543
+ if (asking.has(name)) {
3544
+ for (const handler of this.handlers) handler.stopVoiceIn(name, "Dictation stopped: Claude asked a question");
3545
+ }
3546
+ void this.broadcastWorkspace(name).catch(() => void 0);
3547
+ }
3548
+ }
3549
+ // Runs whether or not a client is connected: the snapshot must be current when the server dies.
3550
+ // Best-effort, as the interval is fire-and-forget.
3551
+ async trackLive() {
3552
+ if (!this.deps.liveSnapshot) return;
3553
+ try {
3554
+ await trackLiveWorkspaces({
3555
+ tmux: this.deps.tmux,
3556
+ snapshot: this.deps.liveSnapshot,
3557
+ parked: this.deps.parked,
3558
+ status: this.deps.status,
3559
+ workspaceLog: this.deps.workspaceLog,
3560
+ clock: this.deps.clock,
3561
+ machineId: this.deps.config.machineId,
3562
+ sessionPrefix: this.deps.config.sessionPrefix
3563
+ });
3564
+ } catch {
3565
+ return;
3566
+ }
3567
+ }
3568
+ // Detects pane-title renames (which have no hook) and pushes the new name, and detects
3569
+ // sessions that have disappeared (the inner process exited and tmux destroyed the session)
3570
+ // and pushes a `closed` so the workspace stops lingering as a ghost in connected clients.
3571
+ // The first sighting of a session only records its name; later changes broadcast.
3572
+ async pollTitles() {
3573
+ if (this.handlers.size === 0) return;
3574
+ let workspaces;
3575
+ try {
3576
+ workspaces = await listWorkspaces({
3577
+ tmux: this.deps.tmux,
3578
+ machineId: this.deps.config.machineId,
3579
+ sessionPrefix: this.deps.config.sessionPrefix,
3580
+ status: this.deps.status,
3581
+ repoRemote: this.deps.repoRemote,
3582
+ claudeSessions: this.deps.claudeSessions,
3583
+ parked: this.deps.parked,
3584
+ messagedLast: this.messagedLast
3585
+ });
3586
+ } catch {
3587
+ return;
3588
+ }
3589
+ const hidden = await this.deps.hiddenFolders.list();
3590
+ const present = /* @__PURE__ */ new Set();
3591
+ const listed = new Set(workspaces.map((w) => w.id));
3592
+ const now = this.deps.clock.now();
3593
+ for (const [id, until] of this.closing) {
3594
+ if (!listed.has(id) || now >= until) this.closing.delete(id);
3595
+ }
3596
+ for (const workspace of workspaces) {
3597
+ if (isHiddenPath(workspace.projectPath, hidden) || this.closing.has(workspace.id)) continue;
3598
+ present.add(workspace.id);
3599
+ const previous = this.lastWorkspaces.get(workspace.id);
3600
+ this.lastWorkspaces.set(workspace.id, workspace);
3601
+ if (previous !== void 0 && previous.name !== workspace.name) {
3602
+ for (const handler of this.handlers) {
3603
+ handler.pushWorkspaceUpdated(workspace);
3604
+ }
3605
+ }
3606
+ }
3607
+ for (const id of this.lastWorkspaces.keys()) {
3608
+ if (!present.has(id)) {
3609
+ const workspace = this.lastWorkspaces.get(id);
3610
+ this.lastWorkspaces.delete(id);
3611
+ for (const handler of this.handlers) {
3612
+ handler.pushClosed(id);
3613
+ }
3614
+ await recordWorkspaceClosed(
3615
+ { workspaceLog: this.deps.workspaceLog, status: this.deps.status, clock: this.deps.clock },
3616
+ workspace,
3617
+ "exited"
3618
+ );
3619
+ this.dropWorkspaceState(id);
3620
+ void closeWorkspaceBrowserSessions(
3621
+ { discovery: this.deps.browserDiscovery, commands: this.deps.browserCommands },
3622
+ id
3623
+ );
3624
+ }
3625
+ }
3626
+ }
3627
+ // The agent only answers `list` with browserSessions, so a session started outside that
3628
+ // request/reply cycle (the CLI, another device's start button) would stay invisible until
3629
+ // a reconnect. This piggybacks on the title poll: when the live set of session names
3630
+ // changes, push the fresh list to every connected client. Discovery is a cheap fs scan.
3631
+ async pollBrowserSessions() {
3632
+ if (this.handlers.size === 0) return;
3633
+ let sessions;
3634
+ try {
3635
+ sessions = await this.deps.browserDiscovery.listSessions();
3636
+ } catch {
3637
+ return;
3638
+ }
3639
+ const names = new Set(sessions.map((s) => s.name));
3640
+ const unchanged = names.size === this.lastBrowserNames.size && [...names].every((n) => this.lastBrowserNames.has(n));
3641
+ if (unchanged) return;
3642
+ this.lastBrowserNames = names;
3643
+ const payload = sessions.map(({ name }) => ({ name }));
3644
+ for (const handler of this.handlers) {
3645
+ handler.pushBrowserSessions(payload);
3646
+ }
3647
+ }
3648
+ // Fire-and-forget from the status store, so it must not reject: tmux failing here (its server
3649
+ // exiting with its last session) would otherwise be an unhandled rejection, which ends the agent.
3650
+ async broadcastWorkspace(workspaceId) {
3651
+ let workspaces;
3652
+ try {
3653
+ workspaces = await listWorkspaces({
3654
+ tmux: this.deps.tmux,
3655
+ machineId: this.deps.config.machineId,
3656
+ sessionPrefix: this.deps.config.sessionPrefix,
3657
+ status: this.deps.status,
3658
+ repoRemote: this.deps.repoRemote,
3659
+ claudeSessions: this.deps.claudeSessions,
3660
+ parked: this.deps.parked,
3661
+ messagedLast: this.messagedLast
3662
+ });
3663
+ } catch {
3664
+ return;
3665
+ }
3666
+ const workspace = workspaces.find((w) => w.id === workspaceId);
3667
+ if (!workspace) {
3668
+ return;
3669
+ }
3670
+ const hidden = await this.deps.hiddenFolders.list();
3671
+ if (isHiddenPath(workspace.projectPath, hidden)) {
3672
+ return;
3673
+ }
3674
+ for (const handler of this.handlers) {
3675
+ handler.pushWorkspaceUpdated(workspace);
3676
+ }
3677
+ }
3678
+ address() {
3679
+ const addr = this.http?.address();
3680
+ return { port: addr?.port ?? 0 };
3681
+ }
3682
+ stop() {
3683
+ if (this.titlePoll) {
3684
+ clearInterval(this.titlePoll);
3685
+ this.titlePoll = null;
3686
+ }
3687
+ if (this.statsPoll) {
3688
+ clearInterval(this.statsPoll);
3689
+ this.statsPoll = null;
3690
+ }
3691
+ this.lastWorkspaces.clear();
3692
+ this.lastBrowserNames.clear();
3693
+ const http = this.http;
3694
+ if (!http) {
3695
+ return Promise.resolve();
3696
+ }
3697
+ return new Promise((resolve, reject) => {
3698
+ http.close((err) => {
3699
+ this.http = null;
3700
+ if (err) {
3701
+ reject(err);
3702
+ } else {
3703
+ resolve();
3704
+ }
3705
+ });
3706
+ });
3707
+ }
3708
+ };
3709
+
3710
+ // src/application/push-budget.ts
3711
+ var WINDOW_MS = 6e4;
3712
+ var PUSHES_PER_MINUTE = 10;
3713
+ var PushBudget = class {
3714
+ constructor(now, perMinute = PUSHES_PER_MINUTE) {
3715
+ this.now = now;
3716
+ this.perMinute = perMinute;
3717
+ }
3718
+ now;
3719
+ perMinute;
3720
+ taken = /* @__PURE__ */ new Map();
3721
+ take(deviceKey) {
3722
+ const now = this.now();
3723
+ const recent = (this.taken.get(deviceKey) ?? []).filter((t) => now - t < WINDOW_MS);
3724
+ const allowed = recent.length < this.perMinute;
3725
+ if (allowed) recent.push(now);
3726
+ this.taken.set(deviceKey, recent);
3727
+ return allowed;
3728
+ }
3729
+ };
3730
+
3731
+ // src/application/limited-log.ts
3732
+ var EVERY_MS = 6e4;
3733
+ var LimitedLog = class {
3734
+ constructor(log, now, everyMs = EVERY_MS) {
3735
+ this.log = log;
3736
+ this.now = now;
3737
+ this.everyMs = everyMs;
3738
+ }
3739
+ log;
3740
+ now;
3741
+ everyMs;
3742
+ entries = /* @__PURE__ */ new Map();
3743
+ line(key, text) {
3744
+ const now = this.now();
3745
+ const entry = this.entries.get(key) ?? { last: -Infinity, held: 0 };
3746
+ if (now - entry.last >= this.everyMs) {
3747
+ const held = entry.held ? ` (and ${entry.held} more in the last minute)` : "";
3748
+ this.log(`${text}${held}`);
3749
+ this.entries.set(key, { last: now, held: 0 });
3750
+ } else {
3751
+ this.entries.set(key, { ...entry, held: entry.held + 1 });
3752
+ }
3753
+ }
3754
+ };
3755
+
3756
+ // src/application/device-gate.ts
3757
+ var RosterDevices = class {
3758
+ constructor(deps) {
3759
+ this.deps = deps;
3760
+ }
3761
+ deps;
3762
+ async check(x25519) {
3763
+ const roster = await this.deps.store.read();
3764
+ return { standing: standingOf(roster, x25519), enrolment: enrolmentMode(roster) };
3765
+ }
3766
+ async enrol(device, via) {
3767
+ let known = true;
3768
+ const roster = await this.deps.store.update((current) => {
3769
+ known = standingOf(current, device.x25519) !== "unknown";
3770
+ return mayEnrol(current, via) ? enrolDevice(current, device, this.deps.now()) : current;
3771
+ });
3772
+ this.deps.onChange();
3773
+ const entry = known ? void 0 : roster.devices.find((d) => d.x25519 === device.x25519);
3774
+ try {
3775
+ if (entry) this.deps.onEnrolled?.({ id: entry.id, name: entry.name, at: entry.enrolledAt, x25519: entry.x25519 }, via);
3776
+ } catch {
3777
+ }
3778
+ return standingOf(roster, device.x25519) === "enrolled";
3779
+ }
3780
+ // Spends a pairing secret for the device (X25519 key) about to be enrolled, binding it to that
3781
+ // device. Atomic across processes, so two phones racing with one code cannot both get in. A
3782
+ // secret this agent never issued (or has forgotten) gives no reason: it may be a token after all.
3783
+ async redeemPairing(secret, device) {
3784
+ if (!(await this.deps.store.read()).enrolment) return { redeemed: false, reason: REFUSAL.enrolmentClosed };
3785
+ const outcome = await this.spend(secret, device);
3786
+ if (outcome === "redeemed" || outcome === "mine") return { redeemed: true, fresh: outcome === "redeemed" };
3787
+ if (outcome === "used") return { redeemed: false, reason: REFUSAL.codeUsed };
3788
+ if (outcome === "expired") return { redeemed: false, reason: REFUSAL.codeExpired };
3789
+ return { redeemed: false };
3790
+ }
3791
+ // Spends a live secret an enrolled device presented, whatever enrolment is set to: the device
3792
+ // needs no code, and one left live would admit whoever else holds the link. True if it did.
3793
+ async burnPairing(secret, device) {
3794
+ return await this.spend(secret, device) === "redeemed";
3795
+ }
3796
+ async spend(secret, device) {
3797
+ if (redeemPairing(await this.deps.pairings.read(), secret, this.deps.now(), device).outcome === "unknown") return "unknown";
3798
+ let outcome = "unknown";
3799
+ await this.deps.pairings.update((list) => {
3800
+ const result = redeemPairing(list, secret, this.deps.now(), device);
3801
+ outcome = result.outcome;
3802
+ return result.list;
3803
+ });
3804
+ return outcome;
3805
+ }
3806
+ };
3807
+ var RosterMonitor = class {
3808
+ // `onRead`: every roster read, the first included, for what keeps a copy of it.
3809
+ constructor(store, onChange, onRead) {
3810
+ this.store = store;
3811
+ this.onChange = onChange;
3812
+ this.onRead = onRead;
3813
+ }
3814
+ store;
3815
+ onChange;
3816
+ onRead;
3817
+ last = null;
3818
+ async poll() {
3819
+ let roster;
3820
+ try {
3821
+ roster = await this.store.read();
3822
+ } catch {
3823
+ return;
3824
+ }
3825
+ this.onRead?.(roster);
3826
+ const text = JSON.stringify(roster);
3827
+ if (this.last !== null && text !== this.last) this.onChange(roster);
3828
+ this.last = text;
3829
+ }
3830
+ };
3831
+
3832
+ // src/server/device-gate-wiring.ts
3833
+ function startDeviceGate(deps) {
3834
+ const { keys, server, store, log } = deps;
3835
+ let last = null;
3836
+ const wrote = () => {
3837
+ store.read().then((roster) => last = roster, () => void 0);
3838
+ };
3839
+ const devices = new RosterDevices({
3840
+ store,
3841
+ pairings: deps.pairings,
3842
+ now: () => /* @__PURE__ */ new Date(),
3843
+ onChange: wrote,
3844
+ onEnrolled: (device, via) => {
3845
+ log(`device enrolled: ${device.id} "${device.name}" (${via === "pairing" ? "pairing code" : "token"})`);
3846
+ server.broadcastDeviceEnrolled(device);
3847
+ }
3848
+ });
3849
+ const pushBudget = new PushBudget(() => Date.now());
3850
+ const refusalLog = new LimitedLog(log, () => Date.now());
3851
+ const monitor = new RosterMonitor(
3852
+ store,
3853
+ (roster) => {
3854
+ log(`device roster changed (${roster.devices.length} enrolled, ${roster.revoked.length} revoked)`);
3855
+ server.refuseRevokedDevices((key) => standingOf(roster, key) === "revoked");
3856
+ },
3857
+ (roster) => {
3858
+ last = roster;
3859
+ }
3860
+ );
3861
+ void monitor.poll();
3862
+ const poll = setInterval(() => void monitor.poll(), deps.rosterPollMs);
3863
+ poll.unref();
3864
+ return {
3865
+ staticPrivate: b64url.decode(keys.x25519.privateKey),
3866
+ devices,
3867
+ options: (extra) => ({ devices, log, refusalLog, pushBudget, agentVersion: deps.agentVersion, ...extra }),
3868
+ roster: () => last,
3869
+ pollRoster: () => monitor.poll(),
3870
+ stop: () => clearInterval(poll)
3871
+ };
3872
+ }
3873
+
3874
+ // src/infrastructure/phone-listener.ts
3875
+ import { createServer as createServer2 } from "node:http";
3876
+ import { WebSocketServer } from "ws";
3877
+
3878
+ // src/application/phone-admission.ts
3879
+ var WINDOW_MS2 = 6e4;
3880
+ var UNAUTHENTICATED_PER_ADDRESS = 8;
3881
+ var UNAUTHENTICATED_TOTAL = 32;
3882
+ var HANDSHAKES_PER_MINUTE = 20;
3883
+ var UNAUTHENTICATED_PER_PREFIX = 3 * UNAUTHENTICATED_PER_ADDRESS;
3884
+ var HANDSHAKES_PER_MINUTE_PER_PREFIX = 3 * HANDSHAKES_PER_MINUTE;
3885
+ var UNAUTHENTICATED_PUBLIC_TOTAL = 16;
3886
+ var SWEEP_AT = 1024;
3887
+ var SWEEP_EVERY_MS = 1e3;
3888
+ var LOOPBACK2 = /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/;
3889
+ function clientAddress(peer, forwardedFor) {
3890
+ if (!LOOPBACK2.test(peer)) return subscriber(peer);
3891
+ const last = (forwardedFor ?? "").split(",").map((hop) => hop.trim()).filter(Boolean).at(-1);
3892
+ return last === void 0 ? null : subscriber(last);
3893
+ }
3894
+ function isFunnelRequest(peer, header) {
3895
+ return LOOPBACK2.test(peer) && header !== void 0;
3896
+ }
3897
+ function widePrefix(address) {
3898
+ const match = /^([0-9a-f]+:[0-9a-f]+:[0-9a-f]+):([0-9a-f]+)::\/64$/.exec(address);
3899
+ if (!match) return null;
3900
+ return `${match[1]}:${(Number.parseInt(match[2], 16) & 65280).toString(16)}::/56`;
3901
+ }
3902
+ function subscriber(address) {
3903
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
3904
+ if (mapped) return mapped[1];
3905
+ if (!address.includes(":") || address.includes(".")) return address;
3906
+ const [head, tail, ...rest] = address.split("::");
3907
+ if (rest.length > 0) return address;
3908
+ const front = head ? head.split(":") : [];
3909
+ const back = tail ? tail.split(":") : [];
3910
+ const missing = 8 - front.length - back.length;
3911
+ if (tail === void 0 ? front.length !== 8 : missing < 1) return address;
3912
+ const groups = [...front, ...Array(missing).fill("0"), ...back];
3913
+ if (!groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return address;
3914
+ return `${groups.slice(0, 4).map((g) => Number.parseInt(g, 16).toString(16)).join(":")}::/64`;
3915
+ }
3916
+ var PhoneAdmission = class {
3917
+ // Insertion order is age order.
3918
+ pending = { tailnet: /* @__PURE__ */ new Set(), public: /* @__PURE__ */ new Set() };
3919
+ perAddressCount = /* @__PURE__ */ new Map();
3920
+ opened = /* @__PURE__ */ new Map();
3921
+ lastSweep = -Infinity;
3922
+ perAddress;
3923
+ totals;
3924
+ handshakesPerMinute;
3925
+ now;
3926
+ constructor(opts) {
3927
+ this.now = opts.now;
3928
+ this.perAddress = opts.perAddress ?? UNAUTHENTICATED_PER_ADDRESS;
3929
+ this.totals = { tailnet: opts.total ?? UNAUTHENTICATED_TOTAL, public: opts.publicTotal ?? UNAUTHENTICATED_PUBLIC_TOTAL };
3930
+ this.handshakesPerMinute = opts.handshakesPerMinute ?? HANDSHAKES_PER_MINUTE;
3931
+ }
3932
+ admit(address, evict, pool = "tailnet") {
3933
+ const wide = address === null ? null : widePrefix(address);
3934
+ const keys = address === null ? [] : wide === null ? [address] : [address, wide];
3935
+ const caps = (i) => i === 0 ? [this.perAddress, this.handshakesPerMinute] : [UNAUTHENTICATED_PER_PREFIX, HANDSHAKES_PER_MINUTE_PER_PREFIX];
3936
+ if (keys.some((key, i) => (this.perAddressCount.get(key) ?? 0) >= caps(i)[0])) return { ok: false, reason: "address" };
3937
+ if (keys.length > 0) {
3938
+ const now = this.now();
3939
+ if (this.opened.size > SWEEP_AT && now - this.lastSweep >= SWEEP_EVERY_MS) this.sweep(now);
3940
+ const recent = keys.map((key) => (this.opened.get(key) ?? []).filter((t) => now - t < WINDOW_MS2));
3941
+ if (recent.some((times, i) => times.length >= caps(i)[1])) {
3942
+ keys.forEach((key, i) => this.opened.set(key, recent[i]));
3943
+ return { ok: false, reason: "rate" };
3944
+ }
3945
+ keys.forEach((key, i) => this.opened.set(key, [...recent[i], now]));
3946
+ }
3947
+ const pending = this.pending[pool];
3948
+ if (pending.size >= this.totals[pool]) {
3949
+ const oldest = pending.values().next().value;
3950
+ this.remove(oldest);
3951
+ oldest.evict();
3952
+ }
3953
+ const entry = { keys, pool, evict };
3954
+ pending.add(entry);
3955
+ for (const key of keys) this.perAddressCount.set(key, (this.perAddressCount.get(key) ?? 0) + 1);
3956
+ const leave = () => this.remove(entry);
3957
+ return { ok: true, release: leave, authenticated: leave };
3958
+ }
3959
+ remove(entry) {
3960
+ if (!this.pending[entry.pool].delete(entry)) return;
3961
+ for (const key of entry.keys) {
3962
+ const left = (this.perAddressCount.get(key) ?? 1) - 1;
3963
+ if (left > 0) this.perAddressCount.set(key, left);
3964
+ else this.perAddressCount.delete(key);
3965
+ }
3966
+ }
3967
+ sweep(now) {
3968
+ this.lastSweep = now;
3969
+ for (const [address, times] of this.opened) {
3970
+ if (times.every((t) => now - t >= WINDOW_MS2)) this.opened.delete(address);
3971
+ }
3972
+ }
3973
+ };
3974
+
3975
+ // src/application/disk-read-limiter.ts
3976
+ var WINDOW_MS3 = 6e4;
3977
+ var DISK_READS_AT_ONCE = 4;
3978
+ var DISK_READS_PER_MINUTE = 10;
3979
+ var DiskReadLimiter = class {
3980
+ constructor(now, concurrent = DISK_READS_AT_ONCE, perMinute = DISK_READS_PER_MINUTE) {
3981
+ this.now = now;
3982
+ this.concurrent = concurrent;
3983
+ this.perMinute = perMinute;
3984
+ }
3985
+ now;
3986
+ concurrent;
3987
+ perMinute;
3988
+ running = 0;
3989
+ started = [];
3990
+ async run(read) {
3991
+ const now = this.now();
3992
+ this.started = this.started.filter((t) => now - t < WINDOW_MS3);
3993
+ if (this.running >= this.concurrent || this.started.length >= this.perMinute) return "limited";
3994
+ this.started.push(now);
3995
+ this.running++;
3996
+ try {
3997
+ return await read();
3998
+ } finally {
3999
+ this.running--;
4000
+ }
4001
+ }
4002
+ };
4003
+ async function limited(limiter, read) {
4004
+ const result = await limiter.run(read);
4005
+ if (result === "limited") throw new DeviceGateBusy();
4006
+ return result;
4007
+ }
4008
+ function limitedDevices(devices, limiter, roster) {
4009
+ return {
4010
+ check: async (x25519) => {
4011
+ const known = roster();
4012
+ if (known && standingOf(known, x25519) === "enrolled") return { standing: "enrolled", enrolment: enrolmentMode(known) };
4013
+ return limited(limiter, () => devices.check(x25519));
4014
+ },
4015
+ enrol: (device, via) => devices.enrol(device, via),
4016
+ redeemPairing: (secret, device) => limited(limiter, () => devices.redeemPairing(secret, device)),
4017
+ burnPairing: (secret, device) => devices.burnPairing(secret, device)
4018
+ };
4019
+ }
4020
+
4021
+ // src/infrastructure/ws-frame-socket.ts
4022
+ var CLOSE_GRACE_MS = 1e3;
4023
+ function wsFrameSocket(socket, binaryOnly = false) {
4024
+ return {
4025
+ send: (frame) => socket.send(frame, { binary: true }),
4026
+ onFrame: (listener) => socket.on("message", (data, isBinary2) => {
4027
+ if (isBinary2 || !binaryOnly) listener(new Uint8Array(data));
4028
+ }),
4029
+ onClose: (listener) => socket.on("close", () => listener()),
4030
+ close: (code, reason) => {
4031
+ socket.close(code, reason);
4032
+ setTimeout(() => socket.terminate(), CLOSE_GRACE_MS).unref();
4033
+ }
4034
+ };
4035
+ }
4036
+
4037
+ // src/infrastructure/phone-listener.ts
4038
+ var LOOPBACK3 = "127.0.0.1";
4039
+ var HEARTBEAT_MS = 3e4;
4040
+ var HEADERS_TIMEOUT_MS = 1e4;
4041
+ var REQUEST_TIMEOUT_MS = 15e3;
4042
+ var KEEP_ALIVE_TIMEOUT_MS = 5e3;
4043
+ var TOO_MANY = IN_BAND_REFUSAL + 429;
4044
+ function startPhoneListener(opts) {
4045
+ const { gate, server, admission, limiter } = opts;
4046
+ const devices = limitedDevices(gate.devices, limiter, () => gate.roster());
4047
+ const alive = /* @__PURE__ */ new WeakMap();
4048
+ const http = createServer2((_req, res) => {
4049
+ res.writeHead(404).end();
4050
+ });
4051
+ http.headersTimeout = HEADERS_TIMEOUT_MS;
4052
+ http.requestTimeout = REQUEST_TIMEOUT_MS;
4053
+ http.keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS;
4054
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES, perMessageDeflate: false });
4055
+ http.on("upgrade", (req, socket, head) => {
4056
+ socket.on("error", () => void 0);
4057
+ const forwarded = req.headers["x-forwarded-for"];
4058
+ const peer = req.socket.remoteAddress ?? "";
4059
+ const address = clientAddress(peer, Array.isArray(forwarded) ? forwarded.join(",") : forwarded);
4060
+ const funnel = req.headers["tailscale-funnel-request"];
4061
+ const pool = isFunnelRequest(peer, Array.isArray(funnel) ? funnel.join(",") : funnel) ? "public" : "tailnet";
4062
+ wss.handleUpgrade(req, socket, head, (ws) => {
4063
+ ws.on("error", () => void 0);
4064
+ const frame = wsFrameSocket(ws, true);
4065
+ const slot = admission.admit(address, () => frame.close(TOO_MANY, REFUSAL.tooManyPending), pool);
4066
+ if (!slot.ok) {
4067
+ gate.options({}).refusalLog?.line(`phone:${slot.reason}`, `phone port: refused a socket from ${address ?? "a local proxy"}: too many (${slot.reason})`);
4068
+ frame.close(TOO_MANY, REFUSAL.tooManyConnections);
4069
+ return;
4070
+ }
4071
+ alive.set(ws, true);
4072
+ ws.on("pong", () => alive.set(ws, true));
4073
+ frame.onClose(slot.release);
4074
+ server.acceptPhoneSocket(frame, gate.staticPrivate, {
4075
+ ...gate.options({ onAuthenticated: slot.authenticated }),
4076
+ devices,
4077
+ ...opts.startTimer ? { startTimer: opts.startTimer } : {}
4078
+ });
4079
+ });
4080
+ });
4081
+ return new Promise((resolve, reject) => {
4082
+ http.once("error", reject);
4083
+ http.listen(opts.port, LOOPBACK3, () => {
4084
+ http.off("error", reject);
4085
+ http.on("error", (err) => opts.log(`phone port: ${err.message}`));
4086
+ const heartbeat = setInterval(() => {
4087
+ for (const ws of wss.clients) {
4088
+ if (!alive.get(ws)) {
4089
+ ws.terminate();
4090
+ continue;
4091
+ }
4092
+ alive.set(ws, false);
4093
+ ws.ping();
4094
+ }
4095
+ }, opts.heartbeatMs ?? HEARTBEAT_MS);
4096
+ heartbeat.unref();
4097
+ const address = http.address();
4098
+ resolve({
4099
+ port: typeof address === "object" && address ? address.port : opts.port,
4100
+ close: async () => {
4101
+ clearInterval(heartbeat);
4102
+ for (const ws of wss.clients) ws.terminate();
4103
+ wss.close();
4104
+ await new Promise((done) => http.close(() => done()));
4105
+ }
4106
+ });
4107
+ });
4108
+ });
4109
+ }
4110
+
4111
+ // src/infrastructure/token-file.ts
4112
+ import { randomBytes as randomBytes2 } from "node:crypto";
4113
+ import { rename as rename3, writeFile } from "node:fs/promises";
4114
+ import { join as join6 } from "node:path";
4115
+
4116
+ // src/infrastructure/private-dir.ts
4117
+ import { chmod, mkdir as mkdir7, stat as stat4 } from "node:fs/promises";
4118
+
4119
+ // src/application/private-files.ts
4120
+ function exposedFiles(files) {
4121
+ return files.filter(({ mode }) => (mode & 63) !== 0).map(({ path, mode }) => `${path} is readable or writable by other users (mode ${(mode & 511).toString(8)}): run \`chmod 600 ${path}\``);
4122
+ }
4123
+
4124
+ // src/infrastructure/private-dir.ts
4125
+ async function securePrivateDir(dir) {
4126
+ await mkdir7(dir, { recursive: true, mode: 448 });
4127
+ await chmod(dir, 448);
4128
+ }
4129
+ async function assertPrivateFiles(paths) {
4130
+ const found = [];
4131
+ for (const path of paths) {
4132
+ try {
4133
+ found.push({ path, mode: (await stat4(path)).mode });
4134
+ } catch (err) {
4135
+ if (err.code !== "ENOENT") throw err;
4136
+ }
4137
+ }
4138
+ const problems = exposedFiles(found);
4139
+ if (problems.length > 0) throw new Error(problems.join("\n"));
4140
+ }
4141
+
4142
+ // src/infrastructure/token-file.ts
4143
+ async function writeHookToken(dir) {
4144
+ await securePrivateDir(dir);
4145
+ const token = randomBytes2(32).toString("base64url");
4146
+ const path = join6(dir, "token");
4147
+ const temporary = `${path}.${process.pid}.tmp`;
4148
+ await writeFile(temporary, token, { encoding: "utf8", mode: 384 });
4149
+ await rename3(temporary, path);
4150
+ return token;
4151
+ }
4152
+
4153
+ // src/infrastructure/ensure-utf8-locale.ts
4154
+ var UTF8 = /utf-?8/i;
4155
+ function ensureUtf8Locale(env) {
4156
+ const effective = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG;
4157
+ if (effective !== void 0 && UTF8.test(effective)) return;
4158
+ if (env.LC_ALL !== void 0) delete env.LC_ALL;
4159
+ if (env.LC_CTYPE !== void 0) delete env.LC_CTYPE;
4160
+ env.LANG = "en_US.UTF-8";
4161
+ }
4162
+
4163
+ // src/serve.ts
4164
+ var ROSTER_POLL_MS = 3e3;
4165
+ var agentVersion = packageVersion();
4166
+ async function main() {
4167
+ ensureUtf8Locale(process.env);
4168
+ process.stdout.write(`tarfa agent: version ${agentVersion}
4169
+ `);
4170
+ if (await migrateStateDir(homedir2()) === "moved") {
4171
+ process.stdout.write(`tarfa agent: moved ~/.perch to ~/.tarfa (~/.perch is now a link to it)
4172
+ `);
4173
+ }
4174
+ const env = withLegacyEnv(process.env);
4175
+ const config = loadConfig(env, homedir2());
4176
+ assertServerConfig(config);
4177
+ for (const warning of configWarnings(process.env)) process.stderr.write(`${warning}
4178
+ `);
4179
+ await securePrivateDir(config.stateDir);
4180
+ await assertPrivateFiles([
4181
+ config.agentKeyPath,
4182
+ config.devicesPath,
4183
+ config.pairingPath,
4184
+ join7(config.stateDir, "token"),
4185
+ // It holds TARFA_TOKEN, which enrols a phone.
4186
+ await locateAgentEnv(env, homedir2())
4187
+ ]);
4188
+ const hookToken = await writeHookToken(config.stateDir);
4189
+ const tmux = new TmuxAdapter();
4190
+ const status = new FileStatusStore(config.statusStorePath);
4191
+ const parked = new FileParkedStore(config.parkedStorePath);
4192
+ const liveSnapshot = new FileLiveSnapshotStore(config.liveSnapshotPath);
4193
+ const workspaceLog = new FileWorkspaceLogStore(config.workspaceLogPath);
4194
+ const claudeSessions = new FileClaudeSessionsStore(config.claudeSessionsDir);
4195
+ await reconcileAtStartup({
4196
+ tmux,
4197
+ snapshot: liveSnapshot,
4198
+ parked,
4199
+ status,
4200
+ workspaceLog,
4201
+ clock: systemClock,
4202
+ machineId: config.machineId,
4203
+ sessionPrefix: config.sessionPrefix,
4204
+ log: (line) => process.stdout.write(`${line}
4205
+ `)
4206
+ });
4207
+ const server = new WsServer({
4208
+ config,
4209
+ hookToken,
4210
+ tmux,
4211
+ recent: new FileRecentStore(config.recentStorePath),
4212
+ lister: new FsProjectLister(),
4213
+ reader: new FsFileReader(),
4214
+ resolvePath: systemPathResolver,
4215
+ isDirectory: fsIsDirectory,
4216
+ clock: systemClock,
4217
+ ids: new ShortWorkspaceIds({ tmux, sessionPrefix: config.sessionPrefix, parked, status, claudeSessions }),
4218
+ pty: new NodePtyAdapter(),
4219
+ status,
4220
+ writer: new FsFileWriter(),
4221
+ browserDiscovery: new FsBrowserDiscovery(),
4222
+ browserCommands: new AgentBrowserCli(),
4223
+ hiddenFolders: new FileHiddenFoldersStore(config.hiddenFoldersPath),
4224
+ launchers: new FileLaunchersStore(config.launchersPath),
4225
+ detectInstalled: (names) => detectInstalled(names, loginShellResolver()),
4226
+ keyterms: new FileKeytermsStore(config.keytermsPath),
4227
+ clientLog: new FileClientLogStore(config.clientLogPath),
4228
+ hookEventLog: new FileHookEventLog(config.hookEventLogPath),
4229
+ messaging: {
4230
+ ancestry: new ProcAncestry(),
4231
+ delivery: new CompositeDelivery({
4232
+ claude: new ClaudeInboxDelivery(claudeSessions, async (id) => await tmux.firstPaneId?.(id) ?? null),
4233
+ pi: new PiInboxDelivery(config.inboxDir),
4234
+ codex: new CodexDelivery(new CodexAppServer())
4235
+ }),
4236
+ log: new FileMessageLog(config.messageLogPath)
4237
+ },
4238
+ workspaceLog,
4239
+ parked,
4240
+ liveSnapshot,
4241
+ connectUpstream: connectBrowserUpstream,
4242
+ repoRemote: new GitRepoRemote(),
4243
+ claudeSessions,
4244
+ scopes: new SystemdScopeReaper(),
4245
+ ...config.stats === false ? {} : { systemStats: new ProcSystemStats() },
4246
+ claudeVoice: process.platform === "linux" ? new ClaudeVoice({
4247
+ player: new AplayVoicePlayer("aplay", aplayArgs(config.voiceDevice ?? LOOPBACK_DEVICE)),
4248
+ settings: new FileClaudeSettings(join7(homedir2(), ".claude", "settings.json"))
4249
+ }) : void 0
4250
+ });
4251
+ await server.start();
4252
+ process.stdout.write(`tarfa agent: hooks on 127.0.0.1:${server.address().port}
4253
+ `);
4254
+ const keys = await loadOrCreateAgentKeys(config.agentKeyPath);
4255
+ const log = (message) => process.stdout.write(`${message}
4256
+ `);
4257
+ const gate = startDeviceGate({
4258
+ keys,
4259
+ server,
4260
+ store: new FileDeviceStore(config.devicesPath),
4261
+ pairings: new FilePairingStore(config.pairingPath),
4262
+ log,
4263
+ rosterPollMs: ROSTER_POLL_MS,
4264
+ agentVersion
4265
+ });
4266
+ try {
4267
+ const phones = await startPhoneListener({
4268
+ port: config.phonePort,
4269
+ gate,
4270
+ server,
4271
+ admission: new PhoneAdmission({ now: () => Date.now() }),
4272
+ limiter: new DiskReadLimiter(() => Date.now()),
4273
+ log
4274
+ });
4275
+ log(`tarfa agent: phones on 127.0.0.1:${phones.port}`);
4276
+ if (config.phoneAddresses.length > 0) log(`tarfa agent: phone addresses ${config.phoneAddresses.join(", ")}`);
4277
+ } catch (err) {
4278
+ process.stderr.write(
4279
+ `tarfa agent: no phone port: 127.0.0.1:${config.phonePort} ${err instanceof Error ? err.message : String(err)}; set TARFA_PHONE_PORT in agent.env
4280
+ `
4281
+ );
4282
+ }
4283
+ }
4284
+ if (isEntrypoint(import.meta.url, process.argv[1])) {
4285
+ main().catch((err) => {
4286
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}
4287
+ `);
4288
+ process.exitCode = 1;
4289
+ });
4290
+ }
4291
+ export {
4292
+ main
4293
+ };