@tarfa/cli 0.1.4 → 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/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,11 +19,13 @@ 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,
28
+ codexVersions,
25
29
  createWithFreshId,
26
30
  createWorkspace,
27
31
  describeStep,
@@ -36,23 +40,27 @@ import {
36
40
  isEntrypoint,
37
41
  isHiddenPath,
38
42
  isMachineUrl,
39
- isPairingPath,
40
43
  listWorkspaces,
41
44
  loadConfig,
42
45
  loadOrCreateAgentKeys,
43
46
  locateAgentEnv,
47
+ loginShell,
44
48
  loginShellResolver,
45
49
  mainCheckout,
46
50
  migrateStateDir,
51
+ mintPairing,
47
52
  packageRoot,
48
53
  packageVersion,
54
+ pairingCode,
49
55
  pairingLink,
56
+ pairingName,
50
57
  parseEnvFile,
51
- parsePairingLink,
52
58
  parseSetupFlags,
53
59
  planCli,
54
60
  planSetup,
61
+ providerOf,
55
62
  renderService,
63
+ resolveCodexHome,
56
64
  resolveStateDir,
57
65
  revokeDevice,
58
66
  sanitizeDeviceName,
@@ -63,13 +71,13 @@ import {
63
71
  unitRunsLauncher,
64
72
  withEnrolmentMode,
65
73
  withLegacyEnv
66
- } from "./chunk-OC5VX7K3.js";
74
+ } from "./chunk-UPCCR4ZA.js";
67
75
 
68
76
  // src/cli.ts
69
77
  import { randomBytes } from "node:crypto";
70
78
  import { homedir, hostname, userInfo } from "node:os";
71
- import { readFile as readFile6, mkdir as mkdir4, realpath as realpath2 } from "node:fs/promises";
72
- 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";
73
81
 
74
82
  // src/application/format-workspace-table.ts
75
83
  var TITLE_MAX = 40;
@@ -241,10 +249,9 @@ async function readOrNull(path) {
241
249
  throw err;
242
250
  }
243
251
  }
244
- function hooksFiles(home, hooksDir, port) {
252
+ function hooksFiles(home, hooksDir, port, codexHome) {
245
253
  const settingsPath = join(home, ".claude", "settings.json");
246
- const codexDir = join(home, ".codex");
247
- const codexHooksPath = join(codexDir, "hooks.json");
254
+ const codexHooksPath = join(codexHome, "hooks.json");
248
255
  async function wanted() {
249
256
  const files = [];
250
257
  for (const script of SCRIPTS) {
@@ -254,7 +261,7 @@ function hooksFiles(home, hooksDir, port) {
254
261
  const settings = await readOrNull(settingsPath);
255
262
  const merged = installHooks({ settings: settings === null ? {} : JSON.parse(settings) }, { hookCommand: HOOK_COMMAND, guardCommand: GUARD_COMMAND, port });
256
263
  files.push({ path: settingsPath, current: settings, text: JSON.stringify(merged, null, 2) });
257
- if (await fsIsDirectory(codexDir)) {
264
+ if (await fsIsDirectory(codexHome)) {
258
265
  const codex = await readOrNull(codexHooksPath);
259
266
  const codexMerged = installHooks(
260
267
  { settings: codex === null ? {} : JSON.parse(codex) },
@@ -651,29 +658,13 @@ async function firstOnPath(name, pathVar) {
651
658
  return null;
652
659
  }
653
660
 
654
- // src/infrastructure/atomic-write.ts
655
- import { chmod as chmod3, mkdir as mkdir3, rename as rename2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
656
- import { dirname as dirname3 } from "node:path";
657
- async function atomicWrite(path, text, mode) {
658
- await mkdir3(dirname3(path), { recursive: true });
659
- const temp = `${path}.tmp-${process.pid}`;
660
- try {
661
- await writeFile2(temp, text, { mode });
662
- await chmod3(temp, mode);
663
- await rename2(temp, path);
664
- } catch (err) {
665
- await rm2(temp, { force: true });
666
- throw err;
667
- }
668
- }
669
-
670
661
  // src/infrastructure/agent-log.ts
671
662
  import { open, readFile as readFile4, stat as stat3 } from "node:fs/promises";
672
663
  import { join as join5 } from "node:path";
673
664
 
674
665
  // src/infrastructure/service-install.ts
675
666
  import { execFile as execFile2 } from "node:child_process";
676
- 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";
677
668
  import { join as join4 } from "node:path";
678
669
  import { promisify as promisify2 } from "node:util";
679
670
  var exec2 = promisify2(execFile2);
@@ -703,7 +694,7 @@ async function backupFile(path) {
703
694
  if (!await exists(path)) return null;
704
695
  const copy = `${path}.bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
705
696
  await copyFile3(path, copy);
706
- await chmod4(copy, (await stat2(path)).mode & 511);
697
+ await chmod3(copy, (await stat2(path)).mode & 511);
707
698
  return copy;
708
699
  }
709
700
  var LABEL_GONE_MS = 1e4;
@@ -757,7 +748,7 @@ function serviceInstall(options) {
757
748
  return [`launchctl bootout ${domain}/${LEGACY_LABEL}`];
758
749
  },
759
750
  async removeLegacy() {
760
- await rm3(legacyPlist, { force: true });
751
+ await rm2(legacyPlist, { force: true });
761
752
  return [`removed ${legacyPlist}`];
762
753
  }
763
754
  };
@@ -809,9 +800,9 @@ function serviceInstall(options) {
809
800
  return ran.map(([command, args]) => `${command} ${args.join(" ")}`);
810
801
  },
811
802
  async removeLegacy() {
812
- await rm3(legacyUnit, { force: true });
803
+ await rm2(legacyUnit, { force: true });
813
804
  if (await exists(legacyDropIns) && !await exists(dropIns)) await cp(legacyDropIns, dropIns, { recursive: true });
814
- await rm3(legacyDropIns, { recursive: true, force: true });
805
+ await rm2(legacyDropIns, { recursive: true, force: true });
815
806
  await run4("systemctl", ["--user", "daemon-reload"]);
816
807
  return [`removed ${legacyUnit} and its drop-ins`, "systemctl --user daemon-reload"];
817
808
  }
@@ -978,29 +969,50 @@ function attempt(port, ms) {
978
969
  });
979
970
  }
980
971
 
981
- // src/cli.ts
982
- import encodeQR from "qr";
983
-
984
- // src/application/pairing-link.ts
985
- var GENERIC_MACHINE_IDS = /* @__PURE__ */ new Set(["", "local", "localhost"]);
986
- function pairingName(machineId, hostname2) {
987
- return GENERIC_MACHINE_IDS.has(machineId) ? hostname2.split(".")[0] : machineId;
988
- }
989
- async function mintPairingLink(deps, input) {
990
- if (deps.defaultPath !== void 0 && !isPairingPath(deps.defaultPath)) {
991
- 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");
992
1010
  }
993
- const secret = deps.newSecret();
994
- await deps.pairings.update((list) => addPairing(list, secret, deps.now(), deps.ttlMs));
995
- return pairingLink(input.pwaOrigin, {
996
- urls: input.phoneUrls,
997
- agentKey: deps.keys.x25519.publicKey,
998
- secret,
999
- name: deps.machineId,
1000
- ...deps.defaultPath !== void 0 ? { defaultPath: deps.defaultPath } : {}
1001
- });
1002
1011
  }
1003
1012
 
1013
+ // src/cli.ts
1014
+ import encodeQR from "qr";
1015
+
1004
1016
  // src/infrastructure/agent-service.ts
1005
1017
  import { execFile as execFile5 } from "node:child_process";
1006
1018
  import { promisify as promisify5 } from "node:util";
@@ -1078,12 +1090,13 @@ async function spawnCodex(deps, input) {
1078
1090
  firstMessage: input.brief.replace(/\n+$/, "")
1079
1091
  });
1080
1092
  if (!/^[0-9A-Za-z-]+$/.test(thread)) throw new Error(`the Codex app server named an unexpected thread id: ${thread}`);
1093
+ const codexHome = await deps.codex.home();
1081
1094
  const lockTitle = await titleLock(deps, title);
1082
1095
  const command = `codex resume ${thread}`;
1083
1096
  const id = await createWithFreshId(
1084
1097
  deps.ids,
1085
1098
  cwd,
1086
- (id2) => deps.tmux.createSession({ name: id2, cwd, command, codexThread: thread, ...parent ? { parent: parent.id } : {} })
1099
+ (id2) => deps.tmux.createSession({ name: id2, cwd, command, codexThread: thread, env: { CODEX_HOME: codexHome }, ...parent ? { parent: parent.id } : {} })
1087
1100
  );
1088
1101
  return finishLaunch(deps, id, title, lockTitle, ` (Codex thread ${thread})`);
1089
1102
  }
@@ -1133,35 +1146,6 @@ function denySettings(denyPath) {
1133
1146
  };
1134
1147
  }
1135
1148
 
1136
- // src/infrastructure/codex-versions.ts
1137
- import { execFile as execFile6 } from "node:child_process";
1138
- function loginShell(shell = process.env.SHELL || "/bin/sh") {
1139
- return (script) => new Promise((resolve2, reject) => {
1140
- execFile6(shell, ["-lc", script], { env: childEnv(), timeout: 15e3 }, (err, stdout) => err ? reject(err) : resolve2(stdout));
1141
- });
1142
- }
1143
- function codexVersions(run4 = loginShell()) {
1144
- return async () => {
1145
- const [cliOut, daemonOut] = await Promise.all([
1146
- run4("codex --version").catch((err) => {
1147
- throw new Error(`cannot run codex --version: ${err.message}`);
1148
- }),
1149
- run4("codex app-server daemon version").catch((err) => {
1150
- throw new Error(`cannot read the Codex app server's version: ${err.message}`);
1151
- })
1152
- ]);
1153
- const cli = cliOut.trim().split(/\s+/).pop() ?? "";
1154
- let appServer;
1155
- try {
1156
- appServer = JSON.parse(daemonOut).appServerVersion;
1157
- } catch {
1158
- appServer = void 0;
1159
- }
1160
- if (typeof appServer !== "string" || appServer === "") throw new Error("the Codex app server is not running");
1161
- return { cli, appServer };
1162
- };
1163
- }
1164
-
1165
1149
  // src/application/workspace-role.ts
1166
1150
  var WORKSPACE_ROLES = ["orchestrator", "child", "normal"];
1167
1151
  function workspaceRole(w, all) {
@@ -1266,7 +1250,7 @@ async function run3(argv, rawEnv, home, io = {}) {
1266
1250
  return JSON.stringify(await browseDir({ lister, hidden }, path, config.projectRoots), null, 2);
1267
1251
  }
1268
1252
  case "install-hooks": {
1269
- const hooks = hooksFiles(home, HOOKS_DIR, config.port);
1253
+ const hooks = hooksFiles(home, HOOKS_DIR, config.port, await resolveCodexHome({ ...env, ...await agentEnvValues(env, home) }, home, io.loginShell));
1270
1254
  const { codex } = await hooks.install();
1271
1255
  if (!codex) return JSON.stringify({ installed: hooks.settingsPath, scriptPath: hooks.scriptPath });
1272
1256
  return JSON.stringify({
@@ -1278,6 +1262,8 @@ async function run3(argv, rawEnv, home, io = {}) {
1278
1262
  }
1279
1263
  case "devices":
1280
1264
  return devices(new FileDeviceStore(config.devicesPath), rest);
1265
+ case "dictation-key":
1266
+ return dictationKey(new FileDictationKeysStore(config.dictationKeysPath), rest, io.secret ?? readSecret);
1281
1267
  default:
1282
1268
  throw new UsageError(`unknown command: ${command} (run \`tarfa --help\`)`);
1283
1269
  }
@@ -1286,8 +1272,9 @@ var HELP = `usage: tarfa <command> [options] (\`tarfa setup --help\`, \`tarfa
1286
1272
 
1287
1273
  Set up this machine:
1288
1274
  setup write agent.env, the agent key, the service and the hooks, then check the phone port
1289
- 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)
1290
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
1291
1278
  update install the newest @tarfa/cli and apply it (package installs)
1292
1279
  apply rewrite the launcher and service if they changed, reinstall the hooks, restart
1293
1280
  install-hooks install the Claude Code and Codex hooks that report each workspace's status
@@ -1313,6 +1300,7 @@ var COMMANDS = /* @__PURE__ */ new Set([
1313
1300
  "connect",
1314
1301
  "relay-key",
1315
1302
  "devices",
1303
+ "dictation-key",
1316
1304
  "install-hooks",
1317
1305
  "ls",
1318
1306
  "spawn",
@@ -1413,7 +1401,7 @@ async function orchestration(command, argv, env, home, io) {
1413
1401
  nonce: () => randomBytes(6).toString("hex"),
1414
1402
  sleep: io.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms))),
1415
1403
  spawnDir: joinPath(config.stateDir, "spawn"),
1416
- codex: io.codex ?? { versions: codexVersions(), createThread: (input) => new CodexAppServer().createThread(input) }
1404
+ codex: io.codex ?? codexSpawn({ ...env, ...values }, home, io)
1417
1405
  },
1418
1406
  {
1419
1407
  projectPath,
@@ -1613,7 +1601,8 @@ async function readStdin(ttyError) {
1613
1601
  return Buffer.concat(chunks).toString("utf8");
1614
1602
  }
1615
1603
  var RELAYS_GONE = "relays are gone: tarfa setup sets a machine up for phones, and tarfa pair pairs one";
1616
- 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]`;
1617
1606
  function takeOption(args, name, usage, Failure = Error) {
1618
1607
  const at = args.indexOf(name);
1619
1608
  if (at === -1) return void 0;
@@ -1633,6 +1622,15 @@ function pwaOrigin(value) {
1633
1622
  if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`--pwa must be an http(s) origin (got ${value})`);
1634
1623
  return url.origin;
1635
1624
  }
1625
+ function codexSpawn(env, home, io) {
1626
+ let codexHome;
1627
+ const resolve2 = () => codexHome ??= resolveCodexHome(env, home, io.loginShell);
1628
+ return {
1629
+ home: resolve2,
1630
+ versions: async () => codexVersions(loginShell(void 0, { CODEX_HOME: await resolve2() }))(),
1631
+ createThread: async (input) => new CodexAppServer({ codexHome: await resolve2() }).createThread(input)
1632
+ };
1633
+ }
1636
1634
  async function agentEnvValues(env, home) {
1637
1635
  try {
1638
1636
  return withLegacyEnv(parseEnvFile(await readFile6(await locateAgentEnv(env, home), "utf8")));
@@ -1646,6 +1644,8 @@ async function pair(argv, env, home) {
1646
1644
  const config = loadConfig({ ...env, ...await agentEnvValues(env, home) }, home);
1647
1645
  let origin = pwaOrigin(takeOption(args, "--pwa", PAIR_USAGE));
1648
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);
1649
1649
  const minutes = Number(ttl);
1650
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})`);
1651
1651
  if (args.length > 0) throw new Error(PAIR_USAGE);
@@ -1660,19 +1660,21 @@ async function pair(argv, env, home) {
1660
1660
  const bad = config.phoneAddresses.find((url) => !isMachineUrl(url));
1661
1661
  if (bad) throw new Error(`TARFA_PHONE_ADDRESSES holds ${bad}, which a phone refuses: wss://<host>[:port][/<slug>] (run tarfa setup)`);
1662
1662
  if (config.phoneAddresses.length > MAX_ADDRESSES) throw new Error(`TARFA_PHONE_ADDRESSES holds more than ${MAX_ADDRESSES} addresses`);
1663
- 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(
1664
1666
  {
1665
1667
  keys: await loadOrCreateAgentKeys(config.agentKeyPath),
1666
- machineId: pairingName(config.machineId, hostname()),
1668
+ machineId,
1667
1669
  ...config.defaultPath ? { defaultPath: config.defaultPath } : {},
1668
1670
  pairings: new FilePairingStore(config.pairingPath),
1669
1671
  newSecret: generatePairingSecret,
1670
1672
  now: () => /* @__PURE__ */ new Date(),
1671
1673
  ttlMs: minutes * 6e4
1672
1674
  },
1673
- { phoneUrls: config.phoneAddresses, pwaOrigin: origin }
1675
+ { phoneUrls: config.phoneAddresses }
1674
1676
  );
1675
- return pairingInstructions(link, minutes);
1677
+ return pairingInstructions(pairing, { origin, minutes, link: linkAt !== -1 });
1676
1678
  }
1677
1679
  var WaitTimeout = class extends Error {
1678
1680
  exitCode = 124;
@@ -1692,11 +1694,16 @@ replace anything it did not make (an agent key, another tailscale serve entry).
1692
1694
 
1693
1695
  --machine-id <id> the name phones show for this machine
1694
1696
  --project-roots <dir,dir> absolute folders new workspaces may be made in
1695
- --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)
1696
1698
  --phone-port <n> loopback port phones reach (default 8797); every Linux user on one
1697
1699
  machine needs their own, e.g. 8797 and 8798
1698
- --tailscale serve|funnel|none publish the phone port on the tailnet (serve), also on the public
1699
- 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
1700
1707
  --tailscale-port <n> the HTTPS port Tailscale serves it on (default 443 for serve, 10000
1701
1708
  for funnel; Funnel allows only 443, 8443 and 10000)
1702
1709
  --slug <name> serve under /<name>, so several agents share one port
@@ -1704,7 +1711,7 @@ replace anything it did not make (an agent key, another tailscale serve entry).
1704
1711
  4 in all), e.g. a reverse proxy
1705
1712
  --pwa <origin> where phones open the app (default https://tarfa.app; an existing
1706
1713
  TARFA_PWA_ORIGIN is kept unless this is given)
1707
- --pair print a pairing link and QR code at the end
1714
+ --pair print a pairing code and QR code at the end
1708
1715
  --adopt a machine whose service runs a clone's deploy/run-agent.sh: move it to
1709
1716
  the installed package (copies deploy/agent.env to ~/.tarfa/agent.env,
1710
1717
  keeps the original, repoints the service, replaces the ~/.local/bin links
@@ -1790,7 +1797,7 @@ ${SETUP_USAGE.split("\n")[0]}
1790
1797
  const config = loadConfig({ ...withLegacyEnv(parseEnvFile(envText ?? "")), TARFA_MACHINE_ID: flags.machineId }, home);
1791
1798
  const tailscale = fx.tailscale ?? tailscaleCli();
1792
1799
  const owner = fx.portOwner ?? portOwner;
1793
- const hooks = hooksFiles(home, HOOKS_DIR, config.port);
1800
+ const hooks = hooksFiles(home, HOOKS_DIR, config.port, await resolveCodexHome({ ...env, ...withLegacyEnv(parseEnvFile(envText ?? "")) }, home, io.loginShell));
1794
1801
  const devices2 = new FileDeviceStore(config.devicesPath);
1795
1802
  const hasRoster = await readFile6(config.devicesPath).then(() => true, () => false);
1796
1803
  const agentLivePath = cloneRepo === null ? "" : joinPath(cloneRepo, "deploy", "agent-live");
@@ -1812,9 +1819,10 @@ ${SETUP_USAGE.split("\n")[0]}
1812
1819
  stateDir: stateDirLayout(home),
1813
1820
  phonePortOwner: await owner(flags.phonePort),
1814
1821
  hooksPortOwner: await owner(config.port),
1815
- tailscale: flags.tailscale === "none" ? null : await tailscale.status(),
1816
- // Under --tailscale none, read only to word the operator's line: unreadable means the serve form.
1817
- 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(),
1818
1826
  cli: kind === "package" ? await cliState(home, env) : null,
1819
1827
  adoption: cloneRepo === null ? null : { repo: cloneRepo, copyEnv, agentLive: { path: agentLivePath, kind: await linkKind(agentLivePath) } }
1820
1828
  };
@@ -1830,7 +1838,7 @@ ${SETUP_USAGE.split("\n")[0]}
1830
1838
  await loadOrCreateAgentKeys(config.agentKeyPath);
1831
1839
  },
1832
1840
  writeRoster: async () => {
1833
- await mkdir4(dirname4(config.devicesPath), { recursive: true, mode: 448 });
1841
+ await mkdir3(dirname3(config.devicesPath), { recursive: true, mode: 448 });
1834
1842
  await devices2.update((roster) => roster);
1835
1843
  },
1836
1844
  writeLauncher: (path, text) => atomicWrite(path, text, 493),
@@ -1916,7 +1924,7 @@ async function apply2(argv, env, home, io) {
1916
1924
  if (envText === null) throw new Error(`failed: check: no agent config at ${envPath}: run tarfa setup`);
1917
1925
  const config = loadConfig(withLegacyEnv(parseEnvFile(envText)), home);
1918
1926
  const service = fx.service ?? serviceInstall({ home, user: userInfo().username, uid: process.getuid?.() ?? 0 });
1919
- const hooks = hooksFiles(home, HOOKS_DIR, config.port);
1927
+ const hooks = hooksFiles(home, HOOKS_DIR, config.port, await resolveCodexHome({ ...env, ...withLegacyEnv(parseEnvFile(envText)) }, home, io.loginShell));
1920
1928
  const launcherPath = joinPath(home, ".tarfa", "bin", "tarfa-agent");
1921
1929
  const log = fx.agentLog ?? agentLog({ home });
1922
1930
  const result = await runApply(
@@ -1963,20 +1971,52 @@ async function apply2(argv, env, home, io) {
1963
1971
  if (!result.ok) throw new Error(result.lines.join("\n"));
1964
1972
  return result.lines.join("\n");
1965
1973
  }
1966
- function pairingInstructions(link, minutes = PAIRING_TTL_MS / 6e4) {
1967
- 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 ? [
1968
1978
  "On the phone, in Tarfa: Settings \u2192 Scan QR code, then scan this. Or open the link below on the",
1969
- "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,
1970
1986
  "",
1971
1987
  // Half-height blocks: about 60 columns, where `term` needs twice that.
1972
- encodeQR(link, "ascii", { ecc: "low" }),
1973
- link,
1988
+ encodeQR(text, "ascii", { ecc: "low" }),
1989
+ text,
1974
1990
  "",
1975
1991
  // The phone asks before pairing and shows this fingerprint: they must match.
1976
- `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.`,
1977
1993
  `It works once, for ${minutes} minute${minutes === 1 ? "" : "s"}. \`tarfa pair\` makes another.`
1978
1994
  ].join("\n");
1979
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
+ }
1980
2020
  var DEVICES_USAGE = [
1981
2021
  "usage: tarfa devices [list | revoke <id|key> | forget <id|key> | enrolment open|closed]",
1982
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,
@@ -65,6 +69,7 @@ import {
65
69
  readHandshakeFrame,
66
70
  redeemPairing,
67
71
  relationOf,
72
+ resolveCodexHome,
68
73
  runsClaude,
69
74
  sessionOfCodexThread,
70
75
  standingOf,
@@ -73,7 +78,7 @@ import {
73
78
  systemPathResolver,
74
79
  withLegacyEnv,
75
80
  writeFileAtomic
76
- } from "./chunk-OC5VX7K3.js";
81
+ } from "./chunk-UPCCR4ZA.js";
77
82
 
78
83
  // src/serve.ts
79
84
  import { homedir as homedir2 } from "node:os";
@@ -1283,6 +1288,7 @@ var PRE_AUTH_MAX_MESSAGE_BYTES = 8 * 1024;
1283
1288
  var MAX_AUTH_ATTEMPTS = 3;
1284
1289
  var MAX_HELD_MESSAGES = 16;
1285
1290
  var PAIRING_SECRET = /^[A-Za-z0-9_-]{43}$/;
1291
+ var FEATURES = [AGENT_FEATURES.lastReply, AGENT_FEATURES.dictationKeys, AGENT_FEATURES.setDictationKey];
1286
1292
  var NoiseClientConnection = class {
1287
1293
  constructor(socket, staticPrivate, options) {
1288
1294
  this.socket = socket;
@@ -1331,7 +1337,15 @@ var NoiseClientConnection = class {
1331
1337
  return;
1332
1338
  }
1333
1339
  if (message.type === "authResult" && message.ok) {
1334
- 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 };
1335
1349
  message = accepted;
1336
1350
  if (!this.awaitingPhone) this.cancelDeadline();
1337
1351
  this.session.raiseCap(MAX_MESSAGE_BYTES);
@@ -2385,6 +2399,12 @@ var ConnectionHandler = class {
2385
2399
  this.conn.send({ type: "keyterms", keyterms: await this.deps.keyterms.list() });
2386
2400
  return;
2387
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;
2388
2408
  case "readFile": {
2389
2409
  const { binary, data, truncated } = await readFile10({ reader }, message.path, await this.allowedRoots());
2390
2410
  this.conn.send({ type: "fileContents", path: message.path, data, truncated, binary });
@@ -2678,6 +2698,17 @@ var ConnectionHandler = class {
2678
2698
  });
2679
2699
  this.conn.send({ type: "attached", workspaceId });
2680
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
+ }
2681
2712
  // A dictation lives only as long as its attachment: without one the held spaces go nowhere,
2682
2713
  // and the machine's one dictation slot would stay taken.
2683
2714
  cancelVoice(reason) {
@@ -3875,7 +3906,7 @@ function startDeviceGate(deps) {
3875
3906
  return {
3876
3907
  staticPrivate: b64url.decode(keys.x25519.privateKey),
3877
3908
  devices,
3878
- options: (extra) => ({ devices, log, refusalLog, pushBudget, agentVersion: deps.agentVersion, ...extra }),
3909
+ options: (extra) => ({ devices, log, refusalLog, pushBudget, agentVersion: deps.agentVersion, phoneAddresses: deps.phoneAddresses, ...extra }),
3879
3910
  roster: () => last,
3880
3911
  pollRoster: () => monitor.poll(),
3881
3912
  stop: () => clearInterval(poll)
@@ -4126,13 +4157,6 @@ import { join as join6 } from "node:path";
4126
4157
 
4127
4158
  // src/infrastructure/private-dir.ts
4128
4159
  import { chmod, mkdir as mkdir7, stat as stat4 } from "node:fs/promises";
4129
-
4130
- // src/application/private-files.ts
4131
- function exposedFiles(files) {
4132
- 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}\``);
4133
- }
4134
-
4135
- // src/infrastructure/private-dir.ts
4136
4160
  async function securePrivateDir(dir) {
4137
4161
  await mkdir7(dir, { recursive: true, mode: 448 });
4138
4162
  await chmod(dir, 448);
@@ -4185,6 +4209,9 @@ async function main() {
4185
4209
  const env = withLegacyEnv(process.env);
4186
4210
  const config = loadConfig(env, homedir2());
4187
4211
  for (const warning of configWarnings(process.env)) process.stderr.write(`${warning}
4212
+ `);
4213
+ const codexHome = await resolveCodexHome(env, homedir2());
4214
+ process.stdout.write(`tarfa agent: Codex home ${codexHome}
4188
4215
  `);
4189
4216
  await securePrivateDir(config.stateDir);
4190
4217
  await assertPrivateFiles([
@@ -4192,6 +4219,7 @@ async function main() {
4192
4219
  config.devicesPath,
4193
4220
  config.pairingPath,
4194
4221
  join7(config.stateDir, "token"),
4222
+ config.dictationKeysPath,
4195
4223
  // The launcher sources it: whoever can write it runs code as this user.
4196
4224
  await locateAgentEnv(env, homedir2())
4197
4225
  ]);
@@ -4234,6 +4262,8 @@ async function main() {
4234
4262
  launchers: new FileLaunchersStore(config.launchersPath),
4235
4263
  detectInstalled: (names) => detectInstalled(names, loginShellResolver()),
4236
4264
  keyterms: new FileKeytermsStore(config.keytermsPath),
4265
+ dictationKeys: new FileDictationKeysStore(config.dictationKeysPath, (problem) => process.stderr.write(`tarfa agent: no dictation keys sent: ${problem}
4266
+ `)),
4237
4267
  clientLog: new FileClientLogStore(config.clientLogPath),
4238
4268
  hookEventLog: new FileHookEventLog(config.hookEventLogPath),
4239
4269
  messaging: {
@@ -4241,7 +4271,7 @@ async function main() {
4241
4271
  delivery: new CompositeDelivery({
4242
4272
  claude: new ClaudeInboxDelivery(claudeSessions, async (id) => await tmux.firstPaneId?.(id) ?? null),
4243
4273
  pi: new PiInboxDelivery(config.inboxDir),
4244
- codex: new CodexDelivery(new CodexAppServer())
4274
+ codex: new CodexDelivery(new CodexAppServer({ codexHome }))
4245
4275
  }),
4246
4276
  log: new FileMessageLog(config.messageLogPath)
4247
4277
  },
@@ -4273,7 +4303,8 @@ async function main() {
4273
4303
  pairings: new FilePairingStore(config.pairingPath),
4274
4304
  log,
4275
4305
  rosterPollMs: ROSTER_POLL_MS,
4276
- agentVersion
4306
+ agentVersion,
4307
+ phoneAddresses: announcedAddresses(config.phoneAddresses)
4277
4308
  });
4278
4309
  try {
4279
4310
  const phones = await startPhoneListener({