@tarfa/cli 0.1.2 → 0.1.4
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 +37 -0
- package/dist/{chunk-XAYVIDCU.js → chunk-OC5VX7K3.js} +62 -55
- package/dist/cli.js +71 -28
- package/dist/serve.js +58 -46
- package/package.json +1 -1
- package/templates/tarfa-agent.sh +11 -2
- package/templates/tarfa-cli-wrapper.sh +11 -2
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @tarfa/cli
|
|
2
|
+
|
|
3
|
+
The Tarfa agent and command line. Tarfa is a phone app ([tarfa.app](https://tarfa.app)) that shows
|
|
4
|
+
and drives the Claude Code, Codex and shell sessions (tmux workspaces) running on your machines.
|
|
5
|
+
This package runs on each machine (macOS or Linux): it serves that machine's workspaces to your
|
|
6
|
+
phone, end to end encrypted, and gives every workspace the `tarfa` command.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Needs `tmux` and [mise](https://mise.jdx.dev). Nothing is compiled.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
mise use -g node@24 npm:@tarfa/cli@latest
|
|
14
|
+
tarfa setup --machine-id <name> --project-roots ~/workspace --tailscale serve --dry-run # see the plan
|
|
15
|
+
tarfa setup --machine-id <name> --project-roots ~/workspace --tailscale serve --pair # run it
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`tarfa setup` writes `~/.tarfa/agent.env`, the agent key, the service (a systemd user unit or a
|
|
19
|
+
launchd agent) and the Claude Code hooks, publishes the phone port with Tailscale, and prints a
|
|
20
|
+
pairing link and QR code. Open it on the phone, inside the installed app. `tarfa setup --help`
|
|
21
|
+
lists every flag.
|
|
22
|
+
|
|
23
|
+
## Day to day
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
tarfa pair # a one-time pairing link for another phone
|
|
27
|
+
tarfa devices # paired phones; revoke or forget one
|
|
28
|
+
tarfa update # install the newest release and restart the agent; workspaces survive
|
|
29
|
+
tarfa ls # this machine's workspaces
|
|
30
|
+
tarfa --help # every command
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## More
|
|
34
|
+
|
|
35
|
+
The full runbook, including publishing the phone port without Tailscale and running your own copy
|
|
36
|
+
of the app, is [docs/DEPLOY.md](https://github.com/abumalick/tarfa/blob/main/docs/DEPLOY.md).
|
|
37
|
+
Source and issues: [github.com/abumalick/tarfa](https://github.com/abumalick/tarfa).
|
|
@@ -3546,9 +3546,9 @@ function constantTimeEqual(a, b) {
|
|
|
3546
3546
|
var REFUSAL = {
|
|
3547
3547
|
notEnrolled: "device not enrolled",
|
|
3548
3548
|
revoked: "device revoked",
|
|
3549
|
-
//
|
|
3549
|
+
// Sent only by agents from before token enrolment was removed: a device whose key is enrolled
|
|
3550
|
+
// sent the token anyway, and token enrolment off (only a pairing code enrols a new phone).
|
|
3550
3551
|
tokenNotNeeded: "token not needed",
|
|
3551
|
-
// Token enrolment is off: only a pairing code enrols a new phone.
|
|
3552
3552
|
enrolmentOff: "enrolment off",
|
|
3553
3553
|
// No new phone can enrol, pairing code or not.
|
|
3554
3554
|
enrolmentClosed: "enrolment closed",
|
|
@@ -3782,12 +3782,6 @@ function planSetup(flags, state) {
|
|
|
3782
3782
|
}
|
|
3783
3783
|
const addresses = [...derived ? [derived] : [], ...flags.addresses];
|
|
3784
3784
|
if (addresses.length > MAX_ADDRESSES) return { error: `a machine takes at most ${MAX_ADDRESSES} addresses (got ${addresses.length})` };
|
|
3785
|
-
const published = addresses.length > 0 || Boolean(withLegacyEnv(parseEnvFile(state.envText ?? "")).TARFA_PHONE_ADDRESSES);
|
|
3786
|
-
if (published && state.roster === "on" && flags.tokenEnrolment === void 0) {
|
|
3787
|
-
return {
|
|
3788
|
-
error: "token enrolment is on in ~/.tarfa/devices.json, and this run gives phones an address: pass --token-enrolment off to switch it off (pairing codes only), or --token-enrolment on to keep it on deliberately"
|
|
3789
|
-
};
|
|
3790
|
-
}
|
|
3791
3785
|
if (state.legacyService) steps.push({ kind: "stop-legacy-service" });
|
|
3792
3786
|
if (state.stateDir === "legacy") steps.push({ kind: "move-state" });
|
|
3793
3787
|
const adoption = state.adoption;
|
|
@@ -3796,12 +3790,7 @@ function planSetup(flags, state) {
|
|
|
3796
3790
|
const env = planEnv(flags, state, addresses);
|
|
3797
3791
|
if (env) steps.push(env);
|
|
3798
3792
|
if (!state.hasKey) steps.push({ kind: "create-key" });
|
|
3799
|
-
if (state.roster === "absent") steps.push({ kind: "write-roster"
|
|
3800
|
-
else if (flags.tokenEnrolment && state.roster !== flags.tokenEnrolment) steps.push({ kind: "write-roster", mode: flags.tokenEnrolment });
|
|
3801
|
-
const mode = state.roster === "absent" ? flags.tokenEnrolment ?? "off" : flags.tokenEnrolment ?? state.roster;
|
|
3802
|
-
if (mode === "on" && checks.some((line) => line.includes("internet") || line.includes("Funnel allowed"))) {
|
|
3803
|
-
checks.push("warning: token enrolment is on, so whoever holds TARFA_TOKEN can enrol a phone on a public port: run `tarfa devices enrolment off` (or pass --token-enrolment off)");
|
|
3804
|
-
}
|
|
3793
|
+
if (state.roster === "absent") steps.push({ kind: "write-roster" });
|
|
3805
3794
|
const { kind: unitKind, installed } = state.service;
|
|
3806
3795
|
const adopted = unitRunsLauncher(unitKind, installed, state.launcher.path);
|
|
3807
3796
|
const serviceTarget = state.install === "package" || adopted ? { kind: "launcher", path: state.launcher.path } : { kind: "clone", repo: state.repo ?? "" };
|
|
@@ -3836,7 +3825,6 @@ function planEnv(flags, state, addresses) {
|
|
|
3836
3825
|
const before = legacy.text;
|
|
3837
3826
|
const values = parseEnvFile(before);
|
|
3838
3827
|
const wanted = [];
|
|
3839
|
-
if (!values.TARFA_TOKEN) wanted.push(["TARFA_TOKEN", state.newToken, "(generated)"]);
|
|
3840
3828
|
wanted.push(["TARFA_MACHINE_ID", flags.machineId], ["TARFA_PROJECT_ROOTS", flags.projectRoots.join(":")]);
|
|
3841
3829
|
if (flags.defaultPath) wanted.push(["TARFA_DEFAULT_PATH", flags.defaultPath]);
|
|
3842
3830
|
wanted.push(["TARFA_PHONE_PORT", String(flags.phonePort)]);
|
|
@@ -3926,7 +3914,7 @@ function describeStep(step, envPath) {
|
|
|
3926
3914
|
case "create-key":
|
|
3927
3915
|
return "create the agent key (~/.tarfa/agent-key.json)";
|
|
3928
3916
|
case "write-roster":
|
|
3929
|
-
return
|
|
3917
|
+
return "write ~/.tarfa/devices.json (phones join with a `tarfa pair` code)";
|
|
3930
3918
|
case "write-launcher":
|
|
3931
3919
|
return `write the launcher the service runs (${step.path})`;
|
|
3932
3920
|
case "install-unit":
|
|
@@ -3945,7 +3933,7 @@ function describeStep(step, envPath) {
|
|
|
3945
3933
|
return `ask the node's Tailscale operator to run: ${step.line}`;
|
|
3946
3934
|
}
|
|
3947
3935
|
}
|
|
3948
|
-
var VALUE_FLAGS = /* @__PURE__ */ new Set(["--machine-id", "--project-roots", "--default-path", "--phone-port", "--slug", "--tailscale", "--tailscale-port", "--address", "--pwa"
|
|
3936
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set(["--machine-id", "--project-roots", "--default-path", "--phone-port", "--slug", "--tailscale", "--tailscale-port", "--address", "--pwa"]);
|
|
3949
3937
|
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["--pair", "--dry-run", "--adopt"]);
|
|
3950
3938
|
function portNumber(text) {
|
|
3951
3939
|
if (!/^\d{1,5}$/.test(text)) return null;
|
|
@@ -3999,8 +3987,6 @@ function parseSetupFlags(argv) {
|
|
|
3999
3987
|
return { error: `--pwa must be the http(s) origin phones open the app at (got ${single.get("--pwa")})` };
|
|
4000
3988
|
}
|
|
4001
3989
|
}
|
|
4002
|
-
const tokenEnrolment = single.get("--token-enrolment");
|
|
4003
|
-
if (tokenEnrolment !== void 0 && tokenEnrolment !== "on" && tokenEnrolment !== "off") return { error: "--token-enrolment takes on or off" };
|
|
4004
3990
|
return {
|
|
4005
3991
|
machineId,
|
|
4006
3992
|
projectRoots,
|
|
@@ -4013,8 +3999,7 @@ function parseSetupFlags(argv) {
|
|
|
4013
3999
|
...pwa !== void 0 ? { pwa } : {},
|
|
4014
4000
|
pair: set.has("--pair"),
|
|
4015
4001
|
dryRun: set.has("--dry-run"),
|
|
4016
|
-
adopt: set.has("--adopt")
|
|
4017
|
-
...tokenEnrolment !== void 0 ? { tokenEnrolment } : {}
|
|
4002
|
+
adopt: set.has("--adopt")
|
|
4018
4003
|
};
|
|
4019
4004
|
}
|
|
4020
4005
|
|
|
@@ -4211,7 +4196,6 @@ function loadConfig(rawEnv, home) {
|
|
|
4211
4196
|
return {
|
|
4212
4197
|
stateDir,
|
|
4213
4198
|
machineId,
|
|
4214
|
-
token: env.TARFA_TOKEN,
|
|
4215
4199
|
port,
|
|
4216
4200
|
phonePort,
|
|
4217
4201
|
phoneAddresses: phoneAddresses(env),
|
|
@@ -4244,11 +4228,6 @@ function loadConfig(rawEnv, home) {
|
|
|
4244
4228
|
...env.TARFA_VOICE_DEVICE?.trim() ? { voiceDevice: env.TARFA_VOICE_DEVICE.trim() } : {}
|
|
4245
4229
|
};
|
|
4246
4230
|
}
|
|
4247
|
-
function assertServerConfig(config) {
|
|
4248
|
-
if (!config.token) {
|
|
4249
|
-
throw new Error("TARFA_TOKEN is required to run the agent server");
|
|
4250
|
-
}
|
|
4251
|
-
}
|
|
4252
4231
|
function configWarnings(rawEnv) {
|
|
4253
4232
|
const env = withLegacyEnv(rawEnv);
|
|
4254
4233
|
const warnings = legacyEnvNames(rawEnv).map(
|
|
@@ -4256,6 +4235,7 @@ function configWarnings(rawEnv) {
|
|
|
4256
4235
|
);
|
|
4257
4236
|
const relays = ["TARFA_RELAY_URLS", "TARFA_RELAY_URL"].filter((name) => env[name]?.trim());
|
|
4258
4237
|
if (relays.length > 0) warnings.push(`relays are gone; remove ${relays.join(" and ")} from agent.env`);
|
|
4238
|
+
if (env.TARFA_TOKEN?.trim()) warnings.push("TARFA_TOKEN is ignored: phones join only with a `tarfa pair` link; remove it from agent.env");
|
|
4259
4239
|
if (phoneAddresses(env).length === 0) {
|
|
4260
4240
|
warnings.push("no TARFA_PHONE_ADDRESSES: phones reach this agent only once its phone port is published (tailscale serve, a reverse proxy)");
|
|
4261
4241
|
}
|
|
@@ -4989,17 +4969,12 @@ function parseKeys(text, path) {
|
|
|
4989
4969
|
}
|
|
4990
4970
|
|
|
4991
4971
|
// src/application/device-roster.ts
|
|
4992
|
-
var EMPTY_ROSTER = { enrolment: true, devices: [], revoked: [] };
|
|
4972
|
+
var EMPTY_ROSTER = { enrolment: true, tokenEnrolment: false, devices: [], revoked: [] };
|
|
4993
4973
|
function enrolmentMode(roster) {
|
|
4994
|
-
|
|
4995
|
-
return roster.tokenEnrolment === false ? "off" : "on";
|
|
4974
|
+
return roster.enrolment ? "open" : "closed";
|
|
4996
4975
|
}
|
|
4997
4976
|
function withEnrolmentMode(roster, mode) {
|
|
4998
|
-
return { ...roster, enrolment: mode
|
|
4999
|
-
}
|
|
5000
|
-
function mayEnrol(roster, via) {
|
|
5001
|
-
const mode = enrolmentMode(roster);
|
|
5002
|
-
return mode === "on" || mode === "off" && via === "pairing";
|
|
4977
|
+
return { ...roster, enrolment: mode === "open", tokenEnrolment: false };
|
|
5003
4978
|
}
|
|
5004
4979
|
function standingOf(roster, x255192) {
|
|
5005
4980
|
if (roster.revoked.some((d) => d.x25519 === x255192)) return "revoked";
|
|
@@ -5045,7 +5020,8 @@ function forgetDevice(roster, id2) {
|
|
|
5045
5020
|
}
|
|
5046
5021
|
|
|
5047
5022
|
// src/infrastructure/file-device-store.ts
|
|
5048
|
-
import { readFile as readFile5 } from "node:fs/promises";
|
|
5023
|
+
import { mkdir as mkdir5, readFile as readFile5 } from "node:fs/promises";
|
|
5024
|
+
import { dirname as dirname6 } from "node:path";
|
|
5049
5025
|
import { z as z2 } from "zod";
|
|
5050
5026
|
var key = z2.string().regex(/^[A-Za-z0-9_-]{43}$/);
|
|
5051
5027
|
var device = z2.object({ id: z2.string(), x25519: key, ed25519: key, name: z2.string(), enrolledAt: z2.string() });
|
|
@@ -5074,9 +5050,31 @@ var FileDeviceStore = class {
|
|
|
5074
5050
|
if (!parsed.success) throw new Error(`device roster ${this.filePath} is malformed; fix or move it aside`);
|
|
5075
5051
|
return parsed.data;
|
|
5076
5052
|
}
|
|
5053
|
+
// Writes the roster once if the file lacks `tokenEnrolment: false`, missing file included, so an
|
|
5054
|
+
// agent from before token enrolment was removed, brought back, finds it off. True if it wrote.
|
|
5055
|
+
async sealTokenEnrolment() {
|
|
5056
|
+
let raw = null;
|
|
5057
|
+
try {
|
|
5058
|
+
raw = await readFile5(this.filePath, "utf8");
|
|
5059
|
+
} catch (err) {
|
|
5060
|
+
if (err.code !== "ENOENT") throw err;
|
|
5061
|
+
}
|
|
5062
|
+
if (raw !== null) {
|
|
5063
|
+
let parsed = null;
|
|
5064
|
+
try {
|
|
5065
|
+
parsed = JSON.parse(raw);
|
|
5066
|
+
} catch {
|
|
5067
|
+
return false;
|
|
5068
|
+
}
|
|
5069
|
+
if (parsed?.tokenEnrolment === false) return false;
|
|
5070
|
+
}
|
|
5071
|
+
await mkdir5(dirname6(this.filePath), { recursive: true, mode: 448 });
|
|
5072
|
+
await this.update((roster) => roster);
|
|
5073
|
+
return true;
|
|
5074
|
+
}
|
|
5077
5075
|
async update(change) {
|
|
5078
5076
|
return withFileLock(this.filePath, this.staleLockMs, async () => {
|
|
5079
|
-
const next = change(await this.read());
|
|
5077
|
+
const next = { ...change(await this.read()), tokenEnrolment: false };
|
|
5080
5078
|
await writeFileAtomic(this.filePath, `${JSON.stringify(next, null, 2)}
|
|
5081
5079
|
`);
|
|
5082
5080
|
return next;
|
|
@@ -5140,8 +5138,8 @@ var FilePairingStore = class {
|
|
|
5140
5138
|
// src/infrastructure/agent-env-file.ts
|
|
5141
5139
|
import { execFile as execFile3 } from "node:child_process";
|
|
5142
5140
|
import { existsSync as existsSync3 } from "node:fs";
|
|
5143
|
-
import { chmod, copyFile, mkdir as
|
|
5144
|
-
import { dirname as
|
|
5141
|
+
import { chmod, copyFile, mkdir as mkdir6, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
|
|
5142
|
+
import { dirname as dirname7, resolve as resolve5 } from "node:path";
|
|
5145
5143
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
5146
5144
|
import { promisify as promisify3 } from "node:util";
|
|
5147
5145
|
|
|
@@ -5161,10 +5159,10 @@ function withoutGitVars(env) {
|
|
|
5161
5159
|
}
|
|
5162
5160
|
async function mainCheckout(kind2 = installKind()) {
|
|
5163
5161
|
if (kind2 === "package") return null;
|
|
5164
|
-
const here =
|
|
5162
|
+
const here = dirname7(fileURLToPath2(import.meta.url));
|
|
5165
5163
|
try {
|
|
5166
5164
|
const { stdout } = await exec3("git", ["-C", here, "rev-parse", "--path-format=absolute", "--git-common-dir"], { env: withoutGitVars(process.env) });
|
|
5167
|
-
return
|
|
5165
|
+
return dirname7(stdout.trim());
|
|
5168
5166
|
} catch {
|
|
5169
5167
|
return resolve5(here, "..", "..", "..", "..");
|
|
5170
5168
|
}
|
|
@@ -5194,7 +5192,7 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
|
|
|
5194
5192
|
if (err.code !== "ENOENT") throw err;
|
|
5195
5193
|
backup = null;
|
|
5196
5194
|
}
|
|
5197
|
-
await
|
|
5195
|
+
await mkdir6(dirname7(path), { recursive: true, mode: 448 });
|
|
5198
5196
|
await writeFile4(path, text, { mode: 384 });
|
|
5199
5197
|
await chmod(path, 384);
|
|
5200
5198
|
return backup;
|
|
@@ -5204,7 +5202,7 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
|
|
|
5204
5202
|
|
|
5205
5203
|
// src/infrastructure/file-status-store.ts
|
|
5206
5204
|
import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
|
|
5207
|
-
import { dirname as
|
|
5205
|
+
import { dirname as dirname8 } from "node:path";
|
|
5208
5206
|
|
|
5209
5207
|
// ../contracts/src/domain/workspace-status.ts
|
|
5210
5208
|
var WORKSPACE_STATUSES = [
|
|
@@ -5390,6 +5388,7 @@ var machineStatsSchema = z6.object({
|
|
|
5390
5388
|
}).optional()
|
|
5391
5389
|
});
|
|
5392
5390
|
var clientMessageSchema = z6.discriminatedUnion("type", [
|
|
5391
|
+
// `token` carries a `tarfa pair` code for a new phone, or nothing (an enrolled key needs none).
|
|
5393
5392
|
z6.object({ type: z6.literal("auth"), token: z6.string() }),
|
|
5394
5393
|
z6.object({ type: z6.literal("list") }),
|
|
5395
5394
|
z6.object({ type: z6.literal("getRecentPaths") }),
|
|
@@ -5416,6 +5415,9 @@ var clientMessageSchema = z6.discriminatedUnion("type", [
|
|
|
5416
5415
|
z6.object({ type: z6.literal("voiceStart"), workspaceId: z6.string(), id: z6.string() }),
|
|
5417
5416
|
z6.object({ type: z6.literal("voiceChunk"), id: z6.string(), data: z6.string() }),
|
|
5418
5417
|
z6.object({ type: z6.literal("voiceEnd"), id: z6.string() }),
|
|
5418
|
+
// The text of Claude's last reply in a workspace, read aloud on the phone. Sent only to an agent
|
|
5419
|
+
// whose authResult names the `lastReply` feature.
|
|
5420
|
+
z6.object({ type: z6.literal("lastReply"), workspaceId: z6.string() }),
|
|
5419
5421
|
z6.object({ type: z6.literal("resize"), workspaceId: z6.string(), cols: dim, rows: dim }),
|
|
5420
5422
|
// `remember` is set only when the command came from the Custom field: the agent then adds it
|
|
5421
5423
|
// to the machine's launcher list. Additive, so an older agent just ignores it.
|
|
@@ -5437,9 +5439,11 @@ var clientMessageSchema = z6.discriminatedUnion("type", [
|
|
|
5437
5439
|
]);
|
|
5438
5440
|
var detachReasonSchema = z6.enum(["opened-elsewhere", "closed", "error"]);
|
|
5439
5441
|
var PROTOCOL_VERSION = 1;
|
|
5442
|
+
var AGENT_FEATURES = { lastReply: "lastReply" };
|
|
5440
5443
|
var agentMessageSchema = z6.discriminatedUnion("type", [
|
|
5441
|
-
// `credential` is what authenticated the session
|
|
5442
|
-
//
|
|
5444
|
+
// `credential` is what authenticated the session (`token` only from an agent from before token
|
|
5445
|
+
// enrolment was removed); `enrolled` says the device key is enrolled (written), so the phone
|
|
5446
|
+
// forgets its pairing secret. Both optional: an older agent sends neither.
|
|
5443
5447
|
// `pushed`: the agent served the phone at once (its hello asked) and has sent the opening replies
|
|
5444
5448
|
// (workspaces, keyterms) behind it, so the phone does not ask for them.
|
|
5445
5449
|
z6.object({
|
|
@@ -5448,7 +5452,9 @@ var agentMessageSchema = z6.discriminatedUnion("type", [
|
|
|
5448
5452
|
credential: z6.enum(["device", "pairing", "token"]).optional().catch(void 0),
|
|
5449
5453
|
enrolled: z6.boolean().optional().catch(void 0),
|
|
5450
5454
|
pushed: z6.literal(true).optional().catch(void 0),
|
|
5451
|
-
agent
|
|
5455
|
+
// `features` names what this agent answers beyond the protocol, so the PWA offers a control
|
|
5456
|
+
// only to an agent that supports it (a worktree agent shares a release's version number).
|
|
5457
|
+
agent: z6.object({ protocol: z6.number().int(), version: z6.string().optional(), features: z6.array(z6.string()).optional().catch(void 0) }).optional().catch(void 0)
|
|
5452
5458
|
}),
|
|
5453
5459
|
// `claudeVoice`: whether this machine can play a Claude voice dictation (see voiceStart).
|
|
5454
5460
|
// An older agent omits it, and the PWA then treats Claude voice as unavailable there.
|
|
@@ -5484,10 +5490,12 @@ var agentMessageSchema = z6.discriminatedUnion("type", [
|
|
|
5484
5490
|
z6.object({ type: z6.literal("attached"), workspaceId: z6.string() }),
|
|
5485
5491
|
z6.object({ type: z6.literal("output"), workspaceId: z6.string(), data: z6.string() }),
|
|
5486
5492
|
z6.object({ type: z6.literal("fileStored"), workspaceId: z6.string(), path: z6.string() }),
|
|
5493
|
+
// `text` is null when the agent holds no reply for that workspace (none since it started, or not Claude).
|
|
5494
|
+
z6.object({ type: z6.literal("lastReply"), workspaceId: z6.string(), text: z6.string().nullable() }),
|
|
5487
5495
|
z6.object({ type: z6.literal("voiceDone"), id: z6.string(), ok: z6.boolean(), error: z6.string().optional() }),
|
|
5488
5496
|
z6.object({ type: z6.literal("detached"), workspaceId: z6.string(), reason: detachReasonSchema }),
|
|
5489
5497
|
z6.object({ type: z6.literal("closed"), workspaceId: z6.string() }),
|
|
5490
|
-
// A phone was just enrolled on this machine (a pairing code
|
|
5498
|
+
// A phone was just enrolled on this machine (a pairing code), told to every other
|
|
5491
5499
|
// phone connected to it: a device nobody meant to add must not go unnoticed. An older PWA drops
|
|
5492
5500
|
// the unknown type.
|
|
5493
5501
|
z6.object({ type: z6.literal("deviceEnrolled"), id: z6.string(), name: z6.string(), at: z6.string() }),
|
|
@@ -5688,7 +5696,7 @@ var FileStatusStore = class {
|
|
|
5688
5696
|
}
|
|
5689
5697
|
}
|
|
5690
5698
|
persist() {
|
|
5691
|
-
mkdirSync(
|
|
5699
|
+
mkdirSync(dirname8(this.filePath), { recursive: true });
|
|
5692
5700
|
writeFileSync(this.filePath, JSON.stringify(Object.fromEntries(this.statuses), null, 2), "utf8");
|
|
5693
5701
|
}
|
|
5694
5702
|
};
|
|
@@ -5717,8 +5725,8 @@ function parseEntry(value) {
|
|
|
5717
5725
|
}
|
|
5718
5726
|
|
|
5719
5727
|
// src/infrastructure/file-parked-store.ts
|
|
5720
|
-
import { readFile as readFile8, writeFile as writeFile5, mkdir as
|
|
5721
|
-
import { dirname as
|
|
5728
|
+
import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir7 } from "node:fs/promises";
|
|
5729
|
+
import { dirname as dirname9 } from "node:path";
|
|
5722
5730
|
var FileParkedStore = class {
|
|
5723
5731
|
constructor(filePath) {
|
|
5724
5732
|
this.filePath = filePath;
|
|
@@ -5752,7 +5760,7 @@ var FileParkedStore = class {
|
|
|
5752
5760
|
await this.persist(rest);
|
|
5753
5761
|
}
|
|
5754
5762
|
async persist(entries) {
|
|
5755
|
-
await
|
|
5763
|
+
await mkdir7(dirname9(this.filePath), { recursive: true });
|
|
5756
5764
|
await writeFile5(this.filePath, JSON.stringify(entries, null, 2), "utf8");
|
|
5757
5765
|
}
|
|
5758
5766
|
};
|
|
@@ -5787,10 +5795,10 @@ function parseEntry2(value) {
|
|
|
5787
5795
|
}
|
|
5788
5796
|
|
|
5789
5797
|
// src/infrastructure/fs-file-writer.ts
|
|
5790
|
-
import { mkdir as
|
|
5798
|
+
import { mkdir as mkdir8, writeFile as writeFile6, access } from "node:fs/promises";
|
|
5791
5799
|
var FsFileWriter = class {
|
|
5792
5800
|
async ensureDir(dir) {
|
|
5793
|
-
await
|
|
5801
|
+
await mkdir8(dir, { recursive: true });
|
|
5794
5802
|
}
|
|
5795
5803
|
async exists(path) {
|
|
5796
5804
|
try {
|
|
@@ -6082,7 +6090,6 @@ export {
|
|
|
6082
6090
|
stateDirLayout,
|
|
6083
6091
|
migrateStateDir,
|
|
6084
6092
|
loadConfig,
|
|
6085
|
-
assertServerConfig,
|
|
6086
6093
|
configWarnings,
|
|
6087
6094
|
systemClock,
|
|
6088
6095
|
systemPathResolver,
|
|
@@ -6108,7 +6115,6 @@ export {
|
|
|
6108
6115
|
loadOrCreateAgentKeys,
|
|
6109
6116
|
enrolmentMode,
|
|
6110
6117
|
withEnrolmentMode,
|
|
6111
|
-
mayEnrol,
|
|
6112
6118
|
standingOf,
|
|
6113
6119
|
enrolDevice,
|
|
6114
6120
|
revokeDevice,
|
|
@@ -6127,6 +6133,7 @@ export {
|
|
|
6127
6133
|
pdfMediaType,
|
|
6128
6134
|
audioMediaType,
|
|
6129
6135
|
PROTOCOL_VERSION,
|
|
6136
|
+
AGENT_FEATURES,
|
|
6130
6137
|
parseClientMessage,
|
|
6131
6138
|
parseBrowserClientMessage,
|
|
6132
6139
|
parseBrowserAgentMessage,
|
package/dist/cli.js
CHANGED
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
unitRunsLauncher,
|
|
64
64
|
withEnrolmentMode,
|
|
65
65
|
withLegacyEnv
|
|
66
|
-
} from "./chunk-
|
|
66
|
+
} from "./chunk-OC5VX7K3.js";
|
|
67
67
|
|
|
68
68
|
// src/cli.ts
|
|
69
69
|
import { randomBytes } from "node:crypto";
|
|
@@ -344,7 +344,7 @@ async function apply(step, effects) {
|
|
|
344
344
|
await effects.createKey();
|
|
345
345
|
return describeStep(step, "").replace(/^create/, "created");
|
|
346
346
|
case "write-roster":
|
|
347
|
-
await effects.writeRoster(
|
|
347
|
+
await effects.writeRoster();
|
|
348
348
|
return describeStep(step, "").replace(/^write/, "wrote");
|
|
349
349
|
case "stop-legacy-service":
|
|
350
350
|
return `stopped the old agent service: ${(await effects.stopLegacyService()).join("; ")}`;
|
|
@@ -541,6 +541,10 @@ function findMise(home, pathVar, exists2 = existsSync) {
|
|
|
541
541
|
const candidates = [join2(home, ".local", "bin", "mise"), "/opt/homebrew/bin/mise", "/usr/local/bin/mise", ...(pathVar ?? "").split(delimiter).filter(Boolean).map((dir) => join2(dir, "mise"))];
|
|
542
542
|
return candidates.find(exists2) ?? null;
|
|
543
543
|
}
|
|
544
|
+
function findInstalledCli(install, exists2 = existsSync) {
|
|
545
|
+
if (!install) return null;
|
|
546
|
+
return [join2(install, "bin", "tarfa"), join2(install, "node_modules", "@tarfa", "cli", "dist", "cli.js")].find(exists2) ?? null;
|
|
547
|
+
}
|
|
544
548
|
function miseCli(home) {
|
|
545
549
|
const run4 = async (mise, args, timeout) => {
|
|
546
550
|
try {
|
|
@@ -555,9 +559,9 @@ function miseCli(home) {
|
|
|
555
559
|
async locate(mise, version) {
|
|
556
560
|
const install = await run4(mise, ["where", `npm:@tarfa/cli@${version}`], 6e4);
|
|
557
561
|
const nodeHome = await run4(mise, ["where", "node"], 6e4);
|
|
558
|
-
const cli =
|
|
562
|
+
const cli = findInstalledCli(install);
|
|
559
563
|
const node = join2(nodeHome, "bin", "node");
|
|
560
|
-
if (!
|
|
564
|
+
if (!cli) throw new Error(`mise knows no install of @tarfa/cli ${version} with a bin/tarfa or node_modules/@tarfa/cli/dist/cli.js`);
|
|
561
565
|
if (!nodeHome || !existsSync(node)) throw new Error("mise knows no node");
|
|
562
566
|
return { node, cli };
|
|
563
567
|
},
|
|
@@ -909,7 +913,7 @@ var systemDeps = {
|
|
|
909
913
|
exec: async (command, args) => (await run2(command, args, { env: childEnv() })).stdout,
|
|
910
914
|
readFile: (path) => readFile5(path, "utf8")
|
|
911
915
|
};
|
|
912
|
-
var AGENT_COMMAND = /\bsrc\/serve\.ts\b|(^|[\s/])tarfa serve(\s|$)/;
|
|
916
|
+
var AGENT_COMMAND = /\bsrc\/serve\.ts\b|(^|[\s/])tarfa serve(\s|$)|\/@tarfa\/cli\/dist\/cli\.js serve(\s|$)/;
|
|
913
917
|
function isFree(port) {
|
|
914
918
|
return new Promise((resolve2, reject) => {
|
|
915
919
|
const server = createServer();
|
|
@@ -1197,7 +1201,10 @@ async function cliState(home, env) {
|
|
|
1197
1201
|
var CODEX_NEXT_STEP = "open Codex and trust the Tarfa hooks in /hooks; repeat after any hook change";
|
|
1198
1202
|
async function run3(argv, rawEnv, home, io = {}) {
|
|
1199
1203
|
const env = withLegacyEnv(rawEnv);
|
|
1204
|
+
if (argv[0] === void 0 || argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") return HELP;
|
|
1200
1205
|
if (argv[0] === "--version" || argv[0] === "-v") return packageVersion();
|
|
1206
|
+
if (argv[0] === "serve") throw new UsageError("usage: tarfa serve (no options: runs the agent in the foreground, as the service does)");
|
|
1207
|
+
if (!COMMANDS.has(argv[0])) throw new UsageError(`unknown command: ${argv[0]} (run \`tarfa --help\`)`);
|
|
1201
1208
|
if (argv[0] === "setup") return setup(argv.slice(1), env, home, io);
|
|
1202
1209
|
if (argv[0] === "apply") return apply2(argv.slice(1), env, home, io);
|
|
1203
1210
|
if (argv[0] === "update") return update(argv.slice(1), env, home, io);
|
|
@@ -1272,9 +1279,54 @@ async function run3(argv, rawEnv, home, io = {}) {
|
|
|
1272
1279
|
case "devices":
|
|
1273
1280
|
return devices(new FileDeviceStore(config.devicesPath), rest);
|
|
1274
1281
|
default:
|
|
1275
|
-
throw new
|
|
1282
|
+
throw new UsageError(`unknown command: ${command} (run \`tarfa --help\`)`);
|
|
1276
1283
|
}
|
|
1277
1284
|
}
|
|
1285
|
+
var HELP = `usage: tarfa <command> [options] (\`tarfa setup --help\`, \`tarfa apply --help\` and \`tarfa update --help\` list their flags)
|
|
1286
|
+
|
|
1287
|
+
Set up this machine:
|
|
1288
|
+
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
|
|
1290
|
+
devices list, revoke or forget paired phones; open or close enrolment
|
|
1291
|
+
update install the newest @tarfa/cli and apply it (package installs)
|
|
1292
|
+
apply rewrite the launcher and service if they changed, reinstall the hooks, restart
|
|
1293
|
+
install-hooks install the Claude Code and Codex hooks that report each workspace's status
|
|
1294
|
+
serve run the agent in the foreground (what the service runs)
|
|
1295
|
+
|
|
1296
|
+
Workspaces on this machine:
|
|
1297
|
+
ls list workspaces and their state
|
|
1298
|
+
spawn open a workspace on a project with a brief on stdin (--child to orchestrate it)
|
|
1299
|
+
peek print the end of a workspace's screen
|
|
1300
|
+
wait wait until one of the workspaces stops working
|
|
1301
|
+
send send a message to another workspace
|
|
1302
|
+
unpark reopen parked workspaces
|
|
1303
|
+
rename rename a workspace (without an id: this one)
|
|
1304
|
+
close close workspaces
|
|
1305
|
+
list, create, recent, browse the agent's own views, as JSON
|
|
1306
|
+
|
|
1307
|
+
--version print the version`;
|
|
1308
|
+
var COMMANDS = /* @__PURE__ */ new Set([
|
|
1309
|
+
"setup",
|
|
1310
|
+
"apply",
|
|
1311
|
+
"update",
|
|
1312
|
+
"pair",
|
|
1313
|
+
"connect",
|
|
1314
|
+
"relay-key",
|
|
1315
|
+
"devices",
|
|
1316
|
+
"install-hooks",
|
|
1317
|
+
"ls",
|
|
1318
|
+
"spawn",
|
|
1319
|
+
"peek",
|
|
1320
|
+
"unpark",
|
|
1321
|
+
"rename",
|
|
1322
|
+
"send",
|
|
1323
|
+
"close",
|
|
1324
|
+
"wait",
|
|
1325
|
+
"list",
|
|
1326
|
+
"create",
|
|
1327
|
+
"recent",
|
|
1328
|
+
"browse"
|
|
1329
|
+
]);
|
|
1278
1330
|
var LS_USAGE = `usage: tarfa ls [--json] [--mine] [--parent <workspace id>] [--role ${WORKSPACE_ROLES.join("|")}] [--path <project dir>]`;
|
|
1279
1331
|
var SPAWN_USAGE = "usage: tarfa spawn [--title <title>] [--child] [--model <model>] [--cli claude|codex|pi] <projectPath> (brief on stdin)";
|
|
1280
1332
|
var SPAWN_CLIS = ["claude", "codex", "pi"];
|
|
@@ -1636,7 +1688,7 @@ agent key, a new device roster that admits pairing codes only (an existing one i
|
|
|
1636
1688
|
user unit with linger, or launchd), the Claude Code hooks, and optionally a Tailscale address for
|
|
1637
1689
|
phones. Rerunning it with the same flags, or with none, changes nothing: --machine-id, --project-roots,
|
|
1638
1690
|
--phone-port and --default-path default to what the agent.env it finds holds. It refuses rather than
|
|
1639
|
-
replace anything it did not make (an agent key,
|
|
1691
|
+
replace anything it did not make (an agent key, another tailscale serve entry).
|
|
1640
1692
|
|
|
1641
1693
|
--machine-id <id> the name phones show for this machine
|
|
1642
1694
|
--project-roots <dir,dir> absolute folders new workspaces may be made in
|
|
@@ -1652,7 +1704,6 @@ replace anything it did not make (an agent key, a token, another tailscale serve
|
|
|
1652
1704
|
4 in all), e.g. a reverse proxy
|
|
1653
1705
|
--pwa <origin> where phones open the app (default https://tarfa.app; an existing
|
|
1654
1706
|
TARFA_PWA_ORIGIN is kept unless this is given)
|
|
1655
|
-
--token-enrolment on|off let TARFA_TOKEN enrol phones (off by default on a new roster)
|
|
1656
1707
|
--pair print a pairing link and QR code at the end
|
|
1657
1708
|
--adopt a machine whose service runs a clone's deploy/run-agent.sh: move it to
|
|
1658
1709
|
the installed package (copies deploy/agent.env to ~/.tarfa/agent.env,
|
|
@@ -1674,8 +1725,7 @@ Several agents on one node (one per Linux user) share a Tailscale port, each und
|
|
|
1674
1725
|
The node has one operator: a second user runs \`--tailscale none --slug <name> --address <url>\`
|
|
1675
1726
|
with their own --phone-port, and setup prints the \`tailscale serve\` line for the operator to run:
|
|
1676
1727
|
on the port of that --address (or --tailscale-port), as \`tailscale funnel\` when the port is already
|
|
1677
|
-
on Funnel.
|
|
1678
|
-
--token-enrolment says which way. Run as another user (sudo -u), set XDG_RUNTIME_DIR=/run/user/<uid>.
|
|
1728
|
+
on Funnel. Run as another user (sudo -u), set XDG_RUNTIME_DIR=/run/user/<uid>.
|
|
1679
1729
|
|
|
1680
1730
|
Related: \`tarfa apply\` brings the service and hooks in line with the installed package and checks
|
|
1681
1731
|
the agent came back; \`tarfa update\` installs a newer release with mise, then runs its apply.
|
|
@@ -1755,7 +1805,6 @@ ${SETUP_USAGE.split("\n")[0]}
|
|
|
1755
1805
|
service: { kind: service.kind, template: await service.template(), installed: installedUnit, enabled: await service.enabled() },
|
|
1756
1806
|
envPath,
|
|
1757
1807
|
envText,
|
|
1758
|
-
newToken: randomBytes(32).toString("hex"),
|
|
1759
1808
|
hasKey: await readFile6(config.agentKeyPath).then(() => true, () => false),
|
|
1760
1809
|
roster: hasRoster ? enrolmentMode(await devices2.read()) : "absent",
|
|
1761
1810
|
hooksCurrent: await hooks.current(),
|
|
@@ -1780,9 +1829,9 @@ ${SETUP_USAGE.split("\n")[0]}
|
|
|
1780
1829
|
createKey: async () => {
|
|
1781
1830
|
await loadOrCreateAgentKeys(config.agentKeyPath);
|
|
1782
1831
|
},
|
|
1783
|
-
writeRoster: async (
|
|
1832
|
+
writeRoster: async () => {
|
|
1784
1833
|
await mkdir4(dirname4(config.devicesPath), { recursive: true, mode: 448 });
|
|
1785
|
-
await devices2.update((roster) =>
|
|
1834
|
+
await devices2.update((roster) => roster);
|
|
1786
1835
|
},
|
|
1787
1836
|
writeLauncher: (path, text) => atomicWrite(path, text, 493),
|
|
1788
1837
|
copyEnv: (from, to) => copyEnvOnce(from, to),
|
|
@@ -1929,14 +1978,12 @@ function pairingInstructions(link, minutes = PAIRING_TTL_MS / 6e4) {
|
|
|
1929
1978
|
].join("\n");
|
|
1930
1979
|
}
|
|
1931
1980
|
var DEVICES_USAGE = [
|
|
1932
|
-
"usage: tarfa devices [list | revoke <id|key> | forget <id|key> | enrolment
|
|
1933
|
-
" enrolment
|
|
1934
|
-
" enrolment off only a pairing code from `tarfa pair` enrols one",
|
|
1981
|
+
"usage: tarfa devices [list | revoke <id|key> | forget <id|key> | enrolment open|closed]",
|
|
1982
|
+
" enrolment open a pairing code from `tarfa pair` enrols a new phone",
|
|
1935
1983
|
" enrolment closed no new phone can enrol"
|
|
1936
1984
|
].join("\n");
|
|
1937
1985
|
var ENROLMENT_MODES = {
|
|
1938
|
-
|
|
1939
|
-
off: "off (only a pairing code from `tarfa pair` enrols a phone)",
|
|
1986
|
+
open: "open (a pairing code from `tarfa pair` enrols a phone)",
|
|
1940
1987
|
closed: "closed (no new phone can enrol)"
|
|
1941
1988
|
};
|
|
1942
1989
|
async function devices(store, [action = "list", arg]) {
|
|
@@ -1947,23 +1994,19 @@ async function devices(store, [action = "list", arg]) {
|
|
|
1947
1994
|
case "forget": {
|
|
1948
1995
|
if (!arg) throw new Error(DEVICES_USAGE);
|
|
1949
1996
|
const found = { device: null };
|
|
1950
|
-
|
|
1997
|
+
await store.update((roster) => {
|
|
1951
1998
|
const result = action === "revoke" ? revokeDevice(roster, arg, /* @__PURE__ */ new Date()) : forgetDevice(roster, arg);
|
|
1952
1999
|
found.device = result.device;
|
|
1953
2000
|
return result.roster;
|
|
1954
2001
|
});
|
|
1955
2002
|
const { device } = found;
|
|
1956
2003
|
if (!device) throw new Error(`no single device matches ${arg}; see tarfa devices, and name it by its full key if its id is shared`);
|
|
1957
|
-
if (action === "forget") return `forgot ${device.id} (${sanitizeDeviceName(device.name)}); it can enrol again
|
|
1958
|
-
|
|
1959
|
-
if (enrolmentMode(written) === "on") {
|
|
1960
|
-
lines.push("enrolment is on: whoever holds the token can still enrol a new key.");
|
|
1961
|
-
lines.push("Run `tarfa devices enrolment off`, or rotate the token, to stop that.");
|
|
1962
|
-
}
|
|
1963
|
-
return lines.join("\n");
|
|
2004
|
+
if (action === "forget") return `forgot ${device.id} (${sanitizeDeviceName(device.name)}); it can enrol again with a \`tarfa pair\` code`;
|
|
2005
|
+
return `revoked ${device.id} (${sanitizeDeviceName(device.name)}); this key stays refused until forgotten`;
|
|
1964
2006
|
}
|
|
1965
2007
|
case "enrolment": {
|
|
1966
|
-
if (arg
|
|
2008
|
+
if (arg === "on" || arg === "off") throw new Error(`token enrolment is gone: phones join only with a \`tarfa pair\` code. Use \`enrolment open\` or \`enrolment closed\``);
|
|
2009
|
+
if (arg !== "open" && arg !== "closed") throw new Error(DEVICES_USAGE);
|
|
1967
2010
|
await store.update((roster) => withEnrolmentMode(roster, arg));
|
|
1968
2011
|
return `enrolment: ${ENROLMENT_MODES[arg]}`;
|
|
1969
2012
|
}
|
|
@@ -1981,7 +2024,7 @@ function formatRoster(roster) {
|
|
|
1981
2024
|
}
|
|
1982
2025
|
if (isEntrypoint(import.meta.url, process.argv[1])) {
|
|
1983
2026
|
const argv = process.argv.slice(2);
|
|
1984
|
-
const program = argv[0] === "serve" ? import("./serve.js").then((agent) => agent.main()) : run3(argv, process.env, homedir()).then((out) => {
|
|
2027
|
+
const program = argv[0] === "serve" && argv.length === 1 ? import("./serve.js").then((agent) => agent.main()) : run3(argv, process.env, homedir()).then((out) => {
|
|
1985
2028
|
process.stdout.write(`${out}
|
|
1986
2029
|
`);
|
|
1987
2030
|
});
|
package/dist/serve.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
AGENT_FEATURES,
|
|
3
4
|
CodexAppServer,
|
|
4
5
|
CodexAppServerDown,
|
|
5
6
|
DEVICE_REFUSED,
|
|
@@ -24,7 +25,6 @@ import {
|
|
|
24
25
|
ShortWorkspaceIds,
|
|
25
26
|
TmuxAdapter,
|
|
26
27
|
WrongStaticKeyError,
|
|
27
|
-
assertServerConfig,
|
|
28
28
|
audioMediaType,
|
|
29
29
|
b64url,
|
|
30
30
|
browseDir,
|
|
@@ -54,7 +54,6 @@ import {
|
|
|
54
54
|
loadOrCreateAgentKeys,
|
|
55
55
|
locateAgentEnv,
|
|
56
56
|
loginShellResolver,
|
|
57
|
-
mayEnrol,
|
|
58
57
|
migrateStateDir,
|
|
59
58
|
packageVersion,
|
|
60
59
|
parseBrowserAgentMessage,
|
|
@@ -74,7 +73,7 @@ import {
|
|
|
74
73
|
systemPathResolver,
|
|
75
74
|
withLegacyEnv,
|
|
76
75
|
writeFileAtomic
|
|
77
|
-
} from "./chunk-
|
|
76
|
+
} from "./chunk-OC5VX7K3.js";
|
|
78
77
|
|
|
79
78
|
// src/serve.ts
|
|
80
79
|
import { homedir as homedir2 } from "node:os";
|
|
@@ -1323,7 +1322,7 @@ var NoiseClientConnection = class {
|
|
|
1323
1322
|
return this.keyAuth !== null && this.state.kind !== "failed" ? this.keyAuth : void 0;
|
|
1324
1323
|
}
|
|
1325
1324
|
// An accepted `auth` is answered with whether the device key is now enrolled: the phone drops
|
|
1326
|
-
// its
|
|
1325
|
+
// its pairing secret on `enrolled`, so for a new device the answer waits for the
|
|
1327
1326
|
// roster to be written.
|
|
1328
1327
|
send(message) {
|
|
1329
1328
|
if (!this.session || this.state.kind === "failed") return;
|
|
@@ -1332,7 +1331,7 @@ var NoiseClientConnection = class {
|
|
|
1332
1331
|
return;
|
|
1333
1332
|
}
|
|
1334
1333
|
if (message.type === "authResult" && message.ok) {
|
|
1335
|
-
const accepted = { ...message, agent: { protocol: PROTOCOL_VERSION, ...this.options.agentVersion ? { version: this.options.agentVersion } : {} } };
|
|
1334
|
+
const accepted = { ...message, agent: { protocol: PROTOCOL_VERSION, ...this.options.agentVersion ? { version: this.options.agentVersion } : {}, features: [AGENT_FEATURES.lastReply] } };
|
|
1336
1335
|
message = accepted;
|
|
1337
1336
|
if (!this.awaitingPhone) this.cancelDeadline();
|
|
1338
1337
|
this.session.raiseCap(MAX_MESSAGE_BYTES);
|
|
@@ -1450,7 +1449,7 @@ var NoiseClientConnection = class {
|
|
|
1450
1449
|
return;
|
|
1451
1450
|
}
|
|
1452
1451
|
this.keyAuth = standing === "enrolled" ? "device" : null;
|
|
1453
|
-
this.state = standing === "enrolled" ? { kind: "open" } : { kind: "enrolling", hello
|
|
1452
|
+
this.state = standing === "enrolled" ? { kind: "open" } : { kind: "enrolling", hello };
|
|
1454
1453
|
if (standing === "enrolled" && hello.push && this.keyAuthenticatedListeners.length > 0 && this.options.pushBudget?.take(key)) {
|
|
1455
1454
|
this.awaitingPhone = true;
|
|
1456
1455
|
this.keyAuthenticatedListeners.forEach((l) => l());
|
|
@@ -1508,16 +1507,16 @@ var NoiseClientConnection = class {
|
|
|
1508
1507
|
route(raw) {
|
|
1509
1508
|
if (this.state.kind === "failed") return;
|
|
1510
1509
|
const message = raw;
|
|
1511
|
-
|
|
1510
|
+
const pairing = message?.type === "auth" && typeof message.token === "string" && PAIRING_SECRET.test(message.token);
|
|
1511
|
+
if (this.state.kind === "enrolling" && !pairing) {
|
|
1512
|
+
this.logRefusal("no-code", `phone: device ${deviceFingerprint(this.deviceKey)} not enrolled and sent no pairing code`);
|
|
1512
1513
|
this.fail(REFUSAL.notEnrolled, DEVICE_REFUSED);
|
|
1513
1514
|
return;
|
|
1514
1515
|
}
|
|
1515
|
-
const pairing = message?.type === "auth" && typeof message.token === "string" && PAIRING_SECRET.test(message.token);
|
|
1516
1516
|
if (pairing && (this.state.kind === "enrolling" || this.keyAuth !== null)) {
|
|
1517
1517
|
this.spendPairing(message.token, raw);
|
|
1518
1518
|
return;
|
|
1519
1519
|
}
|
|
1520
|
-
if (this.refusesToken()) return;
|
|
1521
1520
|
this.listeners.forEach((l) => l(raw));
|
|
1522
1521
|
}
|
|
1523
1522
|
spendPairing(secret, auth) {
|
|
@@ -1530,12 +1529,12 @@ var NoiseClientConnection = class {
|
|
|
1530
1529
|
const held = this.held ?? [];
|
|
1531
1530
|
this.held = null;
|
|
1532
1531
|
if (this.state.kind === "failed") return;
|
|
1533
|
-
if (!redemption.redeemed && redemption.reason) {
|
|
1534
|
-
|
|
1535
|
-
this.
|
|
1532
|
+
if (!redemption.redeemed && (redemption.reason || this.keyAuth === null)) {
|
|
1533
|
+
const reason = redemption.reason ?? REFUSAL.notEnrolled;
|
|
1534
|
+
this.logRefusal(reason, `phone: device ${fingerprint} refused: ${reason}`);
|
|
1535
|
+
this.fail(reason, DEVICE_REFUSED);
|
|
1536
1536
|
return;
|
|
1537
1537
|
}
|
|
1538
|
-
if (!redemption.redeemed && this.refusesToken()) return;
|
|
1539
1538
|
this.listeners.forEach((l) => l(auth));
|
|
1540
1539
|
held.forEach((raw) => this.drain(raw));
|
|
1541
1540
|
};
|
|
@@ -1549,24 +1548,15 @@ var NoiseClientConnection = class {
|
|
|
1549
1548
|
);
|
|
1550
1549
|
}
|
|
1551
1550
|
}
|
|
1552
|
-
// An unknown device whose `auth` is about to be checked as a token, while only a pairing code
|
|
1553
|
-
// may enrol it: refused here, so the token never gets as far as the handler.
|
|
1554
|
-
refusesToken() {
|
|
1555
|
-
if (this.state.kind !== "enrolling" || this.state.tokenEnrols || this.keyAuth !== null) return false;
|
|
1556
|
-
this.logRefusal("token-off", `phone: device ${deviceFingerprint(this.deviceKey)} refused: token enrolment is off`);
|
|
1557
|
-
this.fail(REFUSAL.enrolmentOff, DEVICE_REFUSED);
|
|
1558
|
-
return true;
|
|
1559
|
-
}
|
|
1560
1551
|
// `declined`: the roster would not take the device (enrolment turned off or closed since the
|
|
1561
1552
|
// handshake, or its id taken), so the credential that authenticated this session no longer
|
|
1562
1553
|
// admits it. `failed`: the write itself failed; the session goes on unenrolled, and the phone
|
|
1563
1554
|
// keeps its credential to try again.
|
|
1564
1555
|
async enrol(hello) {
|
|
1565
1556
|
const key = this.deviceKey;
|
|
1566
|
-
const via = this.keyAuth === "pairing" ? "pairing" : "token";
|
|
1567
1557
|
this.state = { kind: "open" };
|
|
1568
1558
|
try {
|
|
1569
|
-
const enrolled = await this.options.devices.enrol({ x25519: key, ed25519: hello.sig, name: hello.name }
|
|
1559
|
+
const enrolled = await this.options.devices.enrol({ x25519: key, ed25519: hello.sig, name: hello.name });
|
|
1570
1560
|
this.options.log?.(
|
|
1571
1561
|
enrolled ? `phone: enrolled device ${deviceFingerprint(key)} "${hello.name}"` : `phone: device ${deviceFingerprint(key)} not enrolled (enrolment changed meanwhile, or its id is taken); closing its session`
|
|
1572
1562
|
);
|
|
@@ -1580,7 +1570,6 @@ var NoiseClientConnection = class {
|
|
|
1580
1570
|
try {
|
|
1581
1571
|
const { enrolment } = await this.options.devices.check(this.deviceKey);
|
|
1582
1572
|
if (enrolment === "closed") return REFUSAL.enrolmentClosed;
|
|
1583
|
-
if (enrolment === "off" && this.keyAuth !== "pairing") return REFUSAL.enrolmentOff;
|
|
1584
1573
|
} catch {
|
|
1585
1574
|
}
|
|
1586
1575
|
return REFUSAL.enrolmentRefused;
|
|
@@ -2304,17 +2293,11 @@ var ConnectionHandler = class {
|
|
|
2304
2293
|
return;
|
|
2305
2294
|
}
|
|
2306
2295
|
if (message.type === "auth") {
|
|
2307
|
-
const token = this.deps.config.token;
|
|
2308
|
-
const isToken = token !== void 0 && constantTimeEqual(message.token, token);
|
|
2309
|
-
const byKey = this.conn.keyCredential;
|
|
2310
|
-
if (byKey === "device" && isToken && this.conn.refuse) {
|
|
2311
|
-
this.conn.refuse(REFUSAL.tokenNotNeeded);
|
|
2312
|
-
return;
|
|
2313
|
-
}
|
|
2314
2296
|
if (this.authed) return;
|
|
2315
|
-
|
|
2297
|
+
const byKey = this.conn.keyCredential;
|
|
2298
|
+
if (byKey) {
|
|
2316
2299
|
this.authed = true;
|
|
2317
|
-
this.conn.send({ type: "authResult", ok: true, credential: byKey
|
|
2300
|
+
this.conn.send({ type: "authResult", ok: true, credential: byKey });
|
|
2318
2301
|
} else {
|
|
2319
2302
|
this.conn.send({ type: "authResult", ok: false });
|
|
2320
2303
|
this.conn.close();
|
|
@@ -2550,6 +2533,10 @@ var ConnectionHandler = class {
|
|
|
2550
2533
|
voice.session?.end();
|
|
2551
2534
|
return;
|
|
2552
2535
|
}
|
|
2536
|
+
case "lastReply": {
|
|
2537
|
+
this.conn.send({ type: "lastReply", workspaceId: message.workspaceId, text: this.deps.lastReplies?.get(message.workspaceId) ?? null });
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2553
2540
|
case "ping": {
|
|
2554
2541
|
this.conn.send({ type: "pong" });
|
|
2555
2542
|
return;
|
|
@@ -2941,6 +2928,25 @@ function applyHookEvent(deps, input) {
|
|
|
2941
2928
|
return decision.status ? { workspaceId: input.sessionName, status: decision.status } : null;
|
|
2942
2929
|
}
|
|
2943
2930
|
|
|
2931
|
+
// src/application/last-replies.ts
|
|
2932
|
+
var MAX_REPLY_CHARS = 64e3;
|
|
2933
|
+
var TURN_ENDS = /* @__PURE__ */ new Set(["Stop", "StopFailure"]);
|
|
2934
|
+
var LastReplies = class {
|
|
2935
|
+
replies = /* @__PURE__ */ new Map();
|
|
2936
|
+
record(id, event, payload) {
|
|
2937
|
+
if (!TURN_ENDS.has(event) || typeof payload !== "object" || payload === null) return;
|
|
2938
|
+
const text = payload.last_assistant_message;
|
|
2939
|
+
if (typeof text !== "string" || text.trim() === "") return;
|
|
2940
|
+
this.replies.set(id, text.slice(0, MAX_REPLY_CHARS));
|
|
2941
|
+
}
|
|
2942
|
+
get(id) {
|
|
2943
|
+
return this.replies.get(id) ?? null;
|
|
2944
|
+
}
|
|
2945
|
+
forget(id) {
|
|
2946
|
+
this.replies.delete(id);
|
|
2947
|
+
}
|
|
2948
|
+
};
|
|
2949
|
+
|
|
2944
2950
|
// src/application/send-message.ts
|
|
2945
2951
|
import { randomBytes } from "node:crypto";
|
|
2946
2952
|
function headerSafe(name) {
|
|
@@ -3148,6 +3154,7 @@ var WsServer = class {
|
|
|
3148
3154
|
oscTurnEnded = /* @__PURE__ */ new Set();
|
|
3149
3155
|
// Hooks posted by Codex thread, applied one after another in arrival order.
|
|
3150
3156
|
threadHooks = Promise.resolve();
|
|
3157
|
+
lastReplies = new LastReplies();
|
|
3151
3158
|
// The panes showing a question or menu at the last poll.
|
|
3152
3159
|
asking = /* @__PURE__ */ new Set();
|
|
3153
3160
|
// The last OSC 7501 record seen per pane, so only a change is applied (applyProgramStatus).
|
|
@@ -3178,6 +3185,7 @@ var WsServer = class {
|
|
|
3178
3185
|
this.sentDuringTool.delete(id);
|
|
3179
3186
|
this.oscTurnEnded.delete(id);
|
|
3180
3187
|
this.programSeen.delete(id);
|
|
3188
|
+
this.lastReplies.forget(id);
|
|
3181
3189
|
}
|
|
3182
3190
|
// pollTitles logs and drops a workspace it saw go, but it runs only while a phone is connected
|
|
3183
3191
|
// and never follows a hidden folder. This drops the rest, so an exited workspace's number is
|
|
@@ -3282,7 +3290,8 @@ var WsServer = class {
|
|
|
3282
3290
|
forgetWorkspace: (id, closing) => this.forgetWorkspace(id, closing),
|
|
3283
3291
|
dropWorkspaceState: (id) => this.dropWorkspaceState(id),
|
|
3284
3292
|
broadcastClosed: (id) => this.broadcastClosed(id),
|
|
3285
|
-
messagedLast: this.messagedLast
|
|
3293
|
+
messagedLast: this.messagedLast,
|
|
3294
|
+
lastReplies: this.lastReplies
|
|
3286
3295
|
});
|
|
3287
3296
|
this.handlers.add(handler);
|
|
3288
3297
|
conn.onClose(() => {
|
|
@@ -3306,9 +3315,9 @@ var WsServer = class {
|
|
|
3306
3315
|
res.writeHead(401).end();
|
|
3307
3316
|
return;
|
|
3308
3317
|
}
|
|
3309
|
-
let body = "";
|
|
3310
3318
|
let bytes = 0;
|
|
3311
3319
|
let refused = false;
|
|
3320
|
+
const chunks = [];
|
|
3312
3321
|
req.on("data", (chunk) => {
|
|
3313
3322
|
if (refused) return;
|
|
3314
3323
|
bytes += chunk.length;
|
|
@@ -3318,10 +3327,11 @@ var WsServer = class {
|
|
|
3318
3327
|
req.destroy();
|
|
3319
3328
|
return;
|
|
3320
3329
|
}
|
|
3321
|
-
|
|
3330
|
+
chunks.push(chunk);
|
|
3322
3331
|
});
|
|
3323
3332
|
req.on("end", () => {
|
|
3324
3333
|
if (refused) return;
|
|
3334
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
3325
3335
|
if (route === "/unpark") {
|
|
3326
3336
|
void this.handleUnpark(body, res);
|
|
3327
3337
|
return;
|
|
@@ -3374,6 +3384,7 @@ var WsServer = class {
|
|
|
3374
3384
|
applyHook(sessionName, event, claude, agent) {
|
|
3375
3385
|
const statusBefore = this.deps.status.get(sessionName);
|
|
3376
3386
|
const messagedBefore = this.messagedLast.has(sessionName);
|
|
3387
|
+
this.lastReplies.record(sessionName, event, claude);
|
|
3377
3388
|
applyHookEvent(
|
|
3378
3389
|
{
|
|
3379
3390
|
store: this.deps.status,
|
|
@@ -3763,23 +3774,23 @@ var RosterDevices = class {
|
|
|
3763
3774
|
const roster = await this.deps.store.read();
|
|
3764
3775
|
return { standing: standingOf(roster, x25519), enrolment: enrolmentMode(roster) };
|
|
3765
3776
|
}
|
|
3766
|
-
async enrol(device
|
|
3777
|
+
async enrol(device) {
|
|
3767
3778
|
let known = true;
|
|
3768
3779
|
const roster = await this.deps.store.update((current) => {
|
|
3769
3780
|
known = standingOf(current, device.x25519) !== "unknown";
|
|
3770
|
-
return
|
|
3781
|
+
return current.enrolment ? enrolDevice(current, device, this.deps.now()) : current;
|
|
3771
3782
|
});
|
|
3772
3783
|
this.deps.onChange();
|
|
3773
3784
|
const entry = known ? void 0 : roster.devices.find((d) => d.x25519 === device.x25519);
|
|
3774
3785
|
try {
|
|
3775
|
-
if (entry) this.deps.onEnrolled?.({ id: entry.id, name: entry.name, at: entry.enrolledAt, x25519: entry.x25519 }
|
|
3786
|
+
if (entry) this.deps.onEnrolled?.({ id: entry.id, name: entry.name, at: entry.enrolledAt, x25519: entry.x25519 });
|
|
3776
3787
|
} catch {
|
|
3777
3788
|
}
|
|
3778
3789
|
return standingOf(roster, device.x25519) === "enrolled";
|
|
3779
3790
|
}
|
|
3780
3791
|
// Spends a pairing secret for the device (X25519 key) about to be enrolled, binding it to that
|
|
3781
3792
|
// device. Atomic across processes, so two phones racing with one code cannot both get in. A
|
|
3782
|
-
// secret this agent never issued (or has forgotten) gives no reason
|
|
3793
|
+
// secret this agent never issued (or has forgotten) gives no reason.
|
|
3783
3794
|
async redeemPairing(secret, device) {
|
|
3784
3795
|
if (!(await this.deps.store.read()).enrolment) return { redeemed: false, reason: REFUSAL.enrolmentClosed };
|
|
3785
3796
|
const outcome = await this.spend(secret, device);
|
|
@@ -3841,8 +3852,8 @@ function startDeviceGate(deps) {
|
|
|
3841
3852
|
pairings: deps.pairings,
|
|
3842
3853
|
now: () => /* @__PURE__ */ new Date(),
|
|
3843
3854
|
onChange: wrote,
|
|
3844
|
-
onEnrolled: (device
|
|
3845
|
-
log(`device enrolled: ${device.id} "${device.name}" (
|
|
3855
|
+
onEnrolled: (device) => {
|
|
3856
|
+
log(`device enrolled: ${device.id} "${device.name}" (pairing code)`);
|
|
3846
3857
|
server.broadcastDeviceEnrolled(device);
|
|
3847
3858
|
}
|
|
3848
3859
|
});
|
|
@@ -4012,7 +4023,7 @@ function limitedDevices(devices, limiter, roster) {
|
|
|
4012
4023
|
if (known && standingOf(known, x25519) === "enrolled") return { standing: "enrolled", enrolment: enrolmentMode(known) };
|
|
4013
4024
|
return limited(limiter, () => devices.check(x25519));
|
|
4014
4025
|
},
|
|
4015
|
-
enrol: (device
|
|
4026
|
+
enrol: (device) => devices.enrol(device),
|
|
4016
4027
|
redeemPairing: (secret, device) => limited(limiter, () => devices.redeemPairing(secret, device)),
|
|
4017
4028
|
burnPairing: (secret, device) => devices.burnPairing(secret, device)
|
|
4018
4029
|
};
|
|
@@ -4173,7 +4184,6 @@ async function main() {
|
|
|
4173
4184
|
}
|
|
4174
4185
|
const env = withLegacyEnv(process.env);
|
|
4175
4186
|
const config = loadConfig(env, homedir2());
|
|
4176
|
-
assertServerConfig(config);
|
|
4177
4187
|
for (const warning of configWarnings(process.env)) process.stderr.write(`${warning}
|
|
4178
4188
|
`);
|
|
4179
4189
|
await securePrivateDir(config.stateDir);
|
|
@@ -4182,7 +4192,7 @@ async function main() {
|
|
|
4182
4192
|
config.devicesPath,
|
|
4183
4193
|
config.pairingPath,
|
|
4184
4194
|
join7(config.stateDir, "token"),
|
|
4185
|
-
//
|
|
4195
|
+
// The launcher sources it: whoever can write it runs code as this user.
|
|
4186
4196
|
await locateAgentEnv(env, homedir2())
|
|
4187
4197
|
]);
|
|
4188
4198
|
const hookToken = await writeHookToken(config.stateDir);
|
|
@@ -4254,10 +4264,12 @@ async function main() {
|
|
|
4254
4264
|
const keys = await loadOrCreateAgentKeys(config.agentKeyPath);
|
|
4255
4265
|
const log = (message) => process.stdout.write(`${message}
|
|
4256
4266
|
`);
|
|
4267
|
+
const devices = new FileDeviceStore(config.devicesPath);
|
|
4268
|
+
if (await devices.sealTokenEnrolment()) log("tarfa agent: wrote token enrolment off into devices.json (phones join with `tarfa pair` only)");
|
|
4257
4269
|
const gate = startDeviceGate({
|
|
4258
4270
|
keys,
|
|
4259
4271
|
server,
|
|
4260
|
-
store:
|
|
4272
|
+
store: devices,
|
|
4261
4273
|
pairings: new FilePairingStore(config.pairingPath),
|
|
4262
4274
|
log,
|
|
4263
4275
|
rosterPollMs: ROSTER_POLL_MS,
|
package/package.json
CHANGED
package/templates/tarfa-agent.sh
CHANGED
|
@@ -40,8 +40,17 @@ for mise_bin in "$home/.local/bin/mise" /opt/homebrew/bin/mise /usr/local/bin/mi
|
|
|
40
40
|
if [ -x "$mise_bin" ]; then
|
|
41
41
|
install="$("$mise_bin" --cd "$home" where npm:@tarfa/cli 2>/dev/null || true)"
|
|
42
42
|
node_home="$("$mise_bin" --cd "$home" where node 2>/dev/null || true)"
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# npm links the CLI into <install>/bin; aube (a newer mise's npm backend) leaves it in the
|
|
44
|
+
# package under node_modules, whose .bin/tarfa is a shell script node cannot run.
|
|
45
|
+
cli=""
|
|
46
|
+
for candidate in "$install/bin/tarfa" "$install/node_modules/@tarfa/cli/dist/cli.js"; do
|
|
47
|
+
if [ -n "$install" ] && [ -f "$candidate" ]; then
|
|
48
|
+
cli="$candidate"
|
|
49
|
+
break
|
|
50
|
+
fi
|
|
51
|
+
done
|
|
52
|
+
if [ -n "$cli" ] && [ -n "$node_home" ] && [ -x "$node_home/bin/node" ]; then
|
|
53
|
+
exec "$node_home/bin/node" "$cli" serve
|
|
45
54
|
fi
|
|
46
55
|
exec "$mise_bin" exec -- tarfa serve
|
|
47
56
|
fi
|
|
@@ -22,8 +22,17 @@ fi
|
|
|
22
22
|
if [ -n "$mise_bin" ]; then
|
|
23
23
|
install="$("$mise_bin" --cd "$HOME" where npm:@tarfa/cli 2>/dev/null || true)"
|
|
24
24
|
node_home="$("$mise_bin" --cd "$HOME" where node 2>/dev/null || true)"
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
# npm links the CLI into <install>/bin; aube (a newer mise's npm backend) leaves it in the
|
|
26
|
+
# package under node_modules, whose .bin/tarfa is a shell script node cannot run.
|
|
27
|
+
cli=""
|
|
28
|
+
for candidate in "$install/bin/tarfa" "$install/node_modules/@tarfa/cli/dist/cli.js"; do
|
|
29
|
+
if [ -n "$install" ] && [ -f "$candidate" ]; then
|
|
30
|
+
cli="$candidate"
|
|
31
|
+
break
|
|
32
|
+
fi
|
|
33
|
+
done
|
|
34
|
+
if [ -n "$cli" ] && [ -n "$node_home" ] && [ -x "$node_home/bin/node" ]; then
|
|
35
|
+
exec "$node_home/bin/node" "$cli" "$@"
|
|
27
36
|
fi
|
|
28
37
|
fi
|
|
29
38
|
|