@tarfa/cli 0.1.5 → 0.1.6

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/README.md CHANGED
@@ -17,13 +17,14 @@ tarfa setup --machine-id <name> --project-roots ~/workspace --tailscale serve --
17
17
 
18
18
  `tarfa setup` writes `~/.tarfa/agent.env`, the agent key, the service (a systemd user unit or a
19
19
  launchd agent) and the Claude Code hooks, publishes the phone port with Tailscale, and prints a
20
- pairing link and QR code. Open it on the phone, inside the installed app. `tarfa setup --help`
20
+ pairing code and its QR code. In the installed app on the phone, Settings → Add machine: scan it,
21
+ or paste the code. `tarfa setup --help`
21
22
  lists every flag.
22
23
 
23
24
  ## Day to day
24
25
 
25
26
  ```sh
26
- tarfa pair # a one-time pairing link for another phone
27
+ tarfa pair # a one-time pairing code for another phone (--link: a link, for an older app)
27
28
  tarfa devices # paired phones; revoke or forget one
28
29
  tarfa update # install the newest release and restart the agent; workspaces survive
29
30
  tarfa ls # this machine's workspaces
@@ -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) {
@@ -5021,8 +5214,8 @@ function forgetDevice(roster, id2) {
5021
5214
  }
5022
5215
 
5023
5216
  // 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";
5217
+ import { mkdir as mkdir6, readFile as readFile5 } from "node:fs/promises";
5218
+ import { dirname as dirname7 } from "node:path";
5026
5219
  import { z as z2 } from "zod";
5027
5220
  var key = z2.string().regex(/^[A-Za-z0-9_-]{43}$/);
5028
5221
  var device = z2.object({ id: z2.string(), x25519: key, ed25519: key, name: z2.string(), enrolledAt: z2.string() });
@@ -5069,7 +5262,7 @@ var FileDeviceStore = class {
5069
5262
  }
5070
5263
  if (parsed?.tokenEnrolment === false) return false;
5071
5264
  }
5072
- await mkdir5(dirname6(this.filePath), { recursive: true, mode: 448 });
5265
+ await mkdir6(dirname7(this.filePath), { recursive: true, mode: 448 });
5073
5266
  await this.update((roster) => roster);
5074
5267
  return true;
5075
5268
  }
@@ -5083,8 +5276,112 @@ var FileDeviceStore = class {
5083
5276
  }
5084
5277
  };
5085
5278
 
5279
+ // src/application/dictation-keys.ts
5280
+ var DICTATION_PROVIDERS = ["elevenlabs", "openrouter"];
5281
+ var MAX_KEY_LENGTH = 512;
5282
+ function providerOf(name) {
5283
+ return DICTATION_PROVIDERS.includes(name) ? name : null;
5284
+ }
5285
+ function checkKey(raw) {
5286
+ const key2 = raw.trim();
5287
+ if (key2 === "") return { ok: false, error: "no key given" };
5288
+ if (/\s/.test(key2)) return { ok: false, error: "a key holds no whitespace" };
5289
+ if (key2.length > MAX_KEY_LENGTH) return { ok: false, error: `a key is at most ${MAX_KEY_LENGTH} characters` };
5290
+ return { ok: true, key: key2 };
5291
+ }
5292
+ function parseDictationKeys(raw) {
5293
+ let value;
5294
+ try {
5295
+ value = JSON.parse(raw);
5296
+ } catch {
5297
+ return "malformed";
5298
+ }
5299
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return "malformed";
5300
+ const keys = {};
5301
+ for (const provider of DICTATION_PROVIDERS) {
5302
+ const candidate = value[provider];
5303
+ if (typeof candidate !== "string") continue;
5304
+ const checked = checkKey(candidate);
5305
+ if (checked.ok) keys[provider] = checked.key;
5306
+ }
5307
+ return keys;
5308
+ }
5309
+ function withKey(keys, provider, key2) {
5310
+ return { ...keys, [provider]: key2 };
5311
+ }
5312
+ function withoutKey(keys, provider) {
5313
+ const { [provider]: _removed, ...rest } = keys;
5314
+ return rest;
5315
+ }
5316
+ async function changeDictationKey(store, provider, raw) {
5317
+ let key2 = null;
5318
+ if (raw !== null) {
5319
+ const checked = checkKey(raw);
5320
+ if (!checked.ok) return checked;
5321
+ key2 = checked.key;
5322
+ }
5323
+ const result = await store.update((keys) => key2 === null ? withoutKey(keys, provider) : withKey(keys, provider, key2));
5324
+ return "problem" in result ? { ok: false, error: result.problem } : { ok: true };
5325
+ }
5326
+
5327
+ // src/infrastructure/file-dictation-keys-store.ts
5328
+ import { readFile as readFile6, stat as stat3 } from "node:fs/promises";
5329
+
5330
+ // src/application/private-files.ts
5331
+ function exposedFiles(files) {
5332
+ 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}\``);
5333
+ }
5334
+
5335
+ // src/infrastructure/file-dictation-keys-store.ts
5336
+ var STALE_LOCK_MS = 1e4;
5337
+ var FileDictationKeysStore = class {
5338
+ constructor(path, report = () => {
5339
+ }) {
5340
+ this.path = path;
5341
+ this.report = report;
5342
+ }
5343
+ path;
5344
+ report;
5345
+ async load() {
5346
+ let mode;
5347
+ try {
5348
+ mode = (await stat3(this.path)).mode;
5349
+ } catch (err) {
5350
+ if (err.code === "ENOENT") return { keys: {} };
5351
+ return { problem: `${this.path} cannot be read (${err.code ?? "error"})` };
5352
+ }
5353
+ const exposed = exposedFiles([{ path: this.path, mode }]);
5354
+ if (exposed.length > 0) return { problem: exposed[0] };
5355
+ let raw;
5356
+ try {
5357
+ raw = await readFile6(this.path, "utf8");
5358
+ } catch (err) {
5359
+ return { problem: `${this.path} cannot be read (${err.code ?? "error"})` };
5360
+ }
5361
+ const keys = parseDictationKeys(raw);
5362
+ return keys === "malformed" ? { problem: `${this.path} is malformed` } : { keys };
5363
+ }
5364
+ async read() {
5365
+ const loaded = await this.load();
5366
+ if ("problem" in loaded) {
5367
+ this.report(loaded.problem);
5368
+ return {};
5369
+ }
5370
+ return loaded.keys;
5371
+ }
5372
+ async update(change) {
5373
+ return withFileLock(this.path, STALE_LOCK_MS, async () => {
5374
+ const loaded = await this.load();
5375
+ if ("problem" in loaded) return { problem: loaded.problem };
5376
+ await atomicWrite(this.path, `${JSON.stringify(change(loaded.keys), null, 2)}
5377
+ `, 384);
5378
+ return { ok: true };
5379
+ });
5380
+ }
5381
+ };
5382
+
5086
5383
  // src/application/pairing.ts
