@tarfa/cli 0.1.5 → 0.1.7

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.
@@ -2463,8 +2463,6 @@ function deviceFingerprint(x25519PublicKey) {
2463
2463
 
2464
2464
  // ../secure-channel/src/pairing-link.ts
2465
2465
  var PREFIX = "#pair?";
2466
- var KEY = /^[A-Za-z0-9_-]{43}$/;
2467
- var MAX_NAME = 64;
2468
2466
  var MAX_ADDRESSES = 4;
2469
2467
  var MAX_PATH = 128;
2470
2468
  function isPairingPath(path) {
@@ -2485,19 +2483,6 @@ function pairingLink(pwaOrigin, pairing) {
2485
2483
  const query = fields.map(([name, value]) => `${name}=${encode(value)}`).join("&");
2486
2484
  return `${new URL(pwaOrigin).origin}/${PREFIX}${query}`;
2487
2485
  }
2488
- function parsePairingLink(text) {
2489
- const trimmed = text.trim();
2490
- const hash = trimmed.startsWith("#") ? trimmed : trimmed.slice(Math.max(0, trimmed.indexOf("#")));
2491
- if (!trimmed.includes("#") || !hash.startsWith(PREFIX)) return null;
2492
- const query = new URLSearchParams(hash.slice(PREFIX.length));
2493
- const urls = query.getAll("u");
2494
- const [agentKey, secret] = ["k", "s"].map((name2) => query.get(name2) ?? "");
2495
- const name = (query.get("n") ?? "").trim() || "machine";
2496
- if (!KEY.test(agentKey) || !KEY.test(secret) || name.length > MAX_NAME) return null;
2497
- if (urls.length === 0 || urls.length > MAX_ADDRESSES || !urls.every(isMachineUrl)) return null;
2498
- const defaultPath = query.get("p") ?? "";
2499
- return { urls, agentKey, secret, name, ...isPairingPath(defaultPath) ? { defaultPath } : {} };
2500
- }
2501
2486
  var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
2502
2487
  function isMachineUrl(text) {
2503
2488
  try {
@@ -2509,6 +2494,131 @@ function isMachineUrl(text) {
2509
2494
  }
2510
2495
  }
2511
2496
 
2497
+ // ../secure-channel/src/pairing-code.ts
2498
+ var PREFIX2 = "TARFA";
2499
+ var VERSION = 1;
2500
+ var MAX_PLAUSIBLE_VERSION = 15;
2501
+ var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2502
+ var KEY_BYTES = 32;
2503
+ var CHECKSUM_BYTES = 4;
2504
+ var MAX_NAME = 64;
2505
+ var SCHEMES = ["wss://", "ws://"];
2506
+ function pairingCode(pairing) {
2507
+ const out = [VERSION, ...rawKey(pairing.agentKey), ...rawKey(pairing.secret)];
2508
+ const text = (value) => {
2509
+ const bytes = new TextEncoder().encode(value);
2510
+ if (bytes.length > 65535) throw new Error("pairing code field too long");
2511
+ out.push(bytes.length >> 8, bytes.length & 255, ...bytes);
2512
+ };
2513
+ text(pairing.name);
2514
+ text(pairing.defaultPath ?? "");
2515
+ out.push(pairing.urls.length);
2516
+ for (const url of pairing.urls) {
2517
+ const scheme = SCHEMES.findIndex((s) => url.toLowerCase().startsWith(s));
2518
+ if (scheme < 0) throw new Error(`not a machine address: ${url}`);
2519
+ out.push(scheme);
2520
+ text(url.slice(SCHEMES[scheme].length));
2521
+ }
2522
+ const body = Uint8Array.from(out);
2523
+ const code = PREFIX2 + encode32(Uint8Array.from([...body, ...sha256(body).slice(0, CHECKSUM_BYTES)]));
2524
+ if (!parsePairingCode(code).ok) throw new Error("these pairing details do not make a valid pairing code");
2525
+ return code;
2526
+ }
2527
+ function rawKey(key2) {
2528
+ const bytes = /^[A-Za-z0-9_-]{43}$/.test(key2) ? b64url.decode(key2) : new Uint8Array();
2529
+ if (bytes.length !== KEY_BYTES || b64url.encode(bytes) !== key2) throw new Error("a pairing key must be 32 bytes, canonically encoded");
2530
+ return bytes;
2531
+ }
2532
+ function parsePairingCode(text) {
2533
+ const invalid = { ok: false, reason: "invalid" };
2534
+ const compact = text.replace(/\s+/g, "").toUpperCase();
2535
+ if (!compact.startsWith(PREFIX2)) return invalid;
2536
+ const bytes = decode32(compact.slice(PREFIX2.length));
2537
+ if (!bytes || bytes.length < 1 + CHECKSUM_BYTES) return invalid;
2538
+ if (bytes[0] > VERSION && bytes[0] <= MAX_PLAUSIBLE_VERSION) return { ok: false, reason: "newer" };
2539
+ const body = bytes.slice(0, -CHECKSUM_BYTES);
2540
+ const sum = sha256(body);
2541
+ if (!bytes.slice(-CHECKSUM_BYTES).every((b, i) => b === sum[i])) return invalid;
2542
+ if (body[0] !== VERSION) return invalid;
2543
+ const pairing = readBody(body);
2544
+ return pairing ? { ok: true, pairing } : invalid;
2545
+ }
2546
+ function readBody(body) {
2547
+ let at = 1;
2548
+ const take = (n) => {
2549
+ if (at + n > body.length) return null;
2550
+ at += n;
2551
+ return body.slice(at - n, at);
2552
+ };
2553
+ const decoder = new TextDecoder("utf-8", { fatal: true });
2554
+ const text = () => {
2555
+ const length = take(2);
2556
+ const bytes = length && take(length[0] << 8 | length[1]);
2557
+ if (!bytes) return null;
2558
+ try {
2559
+ return decoder.decode(bytes);
2560
+ } catch {
2561
+ return null;
2562
+ }
2563
+ };
2564
+ const agentKey = take(KEY_BYTES);
2565
+ const secret = take(KEY_BYTES);
2566
+ const rawName = text();
2567
+ const path = text();
2568
+ const count = take(1)?.[0];
2569
+ if (!agentKey || !secret || rawName === null || path === null || count === void 0) return null;
2570
+ if (count === 0 || count > MAX_ADDRESSES) return null;
2571
+ const urls = [];
2572
+ for (let i = 0; i < count; i++) {
2573
+ const scheme = SCHEMES[take(1)?.[0] ?? -1];
2574
+ const rest = text();
2575
+ if (!scheme || rest === null || !isMachineUrl(scheme + rest)) return null;
2576
+ urls.push(scheme + rest);
2577
+ }
2578
+ if (at !== body.length) return null;
2579
+ const name = rawName.trim() || "machine";
2580
+ if (name.length > MAX_NAME) return null;
2581
+ return {
2582
+ urls,
2583
+ agentKey: b64url.encode(agentKey),
2584
+ secret: b64url.encode(secret),
2585
+ name,
2586
+ // A path the phone cannot use costs the path, not the pairing.
2587
+ ...isPairingPath(path) ? { defaultPath: path } : {}
2588
+ };
2589
+ }
2590
+ function encode32(bytes) {
2591
+ let out = "";
2592
+ let value = 0;
2593
+ let bits = 0;
2594
+ for (const byte of bytes) {
2595
+ value = (value << 8 | byte) & 4095;
2596
+ bits += 8;
2597
+ while (bits >= 5) {
2598
+ out += ALPHABET[value >>> bits - 5 & 31];
2599
+ bits -= 5;
2600
+ }
2601
+ }
2602
+ return bits > 0 ? out + ALPHABET[value << 5 - bits & 31] : out;
2603
+ }
2604
+ function decode32(text) {
2605
+ const out = [];
2606
+ let value = 0;
2607
+ let bits = 0;
2608
+ for (const char of text) {
2609
+ const digit = ALPHABET.indexOf(char);
2610
+ if (digit < 0) return null;
2611
+ value = (value << 5 | digit) & 4095;
2612
+ bits += 5;
2613
+ if (bits >= 8) {
2614
+ out.push(value >>> bits - 8 & 255);
2615
+ bits -= 8;
2616
+ }
2617
+ }
2618
+ if (bits >= 5 || (value & (1 << bits) - 1) !== 0) return null;
2619
+ return Uint8Array.from(out);
2620
+ }
2621
+
2512
2622
  // ../../node_modules/.pnpm/@noble+ciphers@2.4.0/node_modules/@noble/ciphers/utils.js
2513
2623
  function isBytes3(a) {
2514
2624
  return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
@@ -3740,10 +3850,10 @@ var SHELL_SAFE = /^[\w./@+-]+$/;
3740
3850
  var ADDRESS = /^wss:\/\/[A-Za-z0-9.-]+(:\d{1,5})?(\/[\w-]{0,64})?$/;
3741
3851
  var PWA_ORIGIN = /^https?:\/\/[A-Za-z0-9.-]+(:\d{1,5})?\/?$/;
3742
3852
  var FUNNEL_PORTS_CAP = "https://tailscale.com/cap/funnel-ports?ports=";
3743
- function planSetup(flags, state) {
3853
+ function planSetup(given, state) {
3744
3854
  if (state.phonePortOwner === "other") {
3745
3855
  return {
3746
- error: `127.0.0.1:${flags.phonePort} is taken by another process (another user's agent?): pick a free one with --phone-port, e.g. ${flags.phonePort + 1}`
3856
+ error: `127.0.0.1:${given.phonePort} is taken by another process (another user's agent?): pick a free one with --phone-port, e.g. ${given.phonePort + 1}`
3747
3857
  };
3748
3858
  }
3749
3859
  if (state.hooksPortOwner === "other") {
@@ -3757,21 +3867,27 @@ function planSetup(flags, state) {
3757
3867
  if (state.adoption?.agentLive.kind === "other") {
3758
3868
  return { error: `${state.adoption.agentLive.path} is not a link, and setup only ever removes a link there: move it away, then run tarfa setup again` };
3759
3869
  }
3870
+ const reach = resolveReachability(given, state);
3871
+ if ("error" in reach) return reach;
3872
+ const flags = reach.flags;
3873
+ const relayHint = (error) => reach.relayDefault ? { error: `${error.error} (this machine was reached through a public relay, so setup defaults to Funnel; pass --tailscale serve for tailnet only)` } : error;
3760
3874
  const target = `http://127.0.0.1:${flags.phonePort}`;
3761
3875
  const path = flags.slug ? `/${flags.slug}` : "/";
3762
3876
  const steps = [];
3763
- const checks = [];
3877
+ const checks = [...reach.notes];
3764
3878
  let serveStep;
3765
3879
  let operatorStep;
3766
3880
  let derived;
3881
+ let publicDerived = false;
3767
3882
  if (flags.tailscale !== "none") {
3768
3883
  const port = flags.tailscalePort ?? (flags.tailscale === "funnel" ? 1e4 : 443);
3769
3884
  const checked = checkTailscale(flags.tailscale, port, state.tailscale);
3770
- if ("error" in checked) return checked;
3885
+ if ("error" in checked) return relayHint(checked);
3771
3886
  checks.push(...checked.checks);
3772
3887
  if (state.serve === null) return { error: "could not read `tailscale serve status --json`: setup will not add a handler it cannot check against the existing ones" };
3773
3888
  const served = serveNeeds(state.serve, `${checked.host}:${port}`, port, path, target, flags.tailscale === "funnel");
3774
- if ("error" in served) return served;
3889
+ if ("error" in served) return relayHint(served);
3890
+ publicDerived = flags.tailscale === "funnel" || served.public;
3775
3891
  if (served.public) checks.push(`tailscale: port ${port} is on Funnel, so this address is reachable from the internet`);
3776
3892
  if (served.needed) serveStep = { kind: "tailscale-serve", port, path, target, funnel: flags.tailscale === "funnel" || served.public };
3777
3893
  derived = `wss://${checked.host}:${port}${path === "/" ? "" : path}`;
@@ -3782,6 +3898,7 @@ function planSetup(flags, state) {
3782
3898
  }
3783
3899
  const addresses = [...derived ? [derived] : [], ...flags.addresses];
3784
3900
  if (addresses.length > MAX_ADDRESSES) return { error: `a machine takes at most ${MAX_ADDRESSES} addresses (got ${addresses.length})` };
3901
+ checks.push(describeReachability(addresses.length > 0 ? addresses : reach.kept, state, derived !== void 0 ? { address: derived, public: publicDerived } : null));
3785
3902
  if (state.legacyService) steps.push({ kind: "stop-legacy-service" });
3786
3903
  if (state.stateDir === "legacy") steps.push({ kind: "move-state" });
3787
3904
  const adoption = state.adoption;
@@ -3820,6 +3937,65 @@ function planSetup(flags, state) {
3820
3937
  if (flags.pair) steps.push({ kind: "pair" });
3821
3938
  return { steps, checks };
3822
3939
  }
3940
+ var CHOOSE = "say whether phones must reach this machine without Tailscale: --tailscale funnel (with or without Tailscale, through Funnel) or --tailscale serve (only with Tailscale on); --tailscale none --address <wss-url> if you publish the phone port yourself";
3941
+ function resolveReachability(flags, state) {
3942
+ const env = withLegacyEnv(parseEnvFile(state.envText ?? ""));
3943
+ const relay = Boolean(env.TARFA_RELAY_URLS?.trim() || env.TARFA_RELAY_URL?.trim()) || state.legacyService;
3944
+ const kept = (env.TARFA_PHONE_ADDRESSES ?? "").split(",").map((a) => a.trim()).filter(Boolean);
3945
+ const settled = (tailscale, over = {}, notes = [], relayDefault = false) => ({
3946
+ flags: { ...flags, ...over, tailscale },
3947
+ kept,
3948
+ notes,
3949
+ relayDefault
3950
+ });
3951
+ if (flags.tailscale !== void 0) {
3952
+ const lost = relay && flags.tailscale === "serve" ? ["this machine was reached through a public relay, so phones reached it without Tailscale; with --tailscale serve they reach it only with Tailscale on (--tailscale funnel keeps the old reach)"] : [];
3953
+ return settled(flags.tailscale, {}, lost);
3954
+ }
3955
+ if (flags.addresses.length > 0 || kept.length > 0) return settled("none");
3956
+ const handler = phonePortHandler(state.serve, `http://127.0.0.1:${flags.phonePort}`, flags.slug, flags.tailscalePort);
3957
+ if (handler && relay && !handler.funnel) {
3958
+ return {
3959
+ error: `this machine was reached through a public relay, but its phone port is served on the tailnet only (port ${handler.port}): pass --tailscale funnel to keep it reachable without Tailscale, or --tailscale serve to make it tailnet-only`
3960
+ };
3961
+ }
3962
+ if (handler) {
3963
+ return settled(handler.funnel ? "funnel" : "serve", { tailscalePort: handler.port, ...handler.slug ? { slug: handler.slug } : {} });
3964
+ }
3965
+ if (relay) {
3966
+ return settled("funnel", {}, ["this machine was reached through a public relay, so setup keeps it reachable without Tailscale: --tailscale funnel (pass --tailscale serve for tailnet only)"], true);
3967
+ }
3968
+ return { error: CHOOSE };
3969
+ }
3970
+ function phonePortHandler(serve, target, slug, port) {
3971
+ const found = [];
3972
+ for (const [hostPort, web] of Object.entries(serve?.Web ?? {})) {
3973
+ for (const [path, handler] of Object.entries(web.Handlers ?? {})) {
3974
+ if (handler.Proxy?.replace(/\/$/, "") !== target) continue;
3975
+ if (slug !== void 0 && path !== `/${slug}`) continue;
3976
+ const at = Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
3977
+ if (port !== void 0 && at !== port) continue;
3978
+ found.push({ port: at, ...path === "/" ? {} : { slug: path.slice(1) }, funnel: serve?.AllowFunnel?.[hostPort] === true });
3979
+ }
3980
+ }
3981
+ return found.find((h) => h.funnel) ?? found[0] ?? null;
3982
+ }
3983
+ function describeReachability(addresses, state, derived) {
3984
+ if (addresses.length === 0) {
3985
+ return "reachability: phones cannot reach this machine yet: it has no phone address (--tailscale funnel or serve, or --address)";
3986
+ }
3987
+ const self = (state.tailscale?.Self?.DNSName ?? "").replace(/\.$/, "");
3988
+ const kinds = addresses.map((address) => {
3989
+ if (derived?.address === address) return derived.public ? "public" : "tailnet";
3990
+ const url = new URL(address);
3991
+ if (state.serve === null || !self || url.hostname !== self) return "unknown";
3992
+ return state.serve.AllowFunnel?.[`${url.hostname}:${url.port || 443}`] === true ? "public" : "tailnet";
3993
+ });
3994
+ const list = addresses.join(", ");
3995
+ if (kinds.includes("public")) return `reachability: phones reach this machine with or without Tailscale (${list})`;
3996
+ if (kinds.every((k) => k === "tailnet")) return `reachability: phones reach this machine only with Tailscale on (${list})`;
3997
+ return `reachability: phones reach this machine at ${list}; setup cannot tell whether that needs Tailscale`;
3998
+ }
3823
3999
  function planEnv(flags, state, addresses) {
3824
4000
  const legacy = renameLegacyEnvLines(state.envText ?? "");
3825
4001
  const before = legacy.text;
@@ -3971,8 +4147,8 @@ function parseSetupFlags(argv) {
3971
4147
  if (phonePort === null) return { error: "--phone-port must be a port number (1-65535)" };
3972
4148
  const slug = single.get("--slug");
3973
4149
  if (slug !== void 0 && !SLUG.test(slug)) return { error: "--slug takes letters, digits, _ and - (at most 64)" };
3974
- const tailscale = single.get("--tailscale") ?? "none";
3975
- if (tailscale !== "serve" && tailscale !== "funnel" && tailscale !== "none") return { error: "--tailscale takes serve, funnel or none" };
4150
+ const tailscale = single.get("--tailscale");
4151
+ if (tailscale !== void 0 && tailscale !== "serve" && tailscale !== "funnel" && tailscale !== "none") return { error: "--tailscale takes serve, funnel or none" };
3976
4152
  const tailscalePort = single.has("--tailscale-port") ? portNumber(single.get("--tailscale-port")) : void 0;
3977
4153
  if (tailscalePort === null) return { error: "--tailscale-port must be a port number" };
3978
4154
  const bad = addresses.find((a) => !isMachineUrl(a) || !ADDRESS.test(a));
@@ -3993,7 +4169,7 @@ function parseSetupFlags(argv) {
3993
4169
  ...defaultPath !== void 0 ? { defaultPath } : {},
3994
4170
  phonePort,
3995
4171
  ...slug !== void 0 ? { slug } : {},
3996
- tailscale,
4172
+ ...tailscale !== void 0 ? { tailscale } : {},
3997
4173
  ...tailscalePort !== void 0 ? { tailscalePort } : {},
3998
4174
  addresses,
3999
4175
  ...pwa !== void 0 ? { pwa } : {},
@@ -4207,6 +4383,7 @@ function loadConfig(rawEnv, home) {
4207
4383
  // Usually a symlink to a vocabulary kept with the user's own dotfiles, so one list can
4208
4384
  // serve every machine; TARFA_KEYTERMS_PATH points at it directly instead.
4209
4385
  keytermsPath: env.TARFA_KEYTERMS_PATH ?? join3(stateDir, "keyterms.txt"),
4386
+ dictationKeysPath: join3(stateDir, "dictation-keys.json"),
4210
4387
  clientLogPath: join3(stateDir, "client-log.jsonl"),
4211
4388
  hookEventLogPath: join3(stateDir, "hook-events.jsonl"),
4212
4389
  messageLogPath: join3(stateDir, "messages.jsonl"),
@@ -4938,9 +5115,25 @@ async function browseDir(deps, path, allowedRoots) {
4938
5115
  };
4939
5116
  }
4940
5117
 
4941
- // src/infrastructure/agent-key-file.ts
4942
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
5118
+ // src/infrastructure/atomic-write.ts
5119
+ import { chmod, mkdir as mkdir4, rename as rename3, rm, writeFile as writeFile3 } from "node:fs/promises";
4943
5120
  import { dirname as dirname5 } from "node:path";
5121
+ async function atomicWrite(path, text, mode) {
5122
+ await mkdir4(dirname5(path), { recursive: true });
5123
+ const temp = `${path}.tmp-${process.pid}`;
5124
+ try {
5125
+ await writeFile3(temp, text, { mode });
5126
+ await chmod(temp, mode);
5127
+ await rename3(temp, path);
5128
+ } catch (err) {
5129
+ await rm(temp, { force: true });
5130
+ throw err;
5131
+ }
5132
+ }
5133
+
5134
+ // src/infrastructure/agent-key-file.ts
5135
+ import { mkdir as mkdir5, readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises";
5136
+ import { dirname as dirname6 } from "node:path";
4944
5137
  async function loadOrCreateAgentKeys(path) {
4945
5138
  try {
4946
5139
  return parseKeys(await readFile4(path, "utf8"), path);
@@ -4948,9 +5141,9 @@ async function loadOrCreateAgentKeys(path) {
4948
5141
  if (err.code !== "ENOENT") throw err;
4949
5142
  }
4950
5143
  const keys = generateAgentKeys();
4951
- await mkdir4(dirname5(path), { recursive: true, mode: 448 });
5144
+ await mkdir5(dirname6(path), { recursive: true, mode: 448 });
4952
5145
  try {
4953
- await writeFile3(path, `${JSON.stringify(keys, null, 2)}
5146
+ await writeFile4(path, `${JSON.stringify(keys, null, 2)}
4954
5147
  `, { mode: 384, flag: "wx" });
4955
5148
  return keys;
4956
5149
  } catch (err) {
@@ -4958,6 +5151,16 @@ async function loadOrCreateAgentKeys(path) {
4958
5151
  return parseKeys(await readFile4(path, "utf8"), path);
4959
5152
  }
4960
5153
  }
5154
+ async function readAgentPublicKey(path) {
5155
+ let text;
5156
+ try {
5157
+ text = await readFile4(path, "utf8");
5158
+ } catch (err) {
5159
+ if (err.code === "ENOENT") throw new Error(`no agent key at ${path}: run tarfa setup first`);
5160
+ throw err;
5161
+ }
5162
+ return parseKeys(text, path).x25519.publicKey;
5163
+ }
4961
5164
  function parseKeys(text, path) {
4962
5165
  const keyPattern = /^[A-Za-z0-9_-]{43}$/;
4963
5166
  const parsed = JSON.parse(text);
@@ -5021,8 +5224,8 @@ function forgetDevice(roster, id2) {
5021
5224
  }
5022
5225
 
5023
5226
  // src/infrastructure/file-device-store.ts
5024
- import { mkdir as mkdir5, readFile as readFile5 } from "node:fs/promises";
5025
- import { dirname as dirname6 } from "node:path";
5227
+ import { mkdir as mkdir6, readFile as readFile5 } from "node:fs/promises";
5228
+ import { dirname as dirname7 } from "node:path";
5026
5229
  import { z as z2 } from "zod";
5027
5230
  var key = z2.string().regex(/^[A-Za-z0-9_-]{43}$/);
5028
5231
  var device = z2.object({ id: z2.string(), x25519: key, ed25519: key, name: z2.string(), enrolledAt: z2.string() });
@@ -5069,7 +5272,7 @@ var FileDeviceStore = class {
5069
5272
  }
5070
5273
  if (parsed?.tokenEnrolment === false) return false;
5071
5274
  }
5072
- await mkdir5(dirname6(this.filePath), { recursive: true, mode: 448 });
5275
+ await mkdir6(dirname7(this.filePath), { recursive: true, mode: 448 });
5073
5276
  await this.update((roster) => roster);
5074
5277
  return true;
5075
5278
  }
@@ -5083,128 +5286,16 @@ var FileDeviceStore = class {
5083
5286
  }
5084
5287
  };
5085
5288
 
5086
- // src/application/pairing.ts
5087
- var PAIRING_TTL_MS = 10 * 6e4;
5088
- var PAIRING_RETAIN_MS = 24 * 60 * 6e4;
5089
- var retained = (list, now) => list.filter((p) => Date.parse(p.expiresAt) + PAIRING_RETAIN_MS > now.getTime());
5090
- function stateOf(p, now) {
5091
- if (p.usedAt) return "used";
5092
- return now.getTime() < Date.parse(p.expiresAt) ? "live" : "expired";
5093
- }
5094
- function addPairing(list, secret, now, ttlMs = PAIRING_TTL_MS) {
5095
- return [...retained(list, now), { secret, expiresAt: new Date(now.getTime() + ttlMs).toISOString() }];
5096
- }
5097
- function redeemPairing(list, secret, now, device2) {
5098
- const kept = retained(list, now);
5099
- const match = secret ? kept.find((p) => constantTimeEqual(p.secret, secret)) : void 0;
5100
- if (!match) return { list: kept, outcome: "unknown" };
5101
- if (match.usedAt && match.usedBy === device2) return { list: kept, outcome: "mine" };
5102
- const state = stateOf(match, now);
5103
- if (state !== "live") return { list: kept, outcome: state };
5104
- const at = now.toISOString();
5105
- const spent = { secret: match.secret, expiresAt: at, usedAt: at, usedBy: device2 };
5106
- return { list: kept.map((p) => p === match ? spent : p), outcome: "redeemed" };
5107
- }
5108
-
5109
- // src/infrastructure/file-pairing-store.ts
5110
- import { readFile as readFile6 } from "node:fs/promises";
5111
- import { z as z3 } from "zod";
5112
- var schema = z3.array(z3.object({ secret: z3.string().regex(/^[A-Za-z0-9_-]{43}$/), expiresAt: z3.string(), usedAt: z3.string().optional(), usedBy: z3.string().optional() }));
5113
- var FilePairingStore = class {
5114
- constructor(filePath, staleLockMs = 1e4) {
5115
- this.filePath = filePath;
5116
- this.staleLockMs = staleLockMs;
5117
- }
5118
- filePath;
5119
- staleLockMs;
5120
- async read() {
5121
- try {
5122
- const parsed = schema.safeParse(JSON.parse(await readFile6(this.filePath, "utf8")));
5123
- return parsed.success ? parsed.data : [];
5124
- } catch {
5125
- return [];
5126
- }
5127
- }
5128
- async update(change) {
5129
- return withFileLock(this.filePath, this.staleLockMs, async () => {
5130
- const current = await this.read();
5131
- const next = change(current);
5132
- if (JSON.stringify(next) !== JSON.stringify(current)) await writeFileAtomic(this.filePath, `${JSON.stringify(next, null, 2)}
5133
- `);
5134
- return next;
5135
- });
5136
- }
5137
- };
5138
-
5139
- // src/infrastructure/agent-env-file.ts
5140
- import { execFile as execFile3 } from "node:child_process";
5141
- import { existsSync as existsSync3 } from "node:fs";
5142
- import { chmod, copyFile, mkdir as mkdir6, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
5143
- import { dirname as dirname7, resolve as resolve5 } from "node:path";
5144
- import { fileURLToPath as fileURLToPath2 } from "node:url";
5145
- import { promisify as promisify3 } from "node:util";
5146
-
5147
- // src/application/agent-env-location.ts
5148
- import { join as join6 } from "node:path";
5149
- function resolveAgentEnvPath(input) {
5150
- if (input.env.TARFA_AGENT_ENV) return input.env.TARFA_AGENT_ENV;
5151
- const stateFile = join6(input.home, ".tarfa", "agent.env");
5152
- if (input.exists(stateFile) || input.checkout === null) return stateFile;
5153
- return join6(input.checkout, "deploy", "agent.env");
5154
- }
5155
-
5156
- // src/infrastructure/agent-env-file.ts
5157
- var exec3 = promisify3(execFile3);
5158
- function withoutGitVars(env) {
5159
- return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith("GIT_")));
5160
- }
5161
- async function mainCheckout(kind2 = installKind()) {
5162
- if (kind2 === "package") return null;
5163
- const here = dirname7(fileURLToPath2(import.meta.url));
5164
- try {
5165
- const { stdout } = await exec3("git", ["-C", here, "rev-parse", "--path-format=absolute", "--git-common-dir"], { env: withoutGitVars(process.env) });
5166
- return dirname7(stdout.trim());
5167
- } catch {
5168
- return resolve5(here, "..", "..", "..", "..");
5169
- }
5170
- }
5171
- async function locateAgentEnv(env, home, kind2 = installKind()) {
5172
- return resolveAgentEnvPath({ env, home, checkout: await mainCheckout(kind2), exists: existsSync3 });
5173
- }
5174
- function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
5175
- return {
5176
- path,
5177
- read: async () => {
5178
- try {
5179
- return await readFile7(path, "utf8");
5180
- } catch (err) {
5181
- if (err.code === "ENOENT") {
5182
- throw new Error(`no agent config at ${path}: set the agent up first (docs/DEPLOY.md), or set TARFA_AGENT_ENV`);
5183
- }
5184
- throw err;
5185
- }
5186
- },
5187
- write: async (text) => {
5188
- let backup = `${path}.bak-${now().toISOString().replace(/[:.]/g, "-")}`;
5189
- try {
5190
- await copyFile(path, backup);
5191
- await chmod(backup, 384);
5192
- } catch (err) {
5193
- if (err.code !== "ENOENT") throw err;
5194
- backup = null;
5195
- }
5196
- await mkdir6(dirname7(path), { recursive: true, mode: 448 });
5197
- await writeFile4(path, text, { mode: 384 });
5198
- await chmod(path, 384);
5199
- return backup;
5200
- }
5201
- };
5289
+ // ../contracts/src/domain/workspace-link.ts
5290
+ var WORKSPACE_LINK_PREFIX = "#open?";
5291
+ var KEY = /^[A-Za-z0-9_-]{43}$/;
5292
+ var ID = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
5293
+ var valid = (link) => KEY.test(link.agentKey) && ID.test(link.workspaceId);
5294
+ function workspaceLink(pwaOrigin, link) {
5295
+ if (!valid(link)) throw new Error(`not a linkable workspace: ${link.workspaceId}`);
5296
+ return `${new URL(pwaOrigin).origin}/${WORKSPACE_LINK_PREFIX}k=${link.agentKey}&w=${link.workspaceId}`;
5202
5297
  }
5203
5298
 
5204
- // src/infrastructure/file-status-store.ts
5205
- import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
5206
- import { dirname as dirname8 } from "node:path";
5207
-
5208
5299
  // ../contracts/src/domain/workspace-status.ts
5209
5300
  var WORKSPACE_STATUSES = [
5210
5301
  "needs-feedback",
@@ -5228,6 +5319,17 @@ function runsClaude(command) {
5228
5319
  return /(^|[\s/])claude(\s|$)/.test(command);
5229
5320
  }
5230
5321
 
5322
+ // ../contracts/src/domain/dictation-key-format.ts
5323
+ var SHAPES = {
5324
+ elevenlabs: [/^sk_[A-Za-z0-9]{32,}$/, /^[0-9a-f]{32}$/],
5325
+ openrouter: [/^sk-or-v\d+-[A-Za-z0-9]{32,}$/]
5326
+ };
5327
+ function dictationKeyProblem(provider, key2) {
5328
+ if (SHAPES[provider].some((shape) => shape.test(key2))) return null;
5329
+ const other = provider === "elevenlabs" ? "openrouter" : "elevenlabs";
5330
+ return SHAPES[other].some((shape) => shape.test(key2)) ? "other-provider" : "format";
5331
+ }
5332
+
5231
5333
  // ../contracts/src/domain/image-media-type.ts
5232
5334
  var BY_EXT = {
5233
5335
  png: "image/png",
@@ -5273,244 +5375,274 @@ function audioMediaType(path) {
5273
5375
  }
5274
5376
 
5275
5377
  // ../contracts/src/protocol/messages.ts
5276
- import { z as z6 } from "zod";
5378
+ import { z as z5 } from "zod";
5277
5379
 
5278
5380
  // ../contracts/src/protocol/session-channels.ts
5279
- import { z as z5 } from "zod";
5381
+ import { z as z4 } from "zod";
5280
5382
 
5281
5383
  // ../contracts/src/protocol/client-log.ts
5282
- import { z as z4 } from "zod";
5283
- var clientLogEventSchema = z4.object({
5284
- sess: z4.string(),
5384
+ import { z as z3 } from "zod";
5385
+ var clientLogEventSchema = z3.object({
5386
+ sess: z3.string(),
5285
5387
  // per-app-launch id
5286
- seq: z4.number().int().nonnegative(),
5388
+ seq: z3.number().int().nonnegative(),
5287
5389
  // monotonic within sess
5288
- t: z4.number(),
5390
+ t: z3.number(),
5289
5391
  // epoch ms at emit
5290
- machineId: z4.string().optional(),
5392
+ machineId: z3.string().optional(),
5291
5393
  // absent for app-global events (visibility/online)
5292
- kind: z4.string(),
5293
- code: z4.number().int().optional(),
5394
+ kind: z3.string(),
5395
+ code: z3.number().int().optional(),
5294
5396
  // WS close code, when reported
5295
- reason: z4.string().optional(),
5397
+ reason: z3.string().optional(),
5296
5398
  // classified diagnostic message / close reason
5297
- online: z4.boolean(),
5298
- vis: z4.string()
5399
+ online: z3.boolean(),
5400
+ vis: z3.string()
5299
5401
  });
5300
- var clientLogBatchSchema = z4.object({
5301
- ua: z4.string().optional(),
5302
- shell: z4.string().optional(),
5303
- events: z4.array(clientLogEventSchema).min(1).max(500)
5402
+ var clientLogBatchSchema = z3.object({
5403
+ ua: z3.string().optional(),
5404
+ shell: z3.string().optional(),
5405
+ events: z3.array(clientLogEventSchema).min(1).max(500)
5304
5406
  });
5305
5407
 
5306
5408
  // ../contracts/src/protocol/session-channels.ts
5307
- var id = z5.string().min(1).max(64);
5308
- var mediaResultSchema = z5.enum(["ok", "forbidden", "not-found", "too-large", "error"]);
5409
+ var id = z4.string().min(1).max(64);
5410
+ var mediaResultSchema = z4.enum(["ok", "forbidden", "not-found", "too-large", "error"]);
5309
5411
  var sessionClientMessageSchemas = [
5310
- z5.object({ type: z5.literal("readMedia"), id, path: z5.string() }),
5311
- z5.object({ type: z5.literal("mediaAck"), id, seq: z5.number().int().nonnegative() }),
5312
- z5.object({ type: z5.literal("mediaCancel"), id }),
5412
+ z4.object({ type: z4.literal("readMedia"), id, path: z4.string() }),
5413
+ z4.object({ type: z4.literal("mediaAck"), id, seq: z4.number().int().nonnegative() }),
5414
+ z4.object({ type: z4.literal("mediaCancel"), id }),
5313
5415
  // A browser view: `data` is one message of the browser stream protocol (browser-messages.ts)
5314
5416
  // as JSON text. Each `browser` the agent sends
5315
5417
  // is acknowledged with a `browserAck`, which is what lets the agent drop stale frames for a
5316
5418
  // slow phone (see BrowserStreamHandler).
5317
- z5.object({ type: z5.literal("browserOpen"), stream: id, session: z5.string() }),
5318
- z5.object({ type: z5.literal("browser"), stream: id, data: z5.string() }),
5319
- z5.object({ type: z5.literal("browserAck"), stream: id }),
5320
- z5.object({ type: z5.literal("browserClose"), stream: id }),
5321
- z5.object({ type: z5.literal("clientLog"), id, batch: clientLogBatchSchema })
5419
+ z4.object({ type: z4.literal("browserOpen"), stream: id, session: z4.string() }),
5420
+ z4.object({ type: z4.literal("browser"), stream: id, data: z4.string() }),
5421
+ z4.object({ type: z4.literal("browserAck"), stream: id }),
5422
+ z4.object({ type: z4.literal("browserClose"), stream: id }),
5423
+ z4.object({ type: z4.literal("clientLog"), id, batch: clientLogBatchSchema })
5322
5424
  ];
5323
5425
  var sessionAgentMessageSchemas = [
5324
5426
  // The answer to `readMedia`. `size` and `mediaType` come with `ok`; `size` bytes then follow
5325
5427
  // as chunks numbered from 0.
5326
- z5.object({
5327
- type: z5.literal("media"),
5428
+ z4.object({
5429
+ type: z4.literal("media"),
5328
5430
  id,
5329
5431
  result: mediaResultSchema,
5330
- mediaType: z5.string().optional(),
5331
- size: z5.number().int().nonnegative().optional()
5432
+ mediaType: z4.string().optional(),
5433
+ size: z4.number().int().nonnegative().optional()
5332
5434
  }),
5333
- z5.object({ type: z5.literal("mediaChunk"), id, seq: z5.number().int().nonnegative(), data: z5.string() }),
5334
- z5.object({ type: z5.literal("browser"), stream: id, data: z5.string() }),
5435
+ z4.object({ type: z4.literal("mediaChunk"), id, seq: z4.number().int().nonnegative(), data: z4.string() }),
5436
+ z4.object({ type: z4.literal("browser"), stream: id, data: z4.string() }),
5335
5437
  // The agent ended the stream (the session closed, was taken over, or never existed); the
5336
5438
  // reason, when there is one, came just before as a browser-protocol `ended`.
5337
- z5.object({ type: z5.literal("browserClosed"), stream: id }),
5338
- z5.object({ type: z5.literal("clientLogAck"), id, ok: z5.boolean() })
5439
+ z4.object({ type: z4.literal("browserClosed"), stream: id }),
5440
+ z4.object({ type: z4.literal("clientLogAck"), id, ok: z4.boolean() })
5339
5441
  ];
5340
5442
 
5341
5443
  // ../contracts/src/protocol/messages.ts
5342
- var workspaceStatusSchema = z6.enum(
5444
+ var workspaceStatusSchema = z5.enum(
5343
5445
  WORKSPACE_STATUSES
5344
5446
  );
5345
- var workspaceSchema = z6.object({
5346
- machineId: z6.string(),
5347
- id: z6.string(),
5348
- name: z6.string(),
5349
- projectPath: z6.string(),
5350
- command: z6.string(),
5351
- createdAt: z6.number(),
5352
- lastActivityAt: z6.number(),
5447
+ var workspaceSchema = z5.object({
5448
+ machineId: z5.string(),
5449
+ id: z5.string(),
5450
+ name: z5.string(),
5451
+ projectPath: z5.string(),
5452
+ command: z5.string(),
5453
+ createdAt: z5.number(),
5454
+ lastActivityAt: z5.number(),
5353
5455
  status: workspaceStatusSchema,
5354
5456
  // Optional GitHub owner/repo (parsed from the origin remote by the agent). `.optional()`
5355
5457
  // keeps a version-skewed rollout safe: an old agent omits it, a new one includes it.
5356
- github: z6.object({ owner: z6.string(), repo: z6.string(), ownerType: z6.enum(["user", "org"]).optional() }).optional(),
5458
+ github: z5.object({ owner: z5.string(), repo: z5.string(), ownerType: z5.enum(["user", "org"]).optional() }).optional(),
5357
5459
  // Manual "pin to top of its status group" flag. `.optional()` keeps a version-skewed
5358
5460
  // rollout safe: an old agent omits it and the PWA reads it as not-urgent.
5359
- urgent: z6.boolean().optional(),
5461
+ urgent: z5.boolean().optional(),
5360
5462
  // The name Claude Code's session registry gives the session running in this workspace.
5361
5463
  // `.optional()` keeps a version-skewed rollout safe: an old agent omits it and the PWA
5362
5464
  // simply shows no badge. It must be declared here or zod strips it off the wire.
5363
- agentAddress: z6.string().optional(),
5364
- parentId: z6.string().optional(),
5365
- waitingOn: z6.literal("parent").optional(),
5366
- asking: z6.boolean().optional()
5465
+ agentAddress: z5.string().optional(),
5466
+ parentId: z5.string().optional(),
5467
+ waitingOn: z5.literal("parent").optional(),
5468
+ asking: z5.boolean().optional()
5367
5469
  });
5368
- var dim = z6.number().int().positive();
5369
- var browserSessionSchema = z6.object({ name: z6.string() });
5370
- var machineStatsSchema = z6.object({
5371
- cpuPercent: z6.number(),
5372
- memory: z6.object({ usedBytes: z6.number(), totalBytes: z6.number() }),
5373
- swapUsedBytes: z6.number(),
5374
- limit: z6.object({
5375
- usedBytes: z6.number(),
5376
- totalBytes: z6.number(),
5377
- stallPercent: z6.number().optional(),
5470
+ var dim = z5.number().int().positive();
5471
+ var browserSessionSchema = z5.object({ name: z5.string() });
5472
+ var machineStatsSchema = z5.object({
5473
+ cpuPercent: z5.number(),
5474
+ memory: z5.object({ usedBytes: z5.number(), totalBytes: z5.number() }),
5475
+ swapUsedBytes: z5.number(),
5476
+ limit: z5.object({
5477
+ usedBytes: z5.number(),
5478
+ totalBytes: z5.number(),
5479
+ stallPercent: z5.number().optional(),
5378
5480
  // The kill line. `totalBytes` is min(high, max) — the throttle line the machine is
5379
5481
  // normally pinned against — so headroom, which measures the distance to an OOM kill,
5380
5482
  // needs `memory.max` separately. Absent when the cgroup sets no hard limit.
5381
- maxBytes: z6.number().optional(),
5483
+ maxBytes: z5.number().optional(),
5382
5484
  // Page cache plus reclaimable slab, less mapped file pages: memory the kernel can
5383
5485
  // hand back without killing anything. This is what makes `usedBytes` sitting at the
5384
5486
  // ceiling harmless, and what headroom adds to the distance-to-max.
5385
- reclaimableBytes: z6.number().optional(),
5487
+ reclaimableBytes: z5.number().optional(),
5386
5488
  // Cumulative `memory.events` oom_kill for the cgroup. The client keeps the first
5387
5489
  // value it sees per connection and colours on the delta, so the agent stays dumb.
5388
- oomKills: z6.number().optional()
5490
+ oomKills: z5.number().optional()
5389
5491
  }).optional()
5390
5492
  });
5391
- var clientMessageSchema = z6.discriminatedUnion("type", [
5493
+ var dictationProviderSchema = z5.enum(["elevenlabs", "openrouter"]);
5494
+ var clientMessageSchema = z5.discriminatedUnion("type", [
5392
5495
  // `token` carries a `tarfa pair` code for a new phone, or nothing (an enrolled key needs none).
5393
- z6.object({ type: z6.literal("auth"), token: z6.string() }),
5394
- z6.object({ type: z6.literal("list") }),
5395
- z6.object({ type: z6.literal("getRecentPaths") }),
5396
- z6.object({ type: z6.literal("browseDir"), path: z6.string() }),
5496
+ z5.object({ type: z5.literal("auth"), token: z5.string() }),
5497
+ z5.object({ type: z5.literal("list") }),
5498
+ z5.object({ type: z5.literal("getRecentPaths") }),
5499
+ z5.object({ type: z5.literal("browseDir"), path: z5.string() }),
5397
5500
  // Create a new directory named `name` inside the already-browsed `parent`. The agent
5398
5501
  // authorizes it against the same allowed-roots set as browseDir, then replies with a
5399
5502
  // `dirEntries` for the new (empty) folder so the picker browses into it.
5400
- z6.object({ type: z6.literal("makeDir"), parent: z6.string(), name: z6.string() }),
5401
- z6.object({ type: z6.literal("listRoots") }),
5503
+ z5.object({ type: z5.literal("makeDir"), parent: z5.string(), name: z5.string() }),
5504
+ z5.object({ type: z5.literal("listRoots") }),
5402
5505
  // No longer answered; kept so an agent does not refuse it from an older PWA.
5403
- z6.object({ type: z6.literal("listCommands") }),
5404
- z6.object({ type: z6.literal("listLaunchers") }),
5405
- z6.object({ type: z6.literal("forgetLauncher"), command: z6.string() }),
5406
- z6.object({ type: z6.literal("listKeyterms") }),
5407
- z6.object({ type: z6.literal("readFile"), path: z6.string() }),
5408
- z6.object({ type: z6.literal("attach"), workspaceId: z6.string(), cols: dim, rows: dim }),
5409
- z6.object({ type: z6.literal("detach"), workspaceId: z6.string() }),
5410
- z6.object({ type: z6.literal("input"), workspaceId: z6.string(), data: z6.string() }),
5411
- z6.object({ type: z6.literal("putFile"), workspaceId: z6.string(), name: z6.string(), data: z6.string() }),
5506
+ z5.object({ type: z5.literal("listCommands") }),
5507
+ z5.object({ type: z5.literal("listLaunchers") }),
5508
+ z5.object({ type: z5.literal("forgetLauncher"), command: z5.string() }),
5509
+ z5.object({ type: z5.literal("listKeyterms") }),
5510
+ // Asked only of an agent whose authResult lists AGENT_FEATURES.dictationKeys.
5511
+ z5.object({ type: z5.literal("getDictationKeys") }),
5512
+ // Store (`key`) or remove (`null`) one of the machine's dictation keys, as `tarfa dictation-key`
5513
+ // does. Sent only to an agent whose authResult lists AGENT_FEATURES.setDictationKey; the key is
5514
+ // checked by the agent, which answers `dictationKeySet` with the same `id`, so a late answer to
5515
+ // an earlier save is never taken for this one's.
5516
+ z5.object({ type: z5.literal("setDictationKey"), id: z5.string(), provider: dictationProviderSchema, key: z5.string().nullable() }),
5517
+ z5.object({ type: z5.literal("readFile"), path: z5.string() }),
5518
+ z5.object({ type: z5.literal("attach"), workspaceId: z5.string(), cols: dim, rows: dim }),
5519
+ z5.object({ type: z5.literal("detach"), workspaceId: z5.string() }),
5520
+ z5.object({ type: z5.literal("input"), workspaceId: z5.string(), data: z5.string() }),
5521
+ z5.object({ type: z5.literal("putFile"), workspaceId: z5.string(), name: z5.string(), data: z5.string() }),
5412
5522
  // A dictation streamed as it is spoken: 16 kHz mono s16le PCM (base64) the agent plays into
5413
5523
  // the machine's virtual mic while holding space in the workspace, so Claude Code's /voice
5414
5524
  // transcribes it. `id` names the dictation, so a late reply to an older one is never taken for
5415
5525
  // the current one's. Answered with one `voiceDone`; an older agent answers `bad_message`.
5416
- z6.object({ type: z6.literal("voiceStart"), workspaceId: z6.string(), id: z6.string() }),
5417
- z6.object({ type: z6.literal("voiceChunk"), id: z6.string(), data: z6.string() }),
5418
- z6.object({ type: z6.literal("voiceEnd"), id: z6.string() }),
5526
+ z5.object({ type: z5.literal("voiceStart"), workspaceId: z5.string(), id: z5.string() }),
5527
+ z5.object({ type: z5.literal("voiceChunk"), id: z5.string(), data: z5.string() }),
5528
+ z5.object({ type: z5.literal("voiceEnd"), id: z5.string() }),
5419
5529
  // The text of Claude's last reply in a workspace, read aloud on the phone. Sent only to an agent
5420
5530
  // whose authResult names the `lastReply` feature.
5421
- z6.object({ type: z6.literal("lastReply"), workspaceId: z6.string() }),
5422
- z6.object({ type: z6.literal("resize"), workspaceId: z6.string(), cols: dim, rows: dim }),
5531
+ z5.object({ type: z5.literal("lastReply"), workspaceId: z5.string() }),
5532
+ z5.object({ type: z5.literal("resize"), workspaceId: z5.string(), cols: dim, rows: dim }),
5423
5533
  // `remember` is set only when the command came from the Custom field: the agent then adds it
5424
5534
  // to the machine's launcher list. Additive, so an older agent just ignores it.
5425
- z6.object({ type: z6.literal("create"), projectPath: z6.string(), command: z6.string(), remember: z6.boolean().optional() }),
5426
- z6.object({ type: z6.literal("close"), workspaceId: z6.string() }),
5427
- z6.object({ type: z6.literal("setStatus"), workspaceId: z6.string(), status: workspaceStatusSchema }),
5535
+ z5.object({ type: z5.literal("create"), projectPath: z5.string(), command: z5.string(), remember: z5.boolean().optional() }),
5536
+ z5.object({ type: z5.literal("close"), workspaceId: z5.string() }),
5537
+ z5.object({ type: z5.literal("setStatus"), workspaceId: z5.string(), status: workspaceStatusSchema }),
5428
5538
  // Manually pin/unpin a workspace to the top of its status group. Orthogonal to setStatus;
5429
5539
  // the agent's onChange broadcasts the refreshed workspace to every device.
5430
- z6.object({ type: z6.literal("setUrgent"), workspaceId: z6.string(), urgent: z6.boolean() }),
5540
+ z5.object({ type: z5.literal("setUrgent"), workspaceId: z5.string(), urgent: z5.boolean() }),
5431
5541
  // Start an agent-browser session named after the workspace. Old agents answer with their
5432
5542
  // generic unknown-message error; the PWA only offers this once the agent has proven
5433
5543
  // support by sending `browserSessions`.
5434
- z6.object({ type: z6.literal("startBrowser"), workspaceId: z6.string() }),
5544
+ z5.object({ type: z5.literal("startBrowser"), workspaceId: z5.string() }),
5435
5545
  // Liveness probe: the PWA pings periodically so it can detect a half-open socket that
5436
5546
  // never fires a `close` event (common on mobile) and force a reconnect. The agent answers
5437
5547
  // with `pong`.
5438
- z6.object({ type: z6.literal("ping") }),
5548
+ z5.object({ type: z5.literal("ping") }),
5439
5549
  ...sessionClientMessageSchemas
5440
5550
  ]);
5441
- var detachReasonSchema = z6.enum(["opened-elsewhere", "closed", "error"]);
5551
+ var detachReasonSchema = z5.enum(["opened-elsewhere", "closed", "error"]);
5442
5552
  var PROTOCOL_VERSION = 1;
5443
- var AGENT_FEATURES = { lastReply: "lastReply" };
5444
- var agentMessageSchema = z6.discriminatedUnion("type", [
5553
+ var AGENT_FEATURES = {
5554
+ lastReply: "lastReply",
5555
+ dictationKeys: "dictationKeys",
5556
+ setDictationKey: "setDictationKey",
5557
+ addresses: "addresses"
5558
+ };
5559
+ var agentMessageSchema = z5.discriminatedUnion("type", [
5445
5560
  // `credential` is what authenticated the session (`token` only from an agent from before token
5446
5561
  // enrolment was removed); `enrolled` says the device key is enrolled (written), so the phone
5447
5562
  // forgets its pairing secret. Both optional: an older agent sends neither.
5448
5563
  // `pushed`: the agent served the phone at once (its hello asked) and has sent the opening replies
5449
5564
  // (workspaces, keyterms) behind it, so the phone does not ask for them.
5450
- z6.object({
5451
- type: z6.literal("authResult"),
5452
- ok: z6.boolean(),
5453
- credential: z6.enum(["device", "pairing", "token"]).optional().catch(void 0),
5454
- enrolled: z6.boolean().optional().catch(void 0),
5455
- pushed: z6.literal(true).optional().catch(void 0),
5565
+ z5.object({
5566
+ type: z5.literal("authResult"),
5567
+ ok: z5.boolean(),
5568
+ credential: z5.enum(["device", "pairing", "token"]).optional().catch(void 0),
5569
+ enrolled: z5.boolean().optional().catch(void 0),
5570
+ pushed: z5.literal(true).optional().catch(void 0),
5456
5571
  // `features` names what this agent answers beyond the protocol, so the PWA offers a control
5457
5572
  // only to an agent that supports it (a worktree agent shares a release's version number).
5458
- agent: z6.object({ protocol: z6.number().int(), version: z6.string().optional(), features: z6.array(z6.string()).optional().catch(void 0) }).optional().catch(void 0)
5573
+ // `addresses`: where phones reach this agent now (its TARFA_PHONE_ADDRESSES), which a phone
5574
+ // adopts only once it has connected through each one.
5575
+ agent: z5.object({
5576
+ protocol: z5.number().int(),
5577
+ version: z5.string().optional(),
5578
+ features: z5.array(z5.string()).optional().catch(void 0),
5579
+ addresses: z5.array(z5.string()).optional().catch(void 0)
5580
+ }).optional().catch(void 0)
5459
5581
  }),
5460
5582
  // `claudeVoice`: whether this machine can play a Claude voice dictation (see voiceStart).
5461
5583
  // An older agent omits it, and the PWA then treats Claude voice as unavailable there.
5462
- z6.object({ type: z6.literal("workspaces"), workspaces: z6.array(workspaceSchema), claudeVoice: z6.boolean().optional() }),
5463
- z6.object({ type: z6.literal("workspaceUpdated"), workspace: workspaceSchema }),
5464
- z6.object({ type: z6.literal("recentPaths"), paths: z6.array(z6.string()) }),
5584
+ z5.object({ type: z5.literal("workspaces"), workspaces: z5.array(workspaceSchema), claudeVoice: z5.boolean().optional() }),
5585
+ z5.object({ type: z5.literal("workspaceUpdated"), workspace: workspaceSchema }),
5586
+ z5.object({ type: z5.literal("recentPaths"), paths: z5.array(z5.string()) }),
5465
5587
  // The agent's configured project roots — the folder picker's top-level browse points.
5466
5588
  // `roots` is additive: older agents omit it, so it defaults to [] rather than letting the
5467
5589
  // strict client parser drop the whole message during a version-skewed rollout.
5468
- z6.object({ type: z6.literal("roots"), roots: z6.array(z6.string()).default([]) }),
5590
+ z5.object({ type: z5.literal("roots"), roots: z5.array(z5.string()).default([]) }),
5469
5591
  // What the Create Workspace command list offers on this machine: the known CLIs found
5470
5592
  // installed (`claude`, `codex`) and the custom commands launched here before. Additive like
5471
5593
  // `roots`: an older agent never sends it and the PWA keeps its fixed list.
5472
- z6.object({ type: z6.literal("launchers"), detected: z6.array(z6.string()).default([]), custom: z6.array(z6.string()).default([]) }),
5594
+ z5.object({ type: z5.literal("launchers"), detected: z5.array(z5.string()).default([]), custom: z5.array(z5.string()).default([]) }),
5473
5595
  // The agent machine's dictation vocabulary (~/.tarfa/keyterms.txt), passed to ElevenLabs as
5474
5596
  // keyterms so jargon transcribes correctly. Additive like `roots`: older agents omit it,
5475
5597
  // so it defaults to [] instead of letting the strict client parser drop the whole message.
5476
- z6.object({ type: z6.literal("keyterms"), keyterms: z6.array(z6.string()).default([]) }),
5598
+ z5.object({ type: z5.literal("keyterms"), keyterms: z5.array(z5.string()).default([]) }),
5599
+ // The dictation provider keys the agent's operator stored (~/.tarfa/dictation-keys.json), sent
5600
+ // only in reply to getDictationKeys. A key the agent does not hold is absent.
5601
+ z5.object({
5602
+ type: z5.literal("dictationKeys"),
5603
+ elevenlabs: z5.string().optional().catch(void 0),
5604
+ openrouter: z5.string().optional().catch(void 0)
5605
+ }),
5606
+ // The answer to setDictationKey. `error` is the agent's fixed wording of a refusal and never
5607
+ // holds the key.
5608
+ z5.object({ type: z5.literal("dictationKeySet"), id: z5.string(), provider: dictationProviderSchema, ok: z5.boolean(), error: z5.string().optional() }),
5477
5609
  // `files` is additive: older agents that predate it omit the field, so it defaults to []
5478
5610
  // (a strictly-required field would make the strict client parser silently drop their
5479
5611
  // dirEntries during a version-skewed rollout, hanging the browser on "Loading…").
5480
- z6.object({ type: z6.literal("dirEntries"), path: z6.string(), subdirs: z6.array(z6.string()), files: z6.array(z6.string()).default([]) }),
5612
+ z5.object({ type: z5.literal("dirEntries"), path: z5.string(), subdirs: z5.array(z5.string()), files: z5.array(z5.string()).default([]) }),
5481
5613
  // `truncated`/`binary` are additive: older agents omit them, so they default to false (a
5482
5614
  // strictly-required field would make the strict client parser drop the whole message
5483
5615
  // during a version-skewed rollout).
5484
- z6.object({
5485
- type: z6.literal("fileContents"),
5486
- path: z6.string(),
5487
- data: z6.string(),
5488
- truncated: z6.boolean().default(false),
5489
- binary: z6.boolean().default(false)
5616
+ z5.object({
5617
+ type: z5.literal("fileContents"),
5618
+ path: z5.string(),
5619
+ data: z5.string(),
5620
+ truncated: z5.boolean().default(false),
5621
+ binary: z5.boolean().default(false)
5490
5622
  }),
5491
- z6.object({ type: z6.literal("attached"), workspaceId: z6.string() }),
5492
- z6.object({ type: z6.literal("output"), workspaceId: z6.string(), data: z6.string() }),
5493
- z6.object({ type: z6.literal("fileStored"), workspaceId: z6.string(), path: z6.string() }),
5623
+ z5.object({ type: z5.literal("attached"), workspaceId: z5.string() }),
5624
+ z5.object({ type: z5.literal("output"), workspaceId: z5.string(), data: z5.string() }),
5625
+ z5.object({ type: z5.literal("fileStored"), workspaceId: z5.string(), path: z5.string() }),
5494
5626
  // `text` is null when the agent holds no reply for that workspace (none since it started, or not Claude).
5495
- z6.object({ type: z6.literal("lastReply"), workspaceId: z6.string(), text: z6.string().nullable() }),
5496
- z6.object({ type: z6.literal("voiceDone"), id: z6.string(), ok: z6.boolean(), error: z6.string().optional() }),
5497
- z6.object({ type: z6.literal("detached"), workspaceId: z6.string(), reason: detachReasonSchema }),
5498
- z6.object({ type: z6.literal("closed"), workspaceId: z6.string() }),
5627
+ z5.object({ type: z5.literal("lastReply"), workspaceId: z5.string(), text: z5.string().nullable() }),
5628
+ z5.object({ type: z5.literal("voiceDone"), id: z5.string(), ok: z5.boolean(), error: z5.string().optional() }),
5629
+ z5.object({ type: z5.literal("detached"), workspaceId: z5.string(), reason: detachReasonSchema }),
5630
+ z5.object({ type: z5.literal("closed"), workspaceId: z5.string() }),
5499
5631
  // A phone was just enrolled on this machine (a pairing code), told to every other
5500
5632
  // phone connected to it: a device nobody meant to add must not go unnoticed. An older PWA drops
5501
5633
  // the unknown type.
5502
- z6.object({ type: z6.literal("deviceEnrolled"), id: z6.string(), name: z6.string(), at: z6.string() }),
5503
- z6.object({ type: z6.literal("error"), code: z6.string(), message: z6.string() }),
5634
+ z5.object({ type: z5.literal("deviceEnrolled"), id: z5.string(), name: z5.string(), at: z5.string() }),
5635
+ z5.object({ type: z5.literal("error"), code: z5.string(), message: z5.string() }),
5504
5636
  // Live agent-browser sessions, sent after every `workspaces` reply. `sessions` defaults
5505
5637
  // to [] so a skewed sender omitting it can't make the strict client parser drop the
5506
5638
  // whole message.
5507
- z6.object({ type: z6.literal("browserSessions"), sessions: z6.array(browserSessionSchema).default([]) }),
5639
+ z5.object({ type: z5.literal("browserSessions"), sessions: z5.array(browserSessionSchema).default([]) }),
5508
5640
  // The agent machine's current CPU/memory load (see machineStatsSchema). Pushed on a
5509
5641
  // timer while at least one client is connected; an agent that predates it simply never
5510
5642
  // sends one and the PWA shows no load strip for that machine.
5511
- z6.object({ type: z6.literal("stats"), stats: machineStatsSchema }),
5643
+ z5.object({ type: z5.literal("stats"), stats: machineStatsSchema }),
5512
5644
  // Reply to the client's liveness `ping` (see clientMessageSchema).
5513
- z6.object({ type: z6.literal("pong") }),
5645
+ z5.object({ type: z5.literal("pong") }),
5514
5646
  ...sessionAgentMessageSchemas
5515
5647
  ]);
5516
5648
  function parseClientMessage(raw) {
@@ -5518,64 +5650,64 @@ function parseClientMessage(raw) {
5518
5650
  }
5519
5651
 
5520
5652
  // ../contracts/src/protocol/browser-messages.ts
5521
- import { z as z7 } from "zod";
5522
- var modifiers = z7.number().int().optional();
5523
- var browserClientMessageSchema = z7.discriminatedUnion("type", [
5524
- z7.object({ type: z7.literal("auth"), token: z7.string() }),
5653
+ import { z as z6 } from "zod";
5654
+ var modifiers = z6.number().int().optional();
5655
+ var browserClientMessageSchema = z6.discriminatedUnion("type", [
5656
+ z6.object({ type: z6.literal("auth"), token: z6.string() }),
5525
5657
  // Liveness probe, same pattern as the main terminal socket.
5526
- z7.object({ type: z7.literal("ping") }),
5527
- z7.object({
5528
- type: z7.literal("input_mouse"),
5529
- eventType: z7.string(),
5530
- x: z7.number(),
5531
- y: z7.number(),
5532
- button: z7.string().optional(),
5533
- clickCount: z7.number().int().optional(),
5534
- deltaX: z7.number().optional(),
5535
- deltaY: z7.number().optional(),
5658
+ z6.object({ type: z6.literal("ping") }),
5659
+ z6.object({
5660
+ type: z6.literal("input_mouse"),
5661
+ eventType: z6.string(),
5662
+ x: z6.number(),
5663
+ y: z6.number(),
5664
+ button: z6.string().optional(),
5665
+ clickCount: z6.number().int().optional(),
5666
+ deltaX: z6.number().optional(),
5667
+ deltaY: z6.number().optional(),
5536
5668
  modifiers
5537
5669
  }),
5538
- z7.object({
5539
- type: z7.literal("input_keyboard"),
5540
- eventType: z7.string(),
5541
- key: z7.string().optional(),
5542
- code: z7.string().optional(),
5543
- text: z7.string().optional(),
5544
- windowsVirtualKeyCode: z7.number().int().optional(),
5670
+ z6.object({
5671
+ type: z6.literal("input_keyboard"),
5672
+ eventType: z6.string(),
5673
+ key: z6.string().optional(),
5674
+ code: z6.string().optional(),
5675
+ text: z6.string().optional(),
5676
+ windowsVirtualKeyCode: z6.number().int().optional(),
5545
5677
  modifiers
5546
5678
  }),
5547
- z7.object({
5548
- type: z7.literal("input_touch"),
5549
- eventType: z7.string(),
5550
- touchPoints: z7.array(z7.object({ x: z7.number(), y: z7.number() }).passthrough()),
5679
+ z6.object({
5680
+ type: z6.literal("input_touch"),
5681
+ eventType: z6.string(),
5682
+ touchPoints: z6.array(z6.object({ x: z6.number(), y: z6.number() }).passthrough()),
5551
5683
  modifiers
5552
5684
  }),
5553
5685
  // Handled by the agent (CLI navigation/session control), never forwarded upstream.
5554
- z7.object({ type: z7.literal("navigate"), url: z7.string() }),
5555
- z7.object({ type: z7.literal("back") }),
5556
- z7.object({ type: z7.literal("forward") }),
5557
- z7.object({ type: z7.literal("closeSession") }),
5686
+ z6.object({ type: z6.literal("navigate"), url: z6.string() }),
5687
+ z6.object({ type: z6.literal("back") }),
5688
+ z6.object({ type: z6.literal("forward") }),
5689
+ z6.object({ type: z6.literal("closeSession") }),
5558
5690
  // Resize the remote browser viewport to the attached viewer's canvas (CSS pixels).
5559
- z7.object({
5560
- type: z7.literal("setViewport"),
5561
- width: z7.number().int().positive(),
5562
- height: z7.number().int().positive()
5691
+ z6.object({
5692
+ type: z6.literal("setViewport"),
5693
+ width: z6.number().int().positive(),
5694
+ height: z6.number().int().positive()
5563
5695
  })
5564
5696
  ]);
5565
- var browserEndedReasonSchema = z7.enum(["closed", "not-found", "error", "opened-elsewhere"]);
5566
- var browserAgentMessageSchema = z7.discriminatedUnion("type", [
5567
- z7.object({ type: z7.literal("authResult"), ok: z7.boolean() }),
5568
- z7.object({ type: z7.literal("pong") }),
5569
- z7.object({ type: z7.literal("ended"), reason: browserEndedReasonSchema }),
5570
- z7.object({
5571
- type: z7.literal("frame"),
5572
- data: z7.string(),
5697
+ var browserEndedReasonSchema = z6.enum(["closed", "not-found", "error", "opened-elsewhere"]);
5698
+ var browserAgentMessageSchema = z6.discriminatedUnion("type", [
5699
+ z6.object({ type: z6.literal("authResult"), ok: z6.boolean() }),
5700
+ z6.object({ type: z6.literal("pong") }),
5701
+ z6.object({ type: z6.literal("ended"), reason: browserEndedReasonSchema }),
5702
+ z6.object({
5703
+ type: z6.literal("frame"),
5704
+ data: z6.string(),
5573
5705
  // base64 JPEG
5574
- metadata: z7.object({ deviceWidth: z7.number(), deviceHeight: z7.number() }).passthrough()
5706
+ metadata: z6.object({ deviceWidth: z6.number(), deviceHeight: z6.number() }).passthrough()
5575
5707
  }).passthrough(),
5576
- z7.object({ type: z7.literal("tabs") }).passthrough(),
5577
- z7.object({ type: z7.literal("url"), url: z7.string() }).passthrough(),
5578
- z7.object({ type: z7.literal("status") }).passthrough()
5708
+ z6.object({ type: z6.literal("tabs") }).passthrough(),
5709
+ z6.object({ type: z6.literal("url"), url: z6.string() }).passthrough(),
5710
+ z6.object({ type: z6.literal("status") }).passthrough()
5579
5711
  ]);
5580
5712
  function parseBrowserClientMessage(raw) {
5581
5713
  return browserClientMessageSchema.parse(raw);
@@ -5584,7 +5716,267 @@ function parseBrowserAgentMessage(raw) {
5584
5716
  return browserAgentMessageSchema.parse(raw);
5585
5717
  }
5586
5718
 
5719
+ // src/application/dictation-keys.ts
5720
+ var DICTATION_PROVIDERS = ["elevenlabs", "openrouter"];
5721
+ var MAX_KEY_LENGTH = 512;
5722
+ function providerOf(name) {
5723
+ return DICTATION_PROVIDERS.includes(name) ? name : null;
5724
+ }
5725
+ function checkKey(raw) {
5726
+ const key2 = raw.trim();
5727
+ if (key2 === "") return { ok: false, error: "no key given" };
5728
+ if (/\s/.test(key2)) return { ok: false, error: "a key holds no whitespace" };
5729
+ if (key2.length > MAX_KEY_LENGTH) return { ok: false, error: `a key is at most ${MAX_KEY_LENGTH} characters` };
5730
+ return { ok: true, key: key2 };
5731
+ }
5732
+ var SHAPE = {
5733
+ elevenlabs: "an ElevenLabs key starts with sk_ followed by at least 32 letters and digits",
5734
+ openrouter: "an OpenRouter key starts with sk-or-v1- followed by at least 32 letters and digits"
5735
+ };
5736
+ var OTHER = { elevenlabs: "OpenRouter", openrouter: "ElevenLabs" };
5737
+ function keyFormatError(provider, key2) {
5738
+ const problem = dictationKeyProblem(provider, key2);
5739
+ if (problem === null) return null;
5740
+ return problem === "other-provider" ? `this looks like an ${OTHER[provider]} key; ${SHAPE[provider]}` : SHAPE[provider];
5741
+ }
5742
+ function parseDictationKeys(raw) {
5743
+ let value;
5744
+ try {
5745
+ value = JSON.parse(raw);
5746
+ } catch {
5747
+ return "malformed";
5748
+ }
5749
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "malformed";
5750
+ const keys = {};
5751
+ for (const provider of DICTATION_PROVIDERS) {
5752
+ const candidate = value[provider];
5753
+ if (typeof candidate !== "string") continue;
5754
+ const checked = checkKey(candidate);
5755
+ if (checked.ok) keys[provider] = checked.key;
5756
+ }
5757
+ return keys;
5758
+ }
5759
+ function withKey(keys, provider, key2) {
5760
+ return { ...keys, [provider]: key2 };
5761
+ }
5762
+ function withoutKey(keys, provider) {
5763
+ const { [provider]: _removed, ...rest } = keys;
5764
+ return rest;
5765
+ }
5766
+ async function changeDictationKey(store, provider, raw) {
5767
+ let key2 = null;
5768
+ if (raw !== null) {
5769
+ const checked = checkKey(raw);
5770
+ if (!checked.ok) return checked;
5771
+ const formatError = keyFormatError(provider, checked.key);
5772
+ if (formatError) return { ok: false, error: formatError };
5773
+ key2 = checked.key;
5774
+ }
5775
+ const result = await store.update((keys) => key2 === null ? withoutKey(keys, provider) : withKey(keys, provider, key2));
5776
+ return "problem" in result ? { ok: false, error: result.problem } : { ok: true };
5777
+ }
5778
+
5779
+ // src/infrastructure/file-dictation-keys-store.ts
5780
+ import { readFile as readFile6, stat as stat3 } from "node:fs/promises";
5781
+
5782
+ // src/application/private-files.ts
5783
+ function exposedFiles(files) {
5784
+ 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}\``);
5785
+ }
5786
+
5787
+ // src/infrastructure/file-dictation-keys-store.ts
5788
+ var STALE_LOCK_MS = 1e4;
5789
+ var FileDictationKeysStore = class {
5790
+ constructor(path, report = () => {
5791
+ }) {
5792
+ this.path = path;
5793
+ this.report = report;
5794
+ }
5795
+ path;
5796
+ report;
5797
+ async load() {
5798
+ let mode;
5799
+ try {
5800
+ mode = (await stat3(this.path)).mode;
5801
+ } catch (err) {
5802
+ if (err.code === "ENOENT") return { keys: {} };
5803
+ return { problem: `${this.path} cannot be read (${err.code ?? "error"})` };
5804
+ }
5805
+ const exposed = exposedFiles([{ path: this.path, mode }]);
5806
+ if (exposed.length > 0) return { problem: exposed[0] };
5807
+ let raw;
5808
+ try {
5809
+ raw = await readFile6(this.path, "utf8");
5810
+ } catch (err) {
5811
+ return { problem: `${this.path} cannot be read (${err.code ?? "error"})` };
5812
+ }
5813
+ const keys = parseDictationKeys(raw);
5814
+ return keys === "malformed" ? { problem: `${this.path} is malformed` } : { keys };
5815
+ }
5816
+ async read() {
5817
+ const loaded = await this.load();
5818
+ if ("problem" in loaded) {
5819
+ this.report(loaded.problem);
5820
+ return {};
5821
+ }
5822
+ return loaded.keys;
5823
+ }
5824
+ async update(change) {
5825
+ return withFileLock(this.path, STALE_LOCK_MS, async () => {
5826
+ const loaded = await this.load();
5827
+ if ("problem" in loaded) return { problem: loaded.problem };
5828
+ await atomicWrite(this.path, `${JSON.stringify(change(loaded.keys), null, 2)}
5829
+ `, 384);
5830
+ return { ok: true };
5831
+ });
5832
+ }
5833
+ };
5834
+
5835
+ // src/application/pairing.ts
5836
+ var PAIRING_TTL_MS = 20 * 6e4;
5837
+ var PAIRING_RETAIN_MS = 24 * 60 * 6e4;
5838
+ var retained = (list, now) => list.filter((p) => Date.parse(p.expiresAt) + PAIRING_RETAIN_MS > now.getTime());
5839
+ function stateOf(p, now) {
5840
+ if (p.usedAt) return "used";
5841
+ return now.getTime() < Date.parse(p.expiresAt) ? "live" : "expired";
5842
+ }
5843
+ function addPairing(list, secret, now, ttlMs = PAIRING_TTL_MS) {
5844
+ return [...retained(list, now), { secret, expiresAt: new Date(now.getTime() + ttlMs).toISOString() }];
5845
+ }
5846
+ function redeemPairing(list, secret, now, device2) {
5847
+ const kept = retained(list, now);
5848
+ const match = secret ? kept.find((p) => constantTimeEqual(p.secret, secret)) : void 0;
5849
+ if (!match) return { list: kept, outcome: "unknown" };
5850
+ if (match.usedAt && match.usedBy === device2) return { list: kept, outcome: "mine" };
5851
+ const state = stateOf(match, now);
5852
+ if (state !== "live") return { list: kept, outcome: state };
5853
+ const at = now.toISOString();
5854
+ const spent = { secret: match.secret, expiresAt: at, usedAt: at, usedBy: device2 };
5855
+ return { list: kept.map((p) => p === match ? spent : p), outcome: "redeemed" };
5856
+ }
5857
+
5858
+ // src/application/pairing-link.ts
5859
+ var GENERIC_MACHINE_IDS = /* @__PURE__ */ new Set(["", "local", "localhost"]);
5860
+ function pairingName(machineId, hostname) {
5861
+ return GENERIC_MACHINE_IDS.has(machineId) ? hostname.split(".")[0] : machineId;
5862
+ }
5863
+ function announcedAddresses(phoneAddresses2) {
5864
+ const valid2 = phoneAddresses2.length > 0 && phoneAddresses2.length <= MAX_ADDRESSES && phoneAddresses2.every(isMachineUrl);
5865
+ return valid2 ? phoneAddresses2 : void 0;
5866
+ }
5867
+ async function mintPairing(deps, input) {
5868
+ if (deps.defaultPath !== void 0 && !isPairingPath(deps.defaultPath)) {
5869
+ throw new Error(`TARFA_DEFAULT_PATH must be an absolute path of at most 128 characters (got ${deps.defaultPath})`);
5870
+ }
5871
+ const secret = deps.newSecret();
5872
+ await deps.pairings.update((list) => addPairing(list, secret, deps.now(), deps.ttlMs));
5873
+ return {
5874
+ urls: input.phoneUrls,
5875
+ agentKey: deps.keys.x25519.publicKey,
5876
+ secret,
5877
+ name: deps.machineId,
5878
+ ...deps.defaultPath !== void 0 ? { defaultPath: deps.defaultPath } : {}
5879
+ };
5880
+ }
5881
+
5882
+ // src/infrastructure/file-pairing-store.ts
5883
+ import { readFile as readFile7 } from "node:fs/promises";
5884
+ import { z as z7 } from "zod";
5885
+ var schema = z7.array(z7.object({ secret: z7.string().regex(/^[A-Za-z0-9_-]{43}$/), expiresAt: z7.string(), usedAt: z7.string().optional(), usedBy: z7.string().optional() }));
5886
+ var FilePairingStore = class {
5887
+ constructor(filePath, staleLockMs = 1e4) {
5888
+ this.filePath = filePath;
5889
+ this.staleLockMs = staleLockMs;
5890
+ }
5891
+ filePath;
5892
+ staleLockMs;
5893
+ async read() {
5894
+ try {
5895
+ const parsed = schema.safeParse(JSON.parse(await readFile7(this.filePath, "utf8")));
5896
+ return parsed.success ? parsed.data : [];
5897
+ } catch {
5898
+ return [];
5899
+ }
5900
+ }
5901
+ async update(change) {
5902
+ return withFileLock(this.filePath, this.staleLockMs, async () => {
5903
+ const current = await this.read();
5904
+ const next = change(current);
5905
+ if (JSON.stringify(next) !== JSON.stringify(current)) await writeFileAtomic(this.filePath, `${JSON.stringify(next, null, 2)}
5906
+ `);
5907
+ return next;
5908
+ });
5909
+ }
5910
+ };
5911
+
5912
+ // src/infrastructure/agent-env-file.ts
5913
+ import { execFile as execFile3 } from "node:child_process";
5914
+ import { existsSync as existsSync3 } from "node:fs";
5915
+ import { chmod as chmod2, copyFile, mkdir as mkdir7, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
5916
+ import { dirname as dirname8, resolve as resolve5 } from "node:path";
5917
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
5918
+ import { promisify as promisify3 } from "node:util";
5919
+
5920
+ // src/application/agent-env-location.ts
5921
+ import { join as join6 } from "node:path";
5922
+ function resolveAgentEnvPath(input) {
5923
+ if (input.env.TARFA_AGENT_ENV) return input.env.TARFA_AGENT_ENV;
5924
+ const stateFile = join6(input.home, ".tarfa", "agent.env");
5925
+ if (input.exists(stateFile) || input.checkout === null) return stateFile;
5926
+ return join6(input.checkout, "deploy", "agent.env");
5927
+ }
5928
+
5929
+ // src/infrastructure/agent-env-file.ts
5930
+ var exec3 = promisify3(execFile3);
5931
+ function withoutGitVars(env) {
5932
+ return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith("GIT_")));
5933
+ }
5934
+ async function mainCheckout(kind2 = installKind()) {
5935
+ if (kind2 === "package") return null;
5936
+ const here = dirname8(fileURLToPath2(import.meta.url));
5937
+ try {
5938
+ const { stdout } = await exec3("git", ["-C", here, "rev-parse", "--path-format=absolute", "--git-common-dir"], { env: withoutGitVars(process.env) });
5939
+ return dirname8(stdout.trim());
5940
+ } catch {
5941
+ return resolve5(here, "..", "..", "..", "..");
5942
+ }
5943
+ }
5944
+ async function locateAgentEnv(env, home, kind2 = installKind()) {
5945
+ return resolveAgentEnvPath({ env, home, checkout: await mainCheckout(kind2), exists: existsSync3 });
5946
+ }
5947
+ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
5948
+ return {
5949
+ path,
5950
+ read: async () => {
5951
+ try {
5952
+ return await readFile8(path, "utf8");
5953
+ } catch (err) {
5954
+ if (err.code === "ENOENT") {
5955
+ throw new Error(`no agent config at ${path}: set the agent up first (docs/DEPLOY.md), or set TARFA_AGENT_ENV`);
5956
+ }
5957
+ throw err;
5958
+ }
5959
+ },
5960
+ write: async (text) => {
5961
+ let backup = `${path}.bak-${now().toISOString().replace(/[:.]/g, "-")}`;
5962
+ try {
5963
+ await copyFile(path, backup);
5964
+ await chmod2(backup, 384);
5965
+ } catch (err) {
5966
+ if (err.code !== "ENOENT") throw err;
5967
+ backup = null;
5968
+ }
5969
+ await mkdir7(dirname8(path), { recursive: true, mode: 448 });
5970
+ await writeFile5(path, text, { mode: 384 });
5971
+ await chmod2(path, 384);
5972
+ return backup;
5973
+ }
5974
+ };
5975
+ }
5976
+
5587
5977
  // src/infrastructure/file-status-store.ts
5978
+ import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
5979
+ import { dirname as dirname9 } from "node:path";
5588
5980
  var FileStatusStore = class {
5589
5981
  constructor(filePath, now = Date.now) {
5590
5982
  this.filePath = filePath;
@@ -5697,7 +6089,7 @@ var FileStatusStore = class {
5697
6089
  }
5698
6090
  }
5699
6091
  persist() {
5700
- mkdirSync(dirname8(this.filePath), { recursive: true });
6092
+ mkdirSync(dirname9(this.filePath), { recursive: true });
5701
6093
  writeFileSync(this.filePath, JSON.stringify(Object.fromEntries(this.statuses), null, 2), "utf8");
5702
6094
  }
5703
6095
  };
@@ -5726,8 +6118,8 @@ function parseEntry(value) {
5726
6118
  }
5727
6119
 
5728
6120
  // src/infrastructure/file-parked-store.ts
5729
- import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir7 } from "node:fs/promises";
5730
- import { dirname as dirname9 } from "node:path";
6121
+ import { readFile as readFile9, writeFile as writeFile6, mkdir as mkdir8 } from "node:fs/promises";
6122
+ import { dirname as dirname10 } from "node:path";
5731
6123
  var FileParkedStore = class {
5732
6124
  constructor(filePath) {
5733
6125
  this.filePath = filePath;
@@ -5736,7 +6128,7 @@ var FileParkedStore = class {
5736
6128
  async list() {
5737
6129
  let raw;
5738
6130
  try {
5739
- raw = await readFile8(this.filePath, "utf8");
6131
+ raw = await readFile9(this.filePath, "utf8");
5740
6132
  } catch {
5741
6133
  return [];
5742
6134
  }
@@ -5761,8 +6153,8 @@ var FileParkedStore = class {
5761
6153
  await this.persist(rest);
5762
6154
  }
5763
6155
  async persist(entries) {
5764
- await mkdir7(dirname9(this.filePath), { recursive: true });
5765
- await writeFile5(this.filePath, JSON.stringify(entries, null, 2), "utf8");
6156
+ await mkdir8(dirname10(this.filePath), { recursive: true });
6157
+ await writeFile6(this.filePath, JSON.stringify(entries, null, 2), "utf8");
5766
6158
  }
5767
6159
  };
5768
6160
  function parseEntry2(value) {
@@ -5796,10 +6188,10 @@ function parseEntry2(value) {
5796
6188
  }
5797
6189
 
5798
6190
  // src/infrastructure/fs-file-writer.ts
5799
- import { mkdir as mkdir8, writeFile as writeFile6, access } from "node:fs/promises";
6191
+ import { mkdir as mkdir9, writeFile as writeFile7, access } from "node:fs/promises";
5800
6192
  var FsFileWriter = class {
5801
6193
  async ensureDir(dir) {
5802
- await mkdir8(dir, { recursive: true });
6194
+ await mkdir9(dir, { recursive: true });
5803
6195
  }
5804
6196
  async exists(path) {
5805
6197
  try {
@@ -5810,7 +6202,7 @@ var FsFileWriter = class {
5810
6202
  }
5811
6203
  }
5812
6204
  async writeFile(path, bytes) {
5813
- await writeFile6(path, bytes);
6205
+ await writeFile7(path, bytes);
5814
6206
  }
5815
6207
  };
5816
6208
 
@@ -6111,11 +6503,10 @@ export {
6111
6503
  parseDeviceHello,
6112
6504
  deviceFingerprint,
6113
6505
  MAX_ADDRESSES,
6114
- isPairingPath,
6115
6506
  generatePairingSecret,
6116
6507
  pairingLink,
6117
- parsePairingLink,
6118
6508
  isMachineUrl,
6509
+ pairingCode,
6119
6510
  REFUSAL,
6120
6511
  IN_BAND_REFUSAL,
6121
6512
  DEVICE_REFUSED,
@@ -6158,7 +6549,9 @@ export {
6158
6549
  getRecentPaths,
6159
6550
  pathWithinRoots,
6160
6551
  browseDir,
6552
+ atomicWrite,
6161
6553
  loadOrCreateAgentKeys,
6554
+ readAgentPublicKey,
6162
6555
  enrolmentMode,
6163
6556
  withEnrolmentMode,
6164
6557
  standingOf,
@@ -6166,13 +6559,6 @@ export {
6166
6559
  revokeDevice,
6167
6560
  forgetDevice,
6168
6561
  FileDeviceStore,
6169
- PAIRING_TTL_MS,
6170
- addPairing,
6171
- redeemPairing,
6172
- FilePairingStore,
6173
- mainCheckout,
6174
- locateAgentEnv,
6175
- agentEnvFile,
6176
6562
  isWorkspaceStatus,
6177
6563
  runsClaude,
6178
6564
  imageMediaType,
@@ -6183,6 +6569,21 @@ export {
6183
6569
  parseClientMessage,
6184
6570
  parseBrowserClientMessage,
6185
6571
  parseBrowserAgentMessage,
6572
+ workspaceLink,
6573
+ DICTATION_PROVIDERS,
6574
+ providerOf,
6575
+ changeDictationKey,
6576
+ exposedFiles,
6577
+ FileDictationKeysStore,
6578
+ PAIRING_TTL_MS,
6579
+ redeemPairing,
6580
+ pairingName,
6581
+ announcedAddresses,
6582
+ mintPairing,
6583
+ FilePairingStore,
6584
+ mainCheckout,
6585
+ locateAgentEnv,
6586
+ agentEnvFile,
6186
6587
  FileStatusStore,
6187
6588
  FileParkedStore,
6188
6589
  FsFileWriter,