@tarfa/cli 0.1.2 → 0.1.3

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 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).
@@ -5416,6 +5416,9 @@ var clientMessageSchema = z6.discriminatedUnion("type", [
5416
5416
  z6.object({ type: z6.literal("voiceStart"), workspaceId: z6.string(), id: z6.string() }),
5417
5417
  z6.object({ type: z6.literal("voiceChunk"), id: z6.string(), data: z6.string() }),
5418
5418
  z6.object({ type: z6.literal("voiceEnd"), id: z6.string() }),
5419
+ // The text of Claude's last reply in a workspace, read aloud on the phone. Sent only to an agent
5420
+ // whose authResult names the `lastReply` feature.
5421
+ z6.object({ type: z6.literal("lastReply"), workspaceId: z6.string() }),
5419
5422
  z6.object({ type: z6.literal("resize"), workspaceId: z6.string(), cols: dim, rows: dim }),
5420
5423
  // `remember` is set only when the command came from the Custom field: the agent then adds it
5421
5424
  // to the machine's launcher list. Additive, so an older agent just ignores it.
@@ -5437,6 +5440,7 @@ var clientMessageSchema = z6.discriminatedUnion("type", [
5437
5440
  ]);
5438
5441
  var detachReasonSchema = z6.enum(["opened-elsewhere", "closed", "error"]);
5439
5442
  var PROTOCOL_VERSION = 1;
5443
+ var AGENT_FEATURES = { lastReply: "lastReply" };
5440
5444
  var agentMessageSchema = z6.discriminatedUnion("type", [
5441
5445
  // `credential` is what authenticated the session; `enrolled` says the device key is enrolled (written), so the
5442
5446
  // phone forgets its token and pairing secret. Both optional: an older agent sends neither.
@@ -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: z6.object({ protocol: z6.number().int(), version: z6.string().optional() }).optional().catch(void 0)
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,6 +5490,8 @@ 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() }),
@@ -6127,6 +6135,7 @@ export {
6127
6135
  pdfMediaType,
6128
6136
  audioMediaType,
6129
6137
  PROTOCOL_VERSION,
6138
+ AGENT_FEATURES,
6130
6139
  parseClientMessage,
6131
6140
  parseBrowserClientMessage,
6132
6141
  parseBrowserAgentMessage,
package/dist/cli.js CHANGED
@@ -63,7 +63,7 @@ import {
63
63
  unitRunsLauncher,
64
64
  withEnrolmentMode,
65
65
  withLegacyEnv
66
- } from "./chunk-XAYVIDCU.js";
66
+ } from "./chunk-X6FKPFHV.js";
67
67
 
68
68
  // src/cli.ts
69
69
  import { randomBytes } from "node:crypto";
@@ -1197,7 +1197,10 @@ async function cliState(home, env) {
1197
1197
  var CODEX_NEXT_STEP = "open Codex and trust the Tarfa hooks in /hooks; repeat after any hook change";
1198
1198
  async function run3(argv, rawEnv, home, io = {}) {
1199
1199
  const env = withLegacyEnv(rawEnv);
1200
+ if (argv[0] === void 0 || argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") return HELP;
1200
1201
  if (argv[0] === "--version" || argv[0] === "-v") return packageVersion();
1202
+ if (argv[0] === "serve") throw new UsageError("usage: tarfa serve (no options: runs the agent in the foreground, as the service does)");
1203
+ if (!COMMANDS.has(argv[0])) throw new UsageError(`unknown command: ${argv[0]} (run \`tarfa --help\`)`);
1201
1204
  if (argv[0] === "setup") return setup(argv.slice(1), env, home, io);
1202
1205
  if (argv[0] === "apply") return apply2(argv.slice(1), env, home, io);
1203
1206
  if (argv[0] === "update") return update(argv.slice(1), env, home, io);
@@ -1272,9 +1275,54 @@ async function run3(argv, rawEnv, home, io = {}) {
1272
1275
  case "devices":
1273
1276
  return devices(new FileDeviceStore(config.devicesPath), rest);
1274
1277
  default:
1275
- throw new Error(`unknown command: ${command ?? "(none)"}`);
1278
+ throw new UsageError(`unknown command: ${command} (run \`tarfa --help\`)`);
1276
1279
  }
1277
1280
  }
1281
+ var HELP = `usage: tarfa <command> [options] (\`tarfa setup --help\`, \`tarfa apply --help\` and \`tarfa update --help\` list their flags)
1282
+
1283
+ Set up this machine:
1284
+ setup write agent.env, the agent key, the service and the hooks, then check the phone port
1285
+ pair print a one-time pairing link and QR code for a phone
1286
+ devices list, revoke or forget paired phones; switch enrolment on, off or closed
1287
+ update install the newest @tarfa/cli and apply it (package installs)
1288
+ apply rewrite the launcher and service if they changed, reinstall the hooks, restart
1289
+ install-hooks install the Claude Code and Codex hooks that report each workspace's status
1290
+ serve run the agent in the foreground (what the service runs)
1291
+
1292
+ Workspaces on this machine:
1293
+ ls list workspaces and their state
1294
+ spawn open a workspace on a project with a brief on stdin (--child to orchestrate it)
1295
+ peek print the end of a workspace's screen
1296
+ wait wait until one of the workspaces stops working
1297
+ send send a message to another workspace
1298
+ unpark reopen parked workspaces
1299
+ rename rename a workspace (without an id: this one)
1300
+ close close workspaces
1301
+ list, create, recent, browse the agent's own views, as JSON
1302
+
1303
+ --version print the version`;
1304
+ var COMMANDS = /* @__PURE__ */ new Set([
1305
+ "setup",
1306
+ "apply",
1307
+ "update",
1308
+ "pair",
1309
+ "connect",
1310
+ "relay-key",
1311
+ "devices",
1312
+ "install-hooks",
1313
+ "ls",
1314
+ "spawn",
1315
+ "peek",
1316
+ "unpark",
1317
+ "rename",
1318
+ "send",
1319
+ "close",
1320
+ "wait",
1321
+ "list",
1322
+ "create",
1323
+ "recent",
1324
+ "browse"
1325
+ ]);
1278
1326
  var LS_USAGE = `usage: tarfa ls [--json] [--mine] [--parent <workspace id>] [--role ${WORKSPACE_ROLES.join("|")}] [--path <project dir>]`;
1279
1327
  var SPAWN_USAGE = "usage: tarfa spawn [--title <title>] [--child] [--model <model>] [--cli claude|codex|pi] <projectPath> (brief on stdin)";
1280
1328
  var SPAWN_CLIS = ["claude", "codex", "pi"];
@@ -1981,7 +2029,7 @@ function formatRoster(roster) {
1981
2029
  }
1982
2030
  if (isEntrypoint(import.meta.url, process.argv[1])) {
1983
2031
  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) => {
2032
+ const program = argv[0] === "serve" && argv.length === 1 ? import("./serve.js").then((agent) => agent.main()) : run3(argv, process.env, homedir()).then((out) => {
1985
2033
  process.stdout.write(`${out}
1986
2034
  `);
1987
2035
  });
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,
@@ -74,7 +75,7 @@ import {
74
75
  systemPathResolver,
75
76
  withLegacyEnv,
76
77
  writeFileAtomic
77
- } from "./chunk-XAYVIDCU.js";
78
+ } from "./chunk-X6FKPFHV.js";
78
79
 
79
80
  // src/serve.ts
80
81
  import { homedir as homedir2 } from "node:os";
@@ -1332,7 +1333,7 @@ var NoiseClientConnection = class {
1332
1333
  return;
1333
1334
  }
1334
1335
  if (message.type === "authResult" && message.ok) {
1335
- const accepted = { ...message, agent: { protocol: PROTOCOL_VERSION, ...this.options.agentVersion ? { version: this.options.agentVersion } : {} } };
1336
+ const accepted = { ...message, agent: { protocol: PROTOCOL_VERSION, ...this.options.agentVersion ? { version: this.options.agentVersion } : {}, features: [AGENT_FEATURES.lastReply] } };
1336
1337
  message = accepted;
1337
1338
  if (!this.awaitingPhone) this.cancelDeadline();
1338
1339
  this.session.raiseCap(MAX_MESSAGE_BYTES);
@@ -2550,6 +2551,10 @@ var ConnectionHandler = class {
2550
2551
  voice.session?.end();
2551
2552
  return;
2552
2553
  }
2554
+ case "lastReply": {
2555
+ this.conn.send({ type: "lastReply", workspaceId: message.workspaceId, text: this.deps.lastReplies?.get(message.workspaceId) ?? null });
2556
+ return;
2557
+ }
2553
2558
  case "ping": {
2554
2559
  this.conn.send({ type: "pong" });
2555
2560
  return;
@@ -2941,6 +2946,25 @@ function applyHookEvent(deps, input) {
2941
2946
  return decision.status ? { workspaceId: input.sessionName, status: decision.status } : null;
2942
2947
  }
2943
2948
 
2949
+ // src/application/last-replies.ts
2950
+ var MAX_REPLY_CHARS = 64e3;
2951
+ var TURN_ENDS = /* @__PURE__ */ new Set(["Stop", "StopFailure"]);
2952
+ var LastReplies = class {
2953
+ replies = /* @__PURE__ */ new Map();
2954
+ record(id, event, payload) {
2955
+ if (!TURN_ENDS.has(event) || typeof payload !== "object" || payload === null) return;
2956
+ const text = payload.last_assistant_message;
2957
+ if (typeof text !== "string" || text.trim() === "") return;
2958
+ this.replies.set(id, text.slice(0, MAX_REPLY_CHARS));
2959
+ }
2960
+ get(id) {
2961
+ return this.replies.get(id) ?? null;
2962
+ }
2963
+ forget(id) {
2964
+ this.replies.delete(id);
2965
+ }
2966
+ };
2967
+
2944
2968
  // src/application/send-message.ts
2945
2969
  import { randomBytes } from "node:crypto";
2946
2970
  function headerSafe(name) {
@@ -3148,6 +3172,7 @@ var WsServer = class {
3148
3172
  oscTurnEnded = /* @__PURE__ */ new Set();
3149
3173
  // Hooks posted by Codex thread, applied one after another in arrival order.
3150
3174
  threadHooks = Promise.resolve();
3175
+ lastReplies = new LastReplies();
3151
3176
  // The panes showing a question or menu at the last poll.
3152
3177
  asking = /* @__PURE__ */ new Set();
3153
3178
  // The last OSC 7501 record seen per pane, so only a change is applied (applyProgramStatus).
@@ -3178,6 +3203,7 @@ var WsServer = class {
3178
3203
  this.sentDuringTool.delete(id);
3179
3204
  this.oscTurnEnded.delete(id);
3180
3205
  this.programSeen.delete(id);
3206
+ this.lastReplies.forget(id);
3181
3207
  }
3182
3208
  // pollTitles logs and drops a workspace it saw go, but it runs only while a phone is connected
3183
3209
  // and never follows a hidden folder. This drops the rest, so an exited workspace's number is
@@ -3282,7 +3308,8 @@ var WsServer = class {
3282
3308
  forgetWorkspace: (id, closing) => this.forgetWorkspace(id, closing),
3283
3309
  dropWorkspaceState: (id) => this.dropWorkspaceState(id),
3284
3310
  broadcastClosed: (id) => this.broadcastClosed(id),
3285
- messagedLast: this.messagedLast
3311
+ messagedLast: this.messagedLast,
3312
+ lastReplies: this.lastReplies
3286
3313
  });
3287
3314
  this.handlers.add(handler);
3288
3315
  conn.onClose(() => {
@@ -3306,9 +3333,9 @@ var WsServer = class {
3306
3333
  res.writeHead(401).end();
3307
3334
  return;
3308
3335
  }
3309
- let body = "";
3310
3336
  let bytes = 0;
3311
3337
  let refused = false;
3338
+ const chunks = [];
3312
3339
  req.on("data", (chunk) => {
3313
3340
  if (refused) return;
3314
3341
  bytes += chunk.length;
@@ -3318,10 +3345,11 @@ var WsServer = class {
3318
3345
  req.destroy();
3319
3346
  return;
3320
3347
  }
3321
- body += chunk;
3348
+ chunks.push(chunk);
3322
3349
  });
3323
3350
  req.on("end", () => {
3324
3351
  if (refused) return;
3352
+ const body = Buffer.concat(chunks).toString("utf8");
3325
3353
  if (route === "/unpark") {
3326
3354
  void this.handleUnpark(body, res);
3327
3355
  return;
@@ -3374,6 +3402,7 @@ var WsServer = class {
3374
3402
  applyHook(sessionName, event, claude, agent) {
3375
3403
  const statusBefore = this.deps.status.get(sessionName);
3376
3404
  const messagedBefore = this.messagedLast.has(sessionName);
3405
+ this.lastReplies.record(sessionName, event, claude);
3377
3406
  applyHookEvent(
3378
3407
  {
3379
3408
  store: this.deps.status,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tarfa/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Tarfa agent and CLI: serves your Claude Code terminal sessions (tmux workspaces) to the Tarfa phone app.",
5
5
  "repository": {
6
6
  "type": "git",