@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.
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,18 @@ 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,
62
+ readAgentPublicKey,
57
63
  renderService,
58
64
  resolveCodexHome,
59
65
  resolveStateDir,
@@ -65,14 +71,15 @@ import {
65
71
  systemPathResolver,
66
72
  unitRunsLauncher,
67
73
  withEnrolmentMode,
68
- withLegacyEnv
69
- } from "./chunk-KTUXNG4Q.js";
74
+ withLegacyEnv,
75
+ workspaceLink
76
+ } from "./chunk-4M2ZRGQR.js";
70
77
 
71
78
  // src/cli.ts
72
79
  import { randomBytes } from "node:crypto";
73
80
  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";
81
+ import { readFile as readFile6, mkdir as mkdir3, realpath as realpath2 } from "node:fs/promises";
82
+ import { dirname as dirname3, join as joinPath, resolve as resolvePath } from "node:path";
76
83
 
77
84
  // src/application/format-workspace-table.ts
78
85
  var TITLE_MAX = 40;
@@ -653,29 +660,13 @@ async function firstOnPath(name, pathVar) {
653
660
  return null;
654
661
  }
655
662
 
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
663
  // src/infrastructure/agent-log.ts
673
664
  import { open, readFile as readFile4, stat as stat3 } from "node:fs/promises";
674
665
  import { join as join5 } from "node:path";
675
666
 
676
667
  // src/infrastructure/service-install.ts
677
668
  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";
669
+ import { chmod as chmod3, cp, copyFile as copyFile3, readFile as readFile3, rm as rm2, stat as stat2 } from "node:fs/promises";
679
670
  import { join as join4 } from "node:path";
680
671
  import { promisify as promisify2 } from "node:util";
681
672
  var exec2 = promisify2(execFile2);
@@ -705,7 +696,7 @@ async function backupFile(path) {
705
696
  if (!await exists(path)) return null;
706
697
  const copy = `${path}.bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
707
698
  await copyFile3(path, copy);
708
- await chmod4(copy, (await stat2(path)).mode & 511);
699
+ await chmod3(copy, (await stat2(path)).mode & 511);
709
700
  return copy;
710
701
  }
711
702
  var LABEL_GONE_MS = 1e4;
@@ -759,7 +750,7 @@ function serviceInstall(options) {
759
750
  return [`launchctl bootout ${domain}/${LEGACY_LABEL}`];
760
751
  },
761
752
  async removeLegacy() {
762
- await rm3(legacyPlist, { force: true });
753
+ await rm2(legacyPlist, { force: true });
763
754
  return [`removed ${legacyPlist}`];
764
755
  }
765
756
  };
@@ -811,9 +802,9 @@ function serviceInstall(options) {
811
802
  return ran.map(([command, args]) => `${command} ${args.join(" ")}`);
812
803
  },
813
804
  async removeLegacy() {
814
- await rm3(legacyUnit, { force: true });
805
+ await rm2(legacyUnit, { force: true });
815
806
  if (await exists(legacyDropIns) && !await exists(dropIns)) await cp(legacyDropIns, dropIns, { recursive: true });
816
- await rm3(legacyDropIns, { recursive: true, force: true });
807
+ await rm2(legacyDropIns, { recursive: true, force: true });
817
808
  await run4("systemctl", ["--user", "daemon-reload"]);
818
809
  return [`removed ${legacyUnit} and its drop-ins`, "systemctl --user daemon-reload"];
819
810
  }
@@ -980,29 +971,50 @@ function attempt(port, ms) {
980
971
  });
981
972
  }
982
973
 
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})`);
974
+ // src/infrastructure/read-secret.ts
975
+ async function readSecret(prompt) {
976
+ const stdin = process.stdin;
977
+ if (!stdin.isTTY) {
978
+ const chunks = [];
979
+ for await (const chunk of stdin) chunks.push(chunk);
980
+ return Buffer.concat(chunks).toString("utf8");
981
+ }
982
+ process.stderr.write(prompt);
983
+ stdin.setRawMode(true);
984
+ stdin.resume();
985
+ stdin.setEncoding("utf8");
986
+ try {
987
+ return await new Promise((resolve2, reject) => {
988
+ let typed = "";
989
+ const onData = (data) => {
990
+ for (const ch of data) {
991
+ if (ch === "\r" || ch === "\n" || ch === "") {
992
+ stdin.off("data", onData);
993
+ resolve2(typed);
994
+ return;
995
+ }
996
+ if (ch === "") {
997
+ stdin.off("data", onData);
998
+ reject(new Error("cancelled"));
999
+ return;
1000
+ }
1001
+ if (ch === "\x7F" || ch === "\b") typed = typed.slice(0, -1);
1002
+ else typed += ch;
1003
+ }
1004
+ };
1005
+ stdin.on("data", onData);
1006
+ stdin.once("end", () => reject(new Error("cancelled")));
1007
+ });
1008
+ } finally {
1009
+ stdin.setRawMode(false);
1010
+ stdin.pause();
1011
+ process.stderr.write("\n");
994
1012
  }
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
1013
  }