5087
- var PAIRING_TTL_MS = 10 * 6e4;
5384
+ var PAIRING_TTL_MS = 20 * 6e4;
5088
5385
  var PAIRING_RETAIN_MS = 24 * 60 * 6e4;
5089
5386
  var retained = (list, now) => list.filter((p) => Date.parse(p.expiresAt) + PAIRING_RETAIN_MS > now.getTime());
5090
5387
  function stateOf(p, now) {
@@ -5106,8 +5403,32 @@ function redeemPairing(list, secret, now, device2) {
5106
5403
  return { list: kept.map((p) => p === match ? spent : p), outcome: "redeemed" };
5107
5404
  }
5108
5405
 
5406
+ // src/application/pairing-link.ts
5407
+ var GENERIC_MACHINE_IDS = /* @__PURE__ */ new Set(["", "local", "localhost"]);
5408
+ function pairingName(machineId, hostname) {
5409
+ return GENERIC_MACHINE_IDS.has(machineId) ? hostname.split(".")[0] : machineId;
5410
+ }
5411
+ function announcedAddresses(phoneAddresses2) {
5412
+ const valid = phoneAddresses2.length > 0 && phoneAddresses2.length <= MAX_ADDRESSES && phoneAddresses2.every(isMachineUrl);
5413
+ return valid ? phoneAddresses2 : void 0;
5414
+ }
5415
+ async function mintPairing(deps, input) {
5416
+ if (deps.defaultPath !== void 0 && !isPairingPath(deps.defaultPath)) {
5417
+ throw new Error(`TARFA_DEFAULT_PATH must be an absolute path of at most 128 characters (got ${deps.defaultPath})`);
5418
+ }
5419
+ const secret = deps.newSecret();
5420
+ await deps.pairings.update((list) => addPairing(list, secret, deps.now(), deps.ttlMs));
5421
+ return {
5422
+ urls: input.phoneUrls,
5423
+ agentKey: deps.keys.x25519.publicKey,
5424
+ secret,
5425
+ name: deps.machineId,
5426
+ ...deps.defaultPath !== void 0 ? { defaultPath: deps.defaultPath } : {}
5427
+ };
5428
+ }
5429
+
5109
5430
  // src/infrastructure/file-pairing-store.ts
5110
- import { readFile as readFile6 } from "node:fs/promises";
5431
+ import { readFile as readFile7 } from "node:fs/promises";
5111
5432
  import { z as z3 } from "zod";
5112
5433
  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
5434
  var FilePairingStore = class {
@@ -5119,7 +5440,7 @@ var FilePairingStore = class {
5119
5440
  staleLockMs;
5120
5441
  async read() {
5121
5442
  try {
5122
- const parsed = schema.safeParse(JSON.parse(await readFile6(this.filePath, "utf8")));
5443
+ const parsed = schema.safeParse(JSON.parse(await readFile7(this.filePath, "utf8")));
5123
5444
  return parsed.success ? parsed.data : [];
5124
5445
  } catch {
5125
5446
  return [];
@@ -5139,8 +5460,8 @@ var FilePairingStore = class {
5139
5460
  // src/infrastructure/agent-env-file.ts
5140
5461
  import { execFile as execFile3 } from "node:child_process";
5141
5462
  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";
5463
+ import { chmod as chmod2, copyFile, mkdir as mkdir7, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
5464
+ import { dirname as dirname8, resolve as resolve5 } from "node:path";
5144
5465
  import { fileURLToPath as fileURLToPath2 } from "node:url";
5145
5466
  import { promisify as promisify3 } from "node:util";
5146
5467
 
@@ -5160,10 +5481,10 @@ function withoutGitVars(env) {
5160
5481
  }
5161
5482
  async function mainCheckout(kind2 = installKind()) {
5162
5483
  if (kind2 === "package") return null;
5163
- const here = dirname7(fileURLToPath2(import.meta.url));
5484
+ const here = dirname8(fileURLToPath2(import.meta.url));
5164
5485
  try {
5165
5486
  const { stdout } = await exec3("git", ["-C", here, "rev-parse", "--path-format=absolute", "--git-common-dir"], { env: withoutGitVars(process.env) });
5166
- return dirname7(stdout.trim());
5487
+ return dirname8(stdout.trim());
5167
5488
  } catch {
5168
5489
  return resolve5(here, "..", "..", "..", "..");
5169
5490
  }
@@ -5176,7 +5497,7 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
5176
5497
  path,
5177
5498
  read: async () => {
5178
5499
  try {
5179
- return await readFile7(path, "utf8");
5500
+ return await readFile8(path, "utf8");
5180
5501
  } catch (err) {
5181
5502
  if (err.code === "ENOENT") {
5182
5503
  throw new Error(`no agent config at ${path}: set the agent up first (docs/DEPLOY.md), or set TARFA_AGENT_ENV`);
@@ -5188,14 +5509,14 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
5188
5509
  let backup = `${path}.bak-${now().toISOString().replace(/[:.]/g, "-")}`;
5189
5510
  try {
5190
5511
  await copyFile(path, backup);
5191
- await chmod(backup, 384);
5512
+ await chmod2(backup, 384);
5192
5513
  } catch (err) {
5193
5514
  if (err.code !== "ENOENT") throw err;
5194
5515
  backup = null;
5195
5516
  }
5196
- await mkdir6(dirname7(path), { recursive: true, mode: 448 });
5197
- await writeFile4(path, text, { mode: 384 });
5198
- await chmod(path, 384);
5517
+ await mkdir7(dirname8(path), { recursive: true, mode: 448 });
5518
+ await writeFile5(path, text, { mode: 384 });
5519
+ await chmod2(path, 384);
5199
5520
  return backup;
5200
5521
  }
5201
5522
  };
@@ -5203,7 +5524,7 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
5203
5524
 
5204
5525
  // src/infrastructure/file-status-store.ts
5205
5526
  import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
5206
- import { dirname as dirname8 } from "node:path";
5527
+ import { dirname as dirname9 } from "node:path";
5207
5528
 
5208
5529
  // ../contracts/src/domain/workspace-status.ts
5209
5530
  var WORKSPACE_STATUSES = [
@@ -5388,6 +5709,7 @@ var machineStatsSchema = z6.object({
5388
5709
  oomKills: z6.number().optional()
5389
5710
  }).optional()
5390
5711
  });
5712
+ var dictationProviderSchema = z6.enum(["elevenlabs", "openrouter"]);
5391
5713
  var clientMessageSchema = z6.discriminatedUnion("type", [
5392
5714
  // `token` carries a `tarfa pair` code for a new phone, or nothing (an enrolled key needs none).
5393
5715
  z6.object({ type: z6.literal("auth"), token: z6.string() }),
@@ -5404,6 +5726,13 @@ var clientMessageSchema = z6.discriminatedUnion("type", [
5404
5726
  z6.object({ type: z6.literal("listLaunchers") }),
5405
5727
  z6.object({ type: z6.literal("forgetLauncher"), command: z6.string() }),
5406
5728
  z6.object({ type: z6.literal("listKeyterms") }),
5729
+ // Asked only of an agent whose authResult lists AGENT_FEATURES.dictationKeys.
5730
+ z6.object({ type: z6.literal("getDictationKeys") }),
5731
+ // Store (`key`) or remove (`null`) one of the machine's dictation keys, as `tarfa dictation-key`
5732
+ // does. Sent only to an agent whose authResult lists AGENT_FEATURES.setDictationKey; the key is
5733
+ // checked by the agent, which answers `dictationKeySet` with the same `id`, so a late answer to
5734
+ // an earlier save is never taken for this one's.
5735
+ z6.object({ type: z6.literal("setDictationKey"), id: z6.string(), provider: dictationProviderSchema, key: z6.string().nullable() }),
5407
5736
  z6.object({ type: z6.literal("readFile"), path: z6.string() }),
5408
5737
  z6.object({ type: z6.literal("attach"), workspaceId: z6.string(), cols: dim, rows: dim }),
5409
5738
  z6.object({ type: z6.literal("detach"), workspaceId: z6.string() }),
@@ -5440,7 +5769,12 @@ var clientMessageSchema = z6.discriminatedUnion("type", [
5440
5769
  ]);
5441
5770
  var detachReasonSchema = z6.enum(["opened-elsewhere", "closed", "error"]);
5442
5771
  var PROTOCOL_VERSION = 1;
5443
- var AGENT_FEATURES = { lastReply: "lastReply" };
5772
+ var AGENT_FEATURES = {
5773
+ lastReply: "lastReply",
5774
+ dictationKeys: "dictationKeys",
5775
+ setDictationKey: "setDictationKey",
5776
+ addresses: "addresses"
5777
+ };
5444
5778
  var agentMessageSchema = z6.discriminatedUnion("type", [
5445
5779
  // `credential` is what authenticated the session (`token` only from an agent from before token
5446
5780
  // enrolment was removed); `enrolled` says the device key is enrolled (written), so the phone
@@ -5455,7 +5789,14 @@ var agentMessageSchema = z6.discriminatedUnion("type", [
5455
5789
  pushed: z6.literal(true).optional().catch(void 0),
5456
5790
  // `features` names what this agent answers beyond the protocol, so the PWA offers a control
5457
5791
  // 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)
5792
+ // `addresses`: where phones reach this agent now (its TARFA_PHONE_ADDRESSES), which a phone
5793
+ // adopts only once it has connected through each one.
5794
+ agent: z6.object({
5795
+ protocol: z6.number().int(),
5796
+ version: z6.string().optional(),
5797
+ features: z6.array(z6.string()).optional().catch(void 0),
5798
+ addresses: z6.array(z6.string()).optional().catch(void 0)
5799
+ }).optional().catch(void 0)
5459
5800
  }),
5460
5801
  // `claudeVoice`: whether this machine can play a Claude voice dictation (see voiceStart).
5461
5802
  // An older agent omits it, and the PWA then treats Claude voice as unavailable there.
@@ -5474,6 +5815,16 @@ var agentMessageSchema = z6.discriminatedUnion("type", [
5474
5815
  // keyterms so jargon transcribes correctly. Additive like `roots`: older agents omit it,
5475
5816
  // so it defaults to [] instead of letting the strict client parser drop the whole message.
5476
5817
  z6.object({ type: z6.literal("keyterms"), keyterms: z6.array(z6.string()).default([]) }),
5818
+ // The dictation provider keys the agent's operator stored (~/.tarfa/dictation-keys.json), sent
5819
+ // only in reply to getDictationKeys. A key the agent does not hold is absent.
5820
+ z6.object({
5821
+ type: z6.literal("dictationKeys"),
5822
+ elevenlabs: z6.string().optional().catch(void 0),
5823
+ openrouter: z6.string().optional().catch(void 0)
5824
+ }),
5825
+ // The answer to setDictationKey. `error` is the agent's fixed wording of a refusal and never
5826
+ // holds the key.
5827
+ z6.object({ type: z6.literal("dictationKeySet"), id: z6.string(), provider: dictationProviderSchema, ok: z6.boolean(), error: z6.string().optional() }),
5477
5828
  // `files` is additive: older agents that predate it omit the field, so it defaults to []
5478
5829
  // (a strictly-required field would make the strict client parser silently drop their
5479
5830
  // dirEntries during a version-skewed rollout, hanging the browser on "Loading…").
@@ -5697,7 +6048,7 @@ var FileStatusStore = class {
5697
6048
  }
5698
6049
  }
5699
6050
  persist() {
5700
- mkdirSync(dirname8(this.filePath), { recursive: true });
6051
+ mkdirSync(dirname9(this.filePath), { recursive: true });
5701
6052
  writeFileSync(this.filePath, JSON.stringify(Object.fromEntries(this.statuses), null, 2), "utf8");
5702
6053
  }
5703
6054
  };
@@ -5726,8 +6077,8 @@ function parseEntry(value) {
5726
6077
  }
5727
6078
 
5728
6079
  // 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";
6080
+ import { readFile as readFile9, writeFile as writeFile6, mkdir as mkdir8 } from "node:fs/promises";
6081
+ import { dirname as dirname10 } from "node:path";
5731
6082
  var FileParkedStore = class {
5732
6083
  constructor(filePath) {
5733
6084
  this.filePath = filePath;
@@ -5736,7 +6087,7 @@ var FileParkedStore = class {
5736
6087
  async list() {
5737
6088
  let raw;
5738
6089
  try {
5739
- raw = await readFile8(this.filePath, "utf8");
6090
+ raw = await readFile9(this.filePath, "utf8");
5740
6091
  } catch {
5741
6092
  return [];
5742
6093
  }
@@ -5761,8 +6112,8 @@ var FileParkedStore = class {
5761
6112
  await this.persist(rest);
5762
6113
  }
5763
6114
  async persist(entries) {
5764
- await mkdir7(dirname9(this.filePath), { recursive: true });
5765
- await writeFile5(this.filePath, JSON.stringify(entries, null, 2), "utf8");
6115
+ await mkdir8(dirname10(this.filePath), { recursive: true });
6116
+ await writeFile6(this.filePath, JSON.stringify(entries, null, 2), "utf8");
5766
6117
  }
5767
6118
  };
5768
6119
  function parseEntry2(value) {
@@ -5796,10 +6147,10 @@ function parseEntry2(value) {
5796
6147
  }
5797
6148
 
5798
6149
  // src/infrastructure/fs-file-writer.ts
5799
- import { mkdir as mkdir8, writeFile as writeFile6, access } from "node:fs/promises";
6150
+ import { mkdir as mkdir9, writeFile as writeFile7, access } from "node:fs/promises";
5800
6151
  var FsFileWriter = class {
5801
6152
  async ensureDir(dir) {
5802
- await mkdir8(dir, { recursive: true });
6153
+ await mkdir9(dir, { recursive: true });
5803
6154
  }
5804
6155
  async exists(path) {
5805
6156
  try {
@@ -5810,7 +6161,7 @@ var FsFileWriter = class {
5810
6161
  }
5811
6162
  }
5812
6163
  async writeFile(path, bytes) {
5813
- await writeFile6(path, bytes);
6164
+ await writeFile7(path, bytes);
5814
6165
  }
5815
6166
  };
5816
6167
 
@@ -6111,11 +6462,10 @@ export {
6111
6462
  parseDeviceHello,
6112
6463
  deviceFingerprint,
6113
6464
  MAX_ADDRESSES,
6114
- isPairingPath,
6115
6465
  generatePairingSecret,
6116
6466
  pairingLink,
6117
- parsePairingLink,
6118
6467
  isMachineUrl,
6468
+ pairingCode,
6119
6469
  REFUSAL,
6120
6470
  IN_BAND_REFUSAL,
6121
6471
  DEVICE_REFUSED,
@@ -6158,6 +6508,7 @@ export {
6158
6508
  getRecentPaths,
6159
6509
  pathWithinRoots,
6160
6510
  browseDir,
6511
+ atomicWrite,
6161
6512
  loadOrCreateAgentKeys,
6162
6513
  enrolmentMode,
6163
6514
  withEnrolmentMode,
@@ -6166,9 +6517,16 @@ export {
6166
6517
  revokeDevice,
6167
6518
  forgetDevice,
6168
6519
  FileDeviceStore,
6520
+ DICTATION_PROVIDERS,
6521
+ providerOf,
6522
+ changeDictationKey,
6523
+ exposedFiles,
6524
+ FileDictationKeysStore,
6169
6525
  PAIRING_TTL_MS,
6170
- addPairing,
6171
6526
  redeemPairing,
6527
+ pairingName,
6528
+ announcedAddresses,
6529
+ mintPairing,
6172
6530
  FilePairingStore,
6173
6531
  mainCheckout,
6174
6532
  locateAgentEnv,
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  CodexAppServer,
4
+ DICTATION_PROVIDERS,
4
5
  FileClaudeSessionsStore,
5
6
  FileDeviceStore,
7
+ FileDictationKeysStore,
6
8
  FileHiddenFoldersStore,
7
9
  FilePairingStore,
8
10
  FileParkedStore,
@@ -17,9 +19,10 @@ import {
17
19
  STATE_DIR,
18
20
  ShortWorkspaceIds,
19
21
  TmuxAdapter,
20
- addPairing,
21
22
  agentEnvFile,
23
+ atomicWrite,
22
24
  browseDir,
25
+ changeDictationKey,
23
26
  childEnv,
24
27
  cloneOfUnit,
25
28
  codexVersions,
@@ -37,7 +40,6 @@ import {
37
40
  isEntrypoint,
38
41
  isHiddenPath,
39
42
  isMachineUrl,
40
- isPairingPath,
41
43
  listWorkspaces,
42
44
  loadConfig,
43
45
  loadOrCreateAgentKeys,
@@ -46,14 +48,17 @@ import {
46
48
  loginShellResolver,
47
49
  mainCheckout,
48
50
  migrateStateDir,
51
+ mintPairing,
49
52
  packageRoot,
50
53
  packageVersion,
54
+ pairingCode,
51
55
  pairingLink,
56
+ pairingName,
52
57
  parseEnvFile,
53
- parsePairingLink,
54
58
  parseSetupFlags,
55
59
  planCli,
56
60
  planSetup,
61
+ providerOf,
57
62
  renderService,
58
63
  resolveCodexHome,
59
64
  resolveStateDir,
@@ -66,13 +71,13 @@ import {
66
71
  unitRunsLauncher,
67
72
  withEnrolmentMode,
68
73
  withLegacyEnv
69
- } from "./chunk-KTUXNG4Q.js";
74
+ } from "./chunk-UPCCR4ZA.js";
70
75
 
71
76
  // src/cli.ts
72
77
  import { randomBytes } from "node:crypto";
73
78
  import { homedir, hostname, userInfo } from "node:os";
74
- import { readFile as readFile6, mkdir as mkdir4, realpath as realpath2 } from "node:fs/promises";
75
- import { dirname as dirname4, join as joinPath, resolve as resolvePath } from "node:path";
79
+ import { readFile as readFile6, mkdir as mkdir3, realpath as realpath2 } from "node:fs/promises";
80
+ import { dirname as dirname3, join as joinPath, resolve as resolvePath } from "node:path";
76
81
 
77
82
  // src/application/format-workspace-table.ts
78
83
  var TITLE_MAX = 40;
@@ -653,29 +658,13 @@ async function firstOnPath(name, pathVar) {
653
658
  return null;
654
659
  }
655
660
 
656
- // src/infrastructure/atomic-write.ts
657
- import { chmod as chmod3, mkdir as mkdir3, rename as rename2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
658
- import { dirname as dirname3 } from "node:path";
659
- async function atomicWrite(path, text, mode) {
660
- await mkdir3(dirname3(path), { recursive: true });
661
- const temp = `${path}.tmp-${process.pid}`;
662
- try {
663
- await writeFile2(temp, text, { mode });
664
- await chmod3(temp, mode);
665
- await rename2(temp, path);
666
- } catch (err) {
667
- await rm2(temp, { force: true });
668
- throw err;
669
- }
670
- }
671
-
672
661
  // src/infrastructure/agent-log.ts
673
662
  import { open, readFile as readFile4, stat as stat3 } from "node:fs/promises";
674
663
  import { join as join5 } from "node:path";
675
664
 
676
665
  // src/infrastructure/service-install.ts
677
666
  import { execFile as execFile2 } from "node:child_process";
678
- import { chmod as chmod4, cp, copyFile as copyFile3, readFile as readFile3, rm as rm3, stat as stat2 } from "node:fs/promises";
667
+ import { chmod as chmod3, cp, copyFile as copyFile3, readFile as readFile3, rm as rm2, stat as stat2 } from "node:fs/promises";
679
668
  import { join as join4 } from "node:path";
680
669
  import { promisify as promisify2 } from "node:util";
681
670
  var exec2 = promisify2(execFile2);
@@ -705,7 +694,7 @@ async function backupFile(path) {
705
694
  if (!await exists(path)) return null;
706
695
  const copy = `${path}.bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
707
696
  await copyFile3(path, copy);
708
- await chmod4(copy, (await stat2(path)).mode & 511);
697
+ await chmod3(copy, (await stat2(path)).mode & 511);
709
698
  return copy;
710
699
  }
711
700
  var LABEL_GONE_MS = 1e4;
@@ -759,7 +748,7 @@ function serviceInstall(options) {
759
748
  return [`launchctl bootout ${domain}/${LEGACY_LABEL}`];
760
749
  },
761
750
  async removeLegacy() {
762
- await rm3(legacyPlist, { force: true });
751
+ await rm2(legacyPlist, { force: true });
763
752
  return [`removed ${legacyPlist}`];
764
753
  }
765
754
  };
@@ -811,9 +800,9 @@ function serviceInstall(options) {
811
800
  return ran.map(([command, args]) => `${command} ${args.join(" ")}`);
812
801
  },
813
802
  async removeLegacy() {
814
- await rm3(legacyUnit, { force: true });
803
+ await rm2(legacyUnit, { force: true });
815
804
  if (await exists(legacyDropIns) && !await exists(dropIns)) await cp(legacyDropIns, dropIns, { recursive: true });
816
- await rm3(legacyDropIns, { recursive: true, force: true });
805
+ await rm2(legacyDropIns, { recursive: true, force: true });
817
806
  await run4("systemctl", ["--user", "daemon-reload"]);
818
807
  return [`removed ${legacyUnit} and its drop-ins`, "systemctl --user daemon-reload"];
819
808
  }
@@ -980,29 +969,50 @@ function attempt(port, ms) {
980
969
  });
981
970
  }
982
971
 
983
- // src/cli.ts
984
- import encodeQR from "qr";
985
-
986
- // src/application/pairing-link.ts
987
- var GENERIC_MACHINE_IDS = /* @__PURE__ */ new Set(["", "local", "localhost"]);
988
- function pairingName(machineId, hostname2) {
989
- return GENERIC_MACHINE_IDS.has(machineId) ? hostname2.split(".")[0] : machineId;
990
- }
991
- async function mintPairingLink(deps, input) {
992
- if (deps.defaultPath !== void 0 && !isPairingPath(deps.defaultPath)) {
993
- throw new Error(`TARFA_DEFAULT_PATH must be an absolute path of at most 128 characters (got ${deps.defaultPath})`);
972
+ // src/infrastructure/read-secret.ts
973
+ async function readSecret(prompt) {
974
+ const stdin = process.stdin;
975
+ if (!stdin.isTTY) {
976
+ const chunks = [];
977
+ for await (const chunk of stdin) chunks.push(chunk);
978
+ return Buffer.concat(chunks).toString("utf8");
979
+ }
980
+ process.stderr.write(prompt);
981
+ stdin.setRawMode(true);
982
+ stdin.resume();
983
+ stdin.setEncoding("utf8");
984
+ try {
985
+ return await new Promise((resolve2, reject) => {
986
+ let typed = "";
987
+ const onData = (data) => {
988
+ for (const ch of data) {
989
+ if (ch === "\r" || ch === "\n" || ch === "") {
990
+ stdin.off("data", onData);
991
+ resolve2(typed);
992
+ return;
993
+ }
994
+ if (ch === "") {
995
+ stdin.off("data", onData);
996
+ reject(new Error("cancelled"));
997
+ return;
998
+ }
999
+ if (ch === "\x7F" || ch === "\b") typed = typed.slice(0, -1);
1000
+ else typed += ch;
1001
+ }
1002
+ };
1003
+ stdin.on("data", onData);
1004
+ stdin.once("end", () => reject(new Error("cancelled")));
1005
+ });
1006
+ } finally {
1007
+ stdin.setRawMode(false);
1008
+ stdin.pause();
1009
+ process.stderr.write("\n");
994
1010
  }
995
- const secret = deps.newSecret();
996
- await deps.pairings.update((list) => addPairing(list, secret, deps.now(), deps.ttlMs));
997
- return pairingLink(input.pwaOrigin, {
998
- urls: input.phoneUrls,
999
- agentKey: deps.keys.x25519.publicKey,
1000
- secret,
1001
- name: deps.machineId,
1002
- ...deps.defaultPath !== void 0 ? { defaultPath: deps.defaultPath } : {}
1003
- });
1004
1011
  }
1005
1012
 
1013
+ // src/cli.ts
1014
+ import encodeQR from "qr";
1015
+
1006
1016
  // src/infrastructure/agent-service.ts
1007
1017
  import { execFile as execFile5 } from "node:child_process";
1008
1018
  import { promisify as promisify5 } from "node:util";
@@ -1252,6 +1262,8 @@ async function run3(argv, rawEnv, home, io = {}) {
1252
1262
  }
1253
1263
  case "devices":
1254
1264
  return devices(new FileDeviceStore(config.devicesPath), rest);
1265
+ case "dictation-key":
1266
+ return dictationKey(new FileDictationKeysStore(config.dictationKeysPath), rest, io.secret ?? readSecret);
1255
1267
  default:
1256
1268
  throw new UsageError(`unknown command: ${command} (run \`tarfa --help\`)`);
1257
1269
  }
@@ -1260,8 +1272,9 @@ var HELP = `usage: tarfa <command> [options] (\`tarfa setup --help\`, \`tarfa
1260
1272
 
1261
1273
  Set up this machine:
1262
1274
  setup write agent.env, the agent key, the service and the hooks, then check the phone port
1263
- pair print a one-time pairing link and QR code for a phone
1275
+ pair print a one-time pairing code and QR code for a phone (--link: a link, for an older app)
1264
1276
  devices list, revoke or forget paired phones; open or close enrolment
1277
+ dictation-key store the ElevenLabs or OpenRouter key every paired phone dictates with
1265
1278
  update install the newest @tarfa/cli and apply it (package installs)
1266
1279
  apply rewrite the launcher and service if they changed, reinstall the hooks, restart
1267
1280
  install-hooks install the Claude Code and Codex hooks that report each workspace's status
@@ -1287,6 +1300,7 @@ var COMMANDS = /* @__PURE__ */ new Set([
1287
1300
  "connect",
1288
1301
  "relay-key",
1289
1302
  "devices",
1303
+ "dictation-key",
1290
1304
  "install-hooks",
1291
1305
  "ls",
1292
1306
  "spawn",
@@ -1587,7 +1601,8 @@ async function readStdin(ttyError) {
1587
1601
  return Buffer.concat(chunks).toString("utf8");
1588
1602
  }
1589
1603
  var RELAYS_GONE = "relays are gone: tarfa setup sets a machine up for phones, and tarfa pair pairs one";
1590
- var PAIR_USAGE = "usage: tarfa pair [--pwa <PWA origin>] [--ttl <minutes, 1-10, default 10>]";
1604
+ var MAX_PAIRING_NAME = 64;
1605
+ var PAIR_USAGE = `usage: tarfa pair [--pwa <PWA origin>] [--ttl <minutes, 1-${PAIRING_TTL_MS / 6e4}, default ${PAIRING_TTL_MS / 6e4}>] [--link]`;
1591
1606
  function takeOption(args, name, usage, Failure = Error) {
1592
1607
  const at = args.indexOf(name);
1593
1608
  if (at === -1) return void 0;
@@ -1629,6 +1644,8 @@ async function pair(argv, env, home) {
1629
1644
  const config = loadConfig({ ...env, ...await agentEnvValues(env, home) }, home);
1630
1645
  let origin = pwaOrigin(takeOption(args, "--pwa", PAIR_USAGE));
1631
1646
  const ttl = takeOption(args, "--ttl", PAIR_USAGE) ?? String(PAIRING_TTL_MS / 6e4);
1647
+ const linkAt = args.indexOf("--link");
1648
+ if (linkAt !== -1) args.splice(linkAt, 1);
1632
1649
  const minutes = Number(ttl);
1633
1650
  if (!/^\d+$/.test(ttl) || minutes < 1 || minutes * 6e4 > PAIRING_TTL_MS) throw new Error(`--ttl takes whole minutes from 1 to ${PAIRING_TTL_MS / 6e4} (got ${ttl})`);
1634
1651
  if (args.length > 0) throw new Error(PAIR_USAGE);
@@ -1643,19 +1660,21 @@ async function pair(argv, env, home) {
1643
1660
  const bad = config.phoneAddresses.find((url) => !isMachineUrl(url));
1644
1661
  if (bad) throw new Error(`TARFA_PHONE_ADDRESSES holds ${bad}, which a phone refuses: wss://<host>[:port][/<slug>] (run tarfa setup)`);
1645
1662
  if (config.phoneAddresses.length > MAX_ADDRESSES) throw new Error(`TARFA_PHONE_ADDRESSES holds more than ${MAX_ADDRESSES} addresses`);
1646
- const link = await mintPairingLink(
1663
+ const machineId = pairingName(config.machineId, hostname());
1664
+ if (machineId.trim().length > MAX_PAIRING_NAME) throw new Error(`TARFA_MACHINE_ID must be at most ${MAX_PAIRING_NAME} characters, the name phones show (got ${machineId.length})`);
1665
+ const pairing = await mintPairing(
1647
1666
  {
1648
1667
  keys: await loadOrCreateAgentKeys(config.agentKeyPath),
1649
- machineId: pairingName(config.machineId, hostname()),
1668
+ machineId,
1650
1669
  ...config.defaultPath ? { defaultPath: config.defaultPath } : {},
1651
1670
  pairings: new FilePairingStore(config.pairingPath),
1652
1671
  newSecret: generatePairingSecret,
1653
1672
  now: () => /* @__PURE__ */ new Date(),
1654
1673
  ttlMs: minutes * 6e4
1655
1674
  },
1656
- { phoneUrls: config.phoneAddresses, pwaOrigin: origin }
1675
+ { phoneUrls: config.phoneAddresses }
1657
1676
  );
1658
- return pairingInstructions(link, minutes);
1677
+ return pairingInstructions(pairing, { origin, minutes, link: linkAt !== -1 });
1659
1678
  }
1660
1679
  var WaitTimeout = class extends Error {
1661
1680
  exitCode = 124;
@@ -1675,11 +1694,16 @@ replace anything it did not make (an agent key, another tailscale serve entry).
1675
1694
 
1676
1695
  --machine-id <id> the name phones show for this machine
1677
1696
  --project-roots <dir,dir> absolute folders new workspaces may be made in
1678
- --default-path <dir> where a phone resolves relative paths (in pairing links)
1697
+ --default-path <dir> where a phone resolves relative paths (sent when pairing)
1679
1698
  --phone-port <n> loopback port phones reach (default 8797); every Linux user on one
1680
1699
  machine needs their own, e.g. 8797 and 8798
1681
- --tailscale serve|funnel|none publish the phone port on the tailnet (serve), also on the public
1682
- internet (funnel), or not at all (none, the default)
1700
+ --tailscale funnel|serve|none how phones reach this machine: with or without Tailscale (funnel,
1701
+ public through Funnel), only with Tailscale on (serve), or through an
1702
+ address published some other way (none, with --address). Ask the
1703
+ operator. Without it, setup keeps the TARFA_PHONE_ADDRESSES or the
1704
+ serve handler for the phone port the machine already has, picks
1705
+ funnel for a machine phones reached through a relay, and otherwise
1706
+ refuses. Every run prints a reachability: line saying which
1683
1707
  --tailscale-port <n> the HTTPS port Tailscale serves it on (default 443 for serve, 10000
1684
1708
  for funnel; Funnel allows only 443, 8443 and 10000)
1685
1709
  --slug <name> serve under /<name>, so several agents share one port
@@ -1687,7 +1711,7 @@ replace anything it did not make (an agent key, another tailscale serve entry).
1687
1711
  4 in all), e.g. a reverse proxy
1688
1712
  --pwa <origin> where phones open the app (default https://tarfa.app; an existing
1689
1713
  TARFA_PWA_ORIGIN is kept unless this is given)
1690
- --pair print a pairing link and QR code at the end
1714
+ --pair print a pairing code and QR code at the end
1691
1715
  --adopt a machine whose service runs a clone's deploy/run-agent.sh: move it to
1692
1716
  the installed package (copies deploy/agent.env to ~/.tarfa/agent.env,
1693
1717
  keeps the original, repoints the service, replaces the ~/.local/bin links
@@ -1795,9 +1819,10 @@ ${SETUP_USAGE.split("\n")[0]}
1795
1819
  stateDir: stateDirLayout(home),
1796
1820
  phonePortOwner: await owner(flags.phonePort),
1797
1821
  hooksPortOwner: await owner(config.port),
1798
- tailscale: flags.tailscale === "none" ? null : await tailscale.status(),
1799
- // Under --tailscale none, read only to word the operator's line: unreadable means the serve form.
1800
- serve: flags.tailscale === "none" && !flags.slug ? null : await tailscale.serveStatus(),
1822
+ // Read whatever --tailscale says: they settle how the port is published when no flag does, and word
1823
+ // whether phones need Tailscale. Both answer null when Tailscale is absent or will not tell.
1824
+ tailscale: await tailscale.status(),
1825
+ serve: await tailscale.serveStatus(),
1801
1826
  cli: kind === "package" ? await cliState(home, env) : null,
1802
1827
  adoption: cloneRepo === null ? null : { repo: cloneRepo, copyEnv, agentLive: { path: agentLivePath, kind: await linkKind(agentLivePath) } }
1803
1828
  };
@@ -1813,7 +1838,7 @@ ${SETUP_USAGE.split("\n")[0]}
1813
1838
  await loadOrCreateAgentKeys(config.agentKeyPath);
1814
1839
  },
1815
1840
  writeRoster: async () => {
1816
- await mkdir4(dirname4(config.devicesPath), { recursive: true, mode: 448 });
1841
+ await mkdir3(dirname3(config.devicesPath), { recursive: true, mode: 448 });
1817
1842
  await devices2.update((roster) => roster);
1818
1843
  },
1819
1844
  writeLauncher: (path, text) => atomicWrite(path, text, 493),
@@ -1946,20 +1971,52 @@ async function apply2(argv, env, home, io) {
1946
1971
  if (!result.ok) throw new Error(result.lines.join("\n"));
1947
1972
  return result.lines.join("\n");
1948
1973
  }
1949
- function pairingInstructions(link, minutes = PAIRING_TTL_MS / 6e4) {
1950
- return [
1974
+ function pairingInstructions(pairing, options) {
1975
+ const { origin, minutes } = options;
1976
+ const text = options.link ? pairingLink(origin, pairing) : pairingCode(pairing);
1977
+ const how = options.link ? [
1951
1978
  "On the phone, in Tarfa: Settings \u2192 Scan QR code, then scan this. Or open the link below on the",
1952
- "phone (Android opens the installed app; on an iPhone copy it and use Settings \u2192 Paste pairing link).",
1979
+ "phone (Android opens the installed app; on an iPhone copy it and use Settings \u2192 Paste pairing link)."
1980
+ ] : [
1981
+ `On the phone, open the Tarfa app (install it from ${origin} first), then Settings \u2192 Add machine:`,
1982
+ "Scan QR code and scan this, or copy the code below to the phone and tap Paste pairing code."
1983
+ ];
1984
+ return [
1985
+ ...how,
1953
1986
  "",
1954
1987
  // Half-height blocks: about 60 columns, where `term` needs twice that.
1955
- encodeQR(link, "ascii", { ecc: "low" }),
1956
- link,
1988
+ encodeQR(text, "ascii", { ecc: "low" }),
1989
+ text,
1957
1990
  "",
1958
1991
  // The phone asks before pairing and shows this fingerprint: they must match.
1959
- `The phone will show machine key ${deviceFingerprint(parsePairingLink(link).agentKey)}; check it matches.`,
1992
+ `The phone will show machine key ${deviceFingerprint(pairing.agentKey)}; check it matches.`,
1960
1993
  `It works once, for ${minutes} minute${minutes === 1 ? "" : "s"}. \`tarfa pair\` makes another.`
1961
1994
  ].join("\n");
1962
1995
  }
1996
+ var DICTATION_KEY_USAGE = [
1997
+ `usage: tarfa dictation-key [status | set ${DICTATION_PROVIDERS.join("|")} | remove ${DICTATION_PROVIDERS.join("|")}]`,
1998
+ " set reads the key on stdin, or asks for it with echo off; never as an argument",
1999
+ " every phone paired with this machine dictates into its workspaces with it, at its next connect"
2000
+ ].join("\n");
2001
+ async function dictationKey(store, [action = "status", name, ...extra], secret) {
2002
+ if (action === "status") {
2003
+ if (name !== void 0) throw new UsageError(DICTATION_KEY_USAGE);
2004
+ const loaded2 = await store.load();
2005
+ if ("problem" in loaded2) throw new Error(loaded2.problem);
2006
+ return DICTATION_PROVIDERS.map((p) => `${p} ${loaded2.keys[p] ? "set" : "not set"}`).join("\n");
2007
+ }
2008
+ if (action !== "set" && action !== "remove") throw new UsageError(DICTATION_KEY_USAGE);
2009
+ if (action === "set" && extra.length > 0) {
2010
+ throw new UsageError("pass the key on stdin or type it at the prompt, never as an argument: any local user can read a process's arguments");
2011
+ }
2012
+ const provider = name ? providerOf(name) : null;
2013
+ if (!provider || extra.length > 0) throw new UsageError(DICTATION_KEY_USAGE);
2014
+ const loaded = await store.load();
2015
+ if ("problem" in loaded) throw new Error(loaded.problem);
2016
+ const result = await changeDictationKey(store, provider, action === "remove" ? null : await secret(`${provider} key: `));
2017
+ if (!result.ok) throw new Error(result.error);
2018
+ return action === "remove" ? `${provider}: removed; phones stop getting it at their next connect. Rotate it at the provider if a phone that had it is lost` : `${provider}: stored; every phone paired with this machine gets it at its next connect`;
2019
+ }
1963
2020
  var DEVICES_USAGE = [
1964
2021
  "usage: tarfa devices [list | revoke <id|key> | forget <id|key> | enrolment open|closed]",
1965
2022
  " enrolment open a pairing code from `tarfa pair` enrols a new phone",
package/dist/serve.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  DEVICE_REFUSED,
7
7
  FileClaudeSessionsStore,
8
8
  FileDeviceStore,
9
+ FileDictationKeysStore,
9
10
  FileHiddenFoldersStore,
10
11
  FilePairingStore,
11
12
  FileParkedStore,
@@ -25,9 +26,11 @@ import {
25
26
  ShortWorkspaceIds,
26
27
  TmuxAdapter,
27
28
  WrongStaticKeyError,
29
+ announcedAddresses,
28
30
  audioMediaType,
29
31
  b64url,
30
32
  browseDir,
33
+ changeDictationKey,
31
34
  childEnv,
32
35
  cliOfCommand,
33
36
  configWarnings,
@@ -39,6 +42,7 @@ import {
39
42
  enrolDevice,
40
43
  enrolmentMode,
41
44
  exactTarget,
45
+ exposedFiles,
42
46
  formatAgentMessage,
43
47
  fsIsDirectory,
44
48
  getRecentPaths,
@@ -74,7 +78,7 @@ import {
74
78
  systemPathResolver,
75
79
  withLegacyEnv,
76
80
  writeFileAtomic
77
- } from "./chunk-KTUXNG4Q.js";
81
+ } from "./chunk-UPCCR4ZA.js";
78
82
 
79
83
  // src/serve.ts
80
84
  import { homedir as homedir2 } from "node:os";
@@ -1284,6 +1288,7 @@ var PRE_AUTH_MAX_MESSAGE_BYTES = 8 * 1024;
1284
1288
  var MAX_AUTH_ATTEMPTS = 3;
1285
1289
  var MAX_HELD_MESSAGES = 16;
1286
1290
  var PAIRING_SECRET = /^[A-Za-z0-9_-]{43}$/;
1291
+ var FEATURES = [AGENT_FEATURES.lastReply, AGENT_FEATURES.dictationKeys, AGENT_FEATURES.setDictationKey];
1287
1292
  var NoiseClientConnection = class {
1288
1293
  constructor(socket, staticPrivate, options) {
1289
1294
  this.socket = socket;
@@ -1332,7 +1337,15 @@ var NoiseClientConnection = class {
1332
1337
  return;
1333
1338
  }
1334
1339
  if (message.type === "authResult" && message.ok) {
1335
- const accepted = { ...message, agent: { protocol: PROTOCOL_VERSION, ...this.options.agentVersion ? { version: this.options.agentVersion } : {}, features: [AGENT_FEATURES.lastReply] } };
1340
+ const { agentVersion: agentVersion2, phoneAddresses } = this.options;
1341
+ const features = [...this.options.features ?? FEATURES];
1342
+ const agent = {
1343
+ protocol: PROTOCOL_VERSION,
1344
+ ...agentVersion2 ? { version: agentVersion2 } : {},
1345
+ features: phoneAddresses ? [...features, AGENT_FEATURES.addresses] : features,
1346
+ ...phoneAddresses ? { addresses: phoneAddresses } : {}
1347
+ };
1348
+ const accepted = { ...message, agent };
1336
1349
  message = accepted;
1337
1350
  if (!this.awaitingPhone) this.cancelDeadline();
1338
1351
  this.session.raiseCap(MAX_MESSAGE_BYTES);
@@ -2386,6 +2399,12 @@ var ConnectionHandler = class {
2386
2399
  this.conn.send({ type: "keyterms", keyterms: await this.deps.keyterms.list() });
2387
2400
  return;
2388
2401
  }
2402
+ case "getDictationKeys":
2403
+ this.conn.send({ type: "dictationKeys", ...await this.deps.dictationKeys?.read() });
2404
+ return;
2405
+ case "setDictationKey":
2406
+ this.conn.send({ type: "dictationKeySet", id: message.id, provider: message.provider, ...await this.setDictationKey(message.provider, message.key) });
2407
+ return;
2389
2408
  case "readFile": {
2390
2409
  const { binary, data, truncated } = await readFile10({ reader }, message.path, await this.allowedRoots());
2391
2410
  this.conn.send({ type: "fileContents", path: message.path, data, truncated, binary });
@@ -2679,6 +2698,17 @@ var ConnectionHandler = class {
2679
2698
  });
2680
2699
  this.conn.send({ type: "attached", workspaceId });
2681
2700
  }
2701
+ // Any enrolled phone of this machine may change its key: it can already run anything in a
2702
+ // terminal here. A thrown error may name the file, never the key, but is worded here anyway.
2703
+ async setDictationKey(provider, key) {
2704
+ const store = this.deps.dictationKeys;
2705
+ if (!store) return { ok: false, error: "this agent keeps no dictation keys" };
2706
+ try {
2707
+ return await changeDictationKey(store, provider, key);
2708
+ } catch {
2709
+ return { ok: false, error: "the keys file could not be written" };
2710
+ }
2711
+ }
2682
2712
  // A dictation lives only as long as its attachment: without one the held spaces go nowhere,
2683
2713
  // and the machine's one dictation slot would stay taken.
2684
2714
  cancelVoice(reason) {
@@ -3876,7 +3906,7 @@ function startDeviceGate(deps) {
3876
3906
  return {
3877
3907
  staticPrivate: b64url.decode(keys.x25519.privateKey),
3878
3908
  devices,
3879
- options: (extra) => ({ devices, log, refusalLog, pushBudget, agentVersion: deps.agentVersion, ...extra }),
3909
+ options: (extra) => ({ devices, log, refusalLog, pushBudget, agentVersion: deps.agentVersion, phoneAddresses: deps.phoneAddresses, ...extra }),
3880
3910
  roster: () => last,
3881
3911
  pollRoster: () => monitor.poll(),
3882
3912
  stop: () => clearInterval(poll)
@@ -4127,13 +4157,6 @@ import { join as join6 } from "node:path";
4127
4157
 
4128
4158
  // src/infrastructure/private-dir.ts
4129
4159
  import { chmod, mkdir as mkdir7, stat as stat4 } from "node:fs/promises";
4130
-
4131
- // src/application/private-files.ts
4132
- function exposedFiles(files) {
4133
- 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}\``);
4134
- }
4135
-
4136
- // src/infrastructure/private-dir.ts
4137
4160
  async function securePrivateDir(dir) {
4138
4161
  await mkdir7(dir, { recursive: true, mode: 448 });
4139
4162
  await chmod(dir, 448);
@@ -4196,6 +4219,7 @@ async function main() {
4196
4219
  config.devicesPath,
4197
4220
  config.pairingPath,
4198
4221
  join7(config.stateDir, "token"),
4222
+ config.dictationKeysPath,
4199
4223
  // The launcher sources it: whoever can write it runs code as this user.
4200
4224
  await locateAgentEnv(env, homedir2())
4201
4225
  ]);
@@ -4238,6 +4262,8 @@ async function main() {
4238
4262
  launchers: new FileLaunchersStore(config.launchersPath),
4239
4263
  detectInstalled: (names) => detectInstalled(names, loginShellResolver()),
4240
4264
  keyterms: new FileKeytermsStore(config.keytermsPath),
4265
+ dictationKeys: new FileDictationKeysStore(config.dictationKeysPath, (problem) => process.stderr.write(`tarfa agent: no dictation keys sent: ${problem}
4266
+ `)),
4241
4267
  clientLog: new FileClientLogStore(config.clientLogPath),
4242
4268
  hookEventLog: new FileHookEventLog(config.hookEventLogPath),
4243
4269
  messaging: {
@@ -4277,7 +4303,8 @@ async function main() {
4277
4303
  pairings: new FilePairingStore(config.pairingPath),
4278
4304
  log,
4279
4305
  rosterPollMs: ROSTER_POLL_MS,
4280
- agentVersion
4306
+ agentVersion,
4307
+ phoneAddresses: announcedAddresses(config.phoneAddresses)
4281
4308
  });
4282
4309
  try {
4283
4310
  const phones = await startPhoneListener({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tarfa/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Tarfa agent and CLI: serves your Claude Code terminal sessions (tmux workspaces) to the Tarfa phone app.",
5
5
  "repository": {
6
6
  "type": "git",