@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/README.md +3 -2
- package/dist/{chunk-OC5VX7K3.js → chunk-UPCCR4ZA.js} +474 -67
- package/dist/cli.js +145 -105
- package/dist/serve.js +43 -12
- package/package.json +1 -1
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-
|
|
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
|
|
72
|
-
import { dirname as
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
803
|
+
await rm2(legacyUnit, { force: true });
|
|
813
804
|
if (await exists(legacyDropIns) && !await exists(dropIns)) await cp(legacyDropIns, dropIns, { recursive: true });
|
|
814
|
-
await
|
|
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/
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
|
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 ?? {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1675
|
+
{ phoneUrls: config.phoneAddresses }
|
|
1674
1676
|
);
|
|
1675
|
-
return pairingInstructions(
|
|
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 (
|
|
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|
|
|
1699
|
-
|
|
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
|
|
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:
|
|
1816
|
-
//
|
|
1817
|
-
|
|
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
|
|
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(
|
|
1967
|
-
|
|
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(
|
|
1973
|
-
|
|
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(
|
|
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-
|
|
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
|
|
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({
|