1005
1014
 
1015
+ // src/cli.ts
1016
+ import encodeQR from "qr";
1017
+
1006
1018
  // src/infrastructure/agent-service.ts
1007
1019
  import { execFile as execFile5 } from "node:child_process";
1008
1020
  import { promisify as promisify5 } from "node:util";
@@ -1186,7 +1198,7 @@ async function run3(argv, rawEnv, home, io = {}) {
1186
1198
  throw new UsageError(RELAYS_GONE);
1187
1199
  }
1188
1200
  if (argv[0] === "pair") return pair(argv.slice(1), env, home);
1189
- if (argv[0] === "ls" || argv[0] === "spawn" || argv[0] === "peek" || argv[0] === "unpark" || argv[0] === "rename" || argv[0] === "send" || argv[0] === "close" || argv[0] === "wait") {
1201
+ if (argv[0] === "ls" || argv[0] === "spawn" || argv[0] === "peek" || argv[0] === "unpark" || argv[0] === "rename" || argv[0] === "link" || argv[0] === "send" || argv[0] === "close" || argv[0] === "wait") {
1190
1202
  return orchestration(argv[0], argv.slice(1), env, home, io);
1191
1203
  }
1192
1204
  const config = loadConfig({ ...env, ...await agentEnvValues(env, home) }, home);
@@ -1252,6 +1264,8 @@ async function run3(argv, rawEnv, home, io = {}) {
1252
1264
  }
1253
1265
  case "devices":
1254
1266
  return devices(new FileDeviceStore(config.devicesPath), rest);
1267
+ case "dictation-key":
1268
+ return dictationKey(new FileDictationKeysStore(config.dictationKeysPath), rest, io.secret ?? readSecret);
1255
1269
  default:
1256
1270
  throw new UsageError(`unknown command: ${command} (run \`tarfa --help\`)`);
1257
1271
  }
@@ -1260,8 +1274,9 @@ var HELP = `usage: tarfa <command> [options] (\`tarfa setup --help\`, \`tarfa
1260
1274
 
1261
1275
  Set up this machine:
1262
1276
  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
1277
+ pair print a one-time pairing code and QR code for a phone (--link: a link, for an older app)
1264
1278
  devices list, revoke or forget paired phones; open or close enrolment
1279
+ dictation-key store the ElevenLabs or OpenRouter key every paired phone dictates with
1265
1280
  update install the newest @tarfa/cli and apply it (package installs)
1266
1281
  apply rewrite the launcher and service if they changed, reinstall the hooks, restart
1267
1282
  install-hooks install the Claude Code and Codex hooks that report each workspace's status
@@ -1275,6 +1290,7 @@ Workspaces on this machine:
1275
1290
  send send a message to another workspace
1276
1291
  unpark reopen parked workspaces
1277
1292
  rename rename a workspace (without an id: this one)
1293
+ link print a link that opens a workspace in the app (without an id: this one)
1278
1294
  close close workspaces
1279
1295
  list, create, recent, browse the agent's own views, as JSON
1280
1296
 
@@ -1287,12 +1303,14 @@ var COMMANDS = /* @__PURE__ */ new Set([
1287
1303
  "connect",
1288
1304
  "relay-key",
1289
1305
  "devices",
1306
+ "dictation-key",
1290
1307
  "install-hooks",
1291
1308
  "ls",
1292
1309
  "spawn",
1293
1310
  "peek",
1294
1311
  "unpark",
1295
1312
  "rename",
1313
+ "link",
1296
1314
  "send",
1297
1315
  "close",
1298
1316
  "wait",
@@ -1307,6 +1325,7 @@ var SPAWN_CLIS = ["claude", "codex", "pi"];
1307
1325
  var PEEK_USAGE = "usage: tarfa peek <workspace id> [-n <lines>]";
1308
1326
  var UNPARK_USAGE = "usage: tarfa unpark <workspace id>...";
1309
1327
  var RENAME_USAGE = "usage: tarfa rename [<workspace id>] <title> (without an id: the workspace this runs in)";
1328
+ var LINK_USAGE = "usage: tarfa link [<workspace id>] [--pwa <PWA origin>] (without an id: the workspace this runs in)";
1310
1329
  var SEND_USAGE = 'usage: tarfa send <workspace id> "<message>" (or the message on stdin)';
1311
1330
  var CLOSE_USAGE = "usage: tarfa close <workspace id>...";
1312
1331
  var WAIT_USAGE = "usage: tarfa wait <workspace id>... [--timeout <seconds, at least 10>]";
@@ -1469,6 +1488,18 @@ async function orchestration(command, argv, env, home, io) {
1469
1488
  const done = `${id}: renamed to "${title.trim()}"`;
1470
1489
  return locked ? done : `${done} (this tmux cannot lock a title, so Claude may rename it again)`;
1471
1490
  }
1491
+ case "link": {
1492
+ const origin = pwaOrigin(takeOption(args, "--pwa", LINK_USAGE));
1493
+ if (args.length > 1 || args.some((a) => a.startsWith("-"))) throw new Error(LINK_USAGE);
1494
+ const id = args[0] ?? await callerWorkspace(env, tmux, config.sessionPrefix);
1495
+ const workspace = (await listAll()).find((w) => w.id === id);
1496
+ if (!workspace) throw new Error(`no workspace ${id} (see tarfa ls)`);
1497
+ if (isHiddenPath(workspace.projectPath, await new FileHiddenFoldersStore(config.hiddenFoldersPath).list())) {
1498
+ throw new Error(`${id} is in a hidden folder, which phones do not list`);
1499
+ }
1500
+ const agentKey = await readAgentPublicKey(config.agentKeyPath);
1501
+ return workspaceLink(origin ?? pwaOrigin(config.pwaOrigin), { agentKey, workspaceId: id });
1502
+ }
1472
1503
  case "close": {
1473
1504
  if (args.length === 0 || args.some((id) => id.startsWith("-"))) throw new UsageError(CLOSE_USAGE);
1474
1505
  const self = await callerWorkspace(env, tmux, config.sessionPrefix).catch(() => void 0);
@@ -1587,7 +1618,8 @@ async function readStdin(ttyError) {
1587
1618
  return Buffer.concat(chunks).toString("utf8");
1588
1619
  }
1589
1620
  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>]";
1621
+ var MAX_PAIRING_NAME = 64;
1622
+ var PAIR_USAGE = `usage: tarfa pair [--pwa <PWA origin>] [--ttl <minutes, 1-${PAIRING_TTL_MS / 6e4}, default ${PAIRING_TTL_MS / 6e4}>] [--link]`;
1591
1623
  function takeOption(args, name, usage, Failure = Error) {
1592
1624
  const at = args.indexOf(name);
1593
1625
  if (at === -1) return void 0;
@@ -1629,6 +1661,8 @@ async function pair(argv, env, home) {
1629
1661
  const config = loadConfig({ ...env, ...await agentEnvValues(env, home) }, home);
1630
1662
  let origin = pwaOrigin(takeOption(args, "--pwa", PAIR_USAGE));
1631
1663
  const ttl = takeOption(args, "--ttl", PAIR_USAGE) ?? String(PAIRING_TTL_MS / 6e4);
1664
+ const linkAt = args.indexOf("--link");
1665
+ if (linkAt !== -1) args.splice(linkAt, 1);
1632
1666
  const minutes = Number(ttl);
1633
1667
  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
1668
  if (args.length > 0) throw new Error(PAIR_USAGE);
@@ -1643,19 +1677,21 @@ async function pair(argv, env, home) {
1643
1677
  const bad = config.phoneAddresses.find((url) => !isMachineUrl(url));
1644
1678
  if (bad) throw new Error(`TARFA_PHONE_ADDRESSES holds ${bad}, which a phone refuses: wss://<host>[:port][/<slug>] (run tarfa setup)`);
1645
1679
  if (config.phoneAddresses.length > MAX_ADDRESSES) throw new Error(`TARFA_PHONE_ADDRESSES holds more than ${MAX_ADDRESSES} addresses`);
1646
- const link = await mintPairingLink(
1680
+ const machineId = pairingName(config.machineId, hostname());
1681
+ 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})`);
1682
+ const pairing = await mintPairing(
1647
1683
  {
1648
1684
  keys: await loadOrCreateAgentKeys(config.agentKeyPath),
1649
- machineId: pairingName(config.machineId, hostname()),
1685
+ machineId,
1650
1686
  ...config.defaultPath ? { defaultPath: config.defaultPath } : {},
1651
1687
  pairings: new FilePairingStore(config.pairingPath),
1652
1688
  newSecret: generatePairingSecret,
1653
1689
  now: () => /* @__PURE__ */ new Date(),
1654
1690
  ttlMs: minutes * 6e4
1655
1691
  },
1656
- { phoneUrls: config.phoneAddresses, pwaOrigin: origin }
1692
+ { phoneUrls: config.phoneAddresses }
1657
1693
  );
1658
- return pairingInstructions(link, minutes);
1694
+ return pairingInstructions(pairing, { origin, minutes, link: linkAt !== -1 });
1659
1695
  }
1660
1696
  var WaitTimeout = class extends Error {
1661
1697
  exitCode = 124;
@@ -1675,11 +1711,16 @@ replace anything it did not make (an agent key, another tailscale serve entry).
1675
1711
 
1676
1712
  --machine-id <id> the name phones show for this machine
1677
1713
  --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)
1714
+ --default-path <dir> where a phone resolves relative paths (sent when pairing)
1679
1715
  --phone-port <n> loopback port phones reach (default 8797); every Linux user on one
1680
1716
  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)
1717
+ --tailscale funnel|serve|none how phones reach this machine: with or without Tailscale (funnel,
1718
+ public through Funnel), only with Tailscale on (serve), or through an
1719
+ address published some other way (none, with --address). Ask the
1720
+ operator. Without it, setup keeps the TARFA_PHONE_ADDRESSES or the
1721
+ serve handler for the phone port the machine already has, picks
1722
+ funnel for a machine phones reached through a relay, and otherwise
1723
+ refuses. Every run prints a reachability: line saying which
1683
1724
  --tailscale-port <n> the HTTPS port Tailscale serves it on (default 443 for serve, 10000
1684
1725
  for funnel; Funnel allows only 443, 8443 and 10000)
1685
1726
  --slug <name> serve under /<name>, so several agents share one port
@@ -1687,7 +1728,7 @@ replace anything it did not make (an agent key, another tailscale serve entry).
1687
1728
  4 in all), e.g. a reverse proxy
1688
1729
  --pwa <origin> where phones open the app (default https://tarfa.app; an existing
1689
1730
  TARFA_PWA_ORIGIN is kept unless this is given)
1690
- --pair print a pairing link and QR code at the end
1731
+ --pair print a pairing code and QR code at the end
1691
1732
  --adopt a machine whose service runs a clone's deploy/run-agent.sh: move it to
1692
1733
  the installed package (copies deploy/agent.env to ~/.tarfa/agent.env,
1693
1734
  keeps the original, repoints the service, replaces the ~/.local/bin links
@@ -1795,9 +1836,10 @@ ${SETUP_USAGE.split("\n")[0]}
1795
1836
  stateDir: stateDirLayout(home),
1796
1837
  phonePortOwner: await owner(flags.phonePort),
1797
1838
  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(),
1839
+ // Read whatever --tailscale says: they settle how the port is published when no flag does, and word
1840
+ // whether phones need Tailscale. Both answer null when Tailscale is absent or will not tell.
1841
+ tailscale: await tailscale.status(),
1842
+ serve: await tailscale.serveStatus(),
1801
1843
  cli: kind === "package" ? await cliState(home, env) : null,
1802
1844
  adoption: cloneRepo === null ? null : { repo: cloneRepo, copyEnv, agentLive: { path: agentLivePath, kind: await linkKind(agentLivePath) } }
1803
1845
  };
@@ -1813,7 +1855,7 @@ ${SETUP_USAGE.split("\n")[0]}
1813
1855
  await loadOrCreateAgentKeys(config.agentKeyPath);
1814
1856
  },
1815
1857
  writeRoster: async () => {
1816
- await mkdir4(dirname4(config.devicesPath), { recursive: true, mode: 448 });
1858
+ await mkdir3(dirname3(config.devicesPath), { recursive: true, mode: 448 });
1817
1859
  await devices2.update((roster) => roster);
1818
1860
  },
1819
1861
  writeLauncher: (path, text) => atomicWrite(path, text, 493),
@@ -1946,20 +1988,52 @@ async function apply2(argv, env, home, io) {
1946
1988
  if (!result.ok) throw new Error(result.lines.join("\n"));
1947
1989
  return result.lines.join("\n");
1948
1990
  }
1949
- function pairingInstructions(link, minutes = PAIRING_TTL_MS / 6e4) {
1950
- return [
1991
+ function pairingInstructions(pairing, options) {
1992
+ const { origin, minutes } = options;
1993
+ const text = options.link ? pairingLink(origin, pairing) : pairingCode(pairing);
1994
+ const how = options.link ? [
1951
1995
  "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).",
1996
+ "phone (Android opens the installed app; on an iPhone copy it and use Settings \u2192 Paste pairing link)."
1997
+ ] : [
1998
+ `On the phone, open the Tarfa app (install it from ${origin} first), then Settings \u2192 Add machine:`,
1999
+ "Scan QR code and scan this, or copy the code below to the phone and tap Paste pairing code."
2000
+ ];
2001
+ return [
2002
+ ...how,
1953
2003
  "",
1954
2004
  // Half-height blocks: about 60 columns, where `term` needs twice that.
1955
- encodeQR(link, "ascii", { ecc: "low" }),
1956
- link,
2005
+ encodeQR(text, "ascii", { ecc: "low" }),
2006
+ text,
1957
2007
  "",
1958
2008
  // 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.`,
2009
+ `The phone will show machine key ${deviceFingerprint(pairing.agentKey)}; check it matches.`,
1960
2010
  `It works once, for ${minutes} minute${minutes === 1 ? "" : "s"}. \`tarfa pair\` makes another.`
1961
2011
  ].join("\n");
1962
2012
  }
2013
+ var DICTATION_KEY_USAGE = [
2014
+ `usage: tarfa dictation-key [status | set ${DICTATION_PROVIDERS.join("|")} | remove ${DICTATION_PROVIDERS.join("|")}]`,
2015
+ " set reads the key on stdin, or asks for it with echo off; never as an argument",
2016
+ " every phone paired with this machine dictates into its workspaces with it, at its next connect"
2017
+ ].join("\n");
2018
+ async function dictationKey(store, [action = "status", name, ...extra], secret) {
2019
+ if (action === "status") {
2020
+ if (name !== void 0) throw new UsageError(DICTATION_KEY_USAGE);
2021
+ const loaded2 = await store.load();
2022
+ if ("problem" in loaded2) throw new Error(loaded2.problem);
2023
+ return DICTATION_PROVIDERS.map((p) => `${p} ${loaded2.keys[p] ? "set" : "not set"}`).join("\n");
2024
+ }
2025
+ if (action !== "set" && action !== "remove") throw new UsageError(DICTATION_KEY_USAGE);
2026
+ if (action === "set" && extra.length > 0) {
2027
+ 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");
2028
+ }
2029
+ const provider = name ? providerOf(name) : null;
2030
+ if (!provider || extra.length > 0) throw new UsageError(DICTATION_KEY_USAGE);
2031
+ const loaded = await store.load();
2032
+ if ("problem" in loaded) throw new Error(loaded.problem);
2033
+ const result = await changeDictationKey(store, provider, action === "remove" ? null : await secret(`${provider} key: `));
2034
+ if (!result.ok) throw new Error(result.error);
2035
+ 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`;
2036
+ }
1963
2037
  var DEVICES_USAGE = [
1964
2038
  "usage: tarfa devices [list | revoke <id|key> | forget <id|key> | enrolment open|closed]",
1965
2039
  " 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-4M2ZRGQR.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.7",
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",