@tarfa/cli 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  planCli,
60
60
  planSetup,
61
61
  providerOf,
62
+ readAgentPublicKey,
62
63
  renderService,
63
64
  resolveCodexHome,
64
65
  resolveStateDir,
@@ -70,8 +71,9 @@ import {
70
71
  systemPathResolver,
71
72
  unitRunsLauncher,
72
73
  withEnrolmentMode,
73
- withLegacyEnv
74
- } from "./chunk-UPCCR4ZA.js";
74
+ withLegacyEnv,
75
+ workspaceLink
76
+ } from "./chunk-BHXNKH3U.js";
75
77
 
76
78
  // src/cli.ts
77
79
  import { randomBytes } from "node:crypto";
@@ -1196,7 +1198,7 @@ async function run3(argv, rawEnv, home, io = {}) {
1196
1198
  throw new UsageError(RELAYS_GONE);
1197
1199
  }
1198
1200
  if (argv[0] === "pair") return pair(argv.slice(1), env, home);
1199
- if (argv[0] === "ls" || argv[0] === "spawn" || argv[0] === "peek" || argv[0] === "unpark" || argv[0] === "rename" || argv[0] === "send" || argv[0] === "close" || argv[0] === "wait") {
1201
+ if (argv[0] === "ls" || argv[0] === "spawn" || argv[0] === "peek" || argv[0] === "unpark" || argv[0] === "rename" || argv[0] === "link" || argv[0] === "send" || argv[0] === "close" || argv[0] === "wait") {
1200
1202
  return orchestration(argv[0], argv.slice(1), env, home, io);
1201
1203
  }
1202
1204
  const config = loadConfig({ ...env, ...await agentEnvValues(env, home) }, home);
@@ -1288,6 +1290,7 @@ Workspaces on this machine:
1288
1290
  send send a message to another workspace
1289
1291
  unpark reopen parked workspaces
1290
1292
  rename rename a workspace (without an id: this one)
1293
+ link print a link that opens a workspace in the app (without an id: this one)
1291
1294
  close close workspaces
1292
1295
  list, create, recent, browse the agent's own views, as JSON
1293
1296
 
@@ -1307,6 +1310,7 @@ var COMMANDS = /* @__PURE__ */ new Set([
1307
1310
  "peek",
1308
1311
  "unpark",
1309
1312
  "rename",
1313
+ "link",
1310
1314
  "send",
1311
1315
  "close",
1312
1316
  "wait",
@@ -1321,6 +1325,7 @@ var SPAWN_CLIS = ["claude", "codex", "pi"];
1321
1325
  var PEEK_USAGE = "usage: tarfa peek <workspace id> [-n <lines>]";
1322
1326
  var UNPARK_USAGE = "usage: tarfa unpark <workspace id>...";
1323
1327
  var RENAME_USAGE = "usage: tarfa rename [<workspace id>] <title> (without an id: the workspace this runs in)";
1328
+ var LINK_USAGE = "usage: tarfa link [<workspace id>] [--pwa <PWA origin>] (without an id: the workspace this runs in)";
1324
1329
  var SEND_USAGE = 'usage: tarfa send <workspace id> "<message>" (or the message on stdin)';
1325
1330
  var CLOSE_USAGE = "usage: tarfa close <workspace id>...";
1326
1331
  var WAIT_USAGE = "usage: tarfa wait <workspace id>... [--timeout <seconds, at least 10>]";
@@ -1483,6 +1488,18 @@ async function orchestration(command, argv, env, home, io) {
1483
1488
  const done = `${id}: renamed to "${title.trim()}"`;
1484
1489
  return locked ? done : `${done} (this tmux cannot lock a title, so Claude may rename it again)`;
1485
1490
  }
1491
+ case "link": {
1492
+ const origin = pwaOrigin(takeOption(args, "--pwa", LINK_USAGE));
1493
+ if (args.length > 1 || args.some((a) => a.startsWith("-"))) throw new Error(LINK_USAGE);
1494
+ const id = args[0] ?? await callerWorkspace(env, tmux, config.sessionPrefix);
1495
+ const workspace = (await listAll()).find((w) => w.id === id);
1496
+ if (!workspace) throw new Error(`no workspace ${id} (see tarfa ls)`);
1497
+ if (isHiddenPath(workspace.projectPath, await new FileHiddenFoldersStore(config.hiddenFoldersPath).list())) {
1498
+ throw new Error(`${id} is in a hidden folder, which phones do not list`);
1499
+ }
1500
+ const agentKey = await readAgentPublicKey(config.agentKeyPath);
1501
+ return workspaceLink(origin ?? pwaOrigin(config.pwaOrigin), { agentKey, workspaceId: id });
1502
+ }
1486
1503
  case "close": {
1487
1504
  if (args.length === 0 || args.some((id) => id.startsWith("-"))) throw new UsageError(CLOSE_USAGE);
1488
1505
  const self = await callerWorkspace(env, tmux, config.sessionPrefix).catch(() => void 0);
package/dist/serve.js CHANGED
@@ -78,7 +78,7 @@ import {
78
78
  systemPathResolver,
79
79
  withLegacyEnv,
80
80
  writeFileAtomic
81
- } from "./chunk-UPCCR4ZA.js";
81
+ } from "./chunk-BHXNKH3U.js";
82
82
 
83
83
  // src/serve.ts
84
84
  import { homedir as homedir2 } from "node:os";
@@ -91,6 +91,21 @@ var TAIL_MS = 500;
91
91
  var IDLE_MS = 5e3;
92
92
  var MAX_MS = 5 * 6e4;
93
93
  var DRAIN_MS = LEAD_MS + 1e4;
94
+ var CANCEL_POLL_MS = 50;
95
+ var CANCEL_WAIT_MS = 1500;
96
+ var NOT_LISTENING = "Dictation not cancelled: Claude was not listening";
97
+ var CANCEL_KEY = "";
98
+ function claudeVoiceListening(screen) {
99
+ const lines = screen.split("\n");
100
+ let prompt = -1;
101
+ for (let i = lines.length - 1; i >= 0; i--) {
102
+ if (lines[i].startsWith("\u276F")) {
103
+ prompt = i;
104
+ break;
105
+ }
106
+ }
107
+ return prompt >= 2 && lines[prompt - 2].trim() === "listening\u2026";
108
+ }
94
109
  function voiceSettingsProblem(settings) {
95
110
  const s = typeof settings === "object" && settings !== null ? settings : {};
96
111
  const enabled = s.voice?.enabled ?? s.voiceEnabled;
@@ -102,10 +117,11 @@ function voiceSettingsProblem(settings) {
102
117
  }
103
118
  var errorText = (e) => e instanceof Error ? e.message : String(e);
104
119
  var Dictation = class {
105
- constructor(deps, pressSpace, release) {
120
+ constructor(deps, pressSpace, release, pane) {
106
121
  this.deps = deps;
107
122
  this.pressSpace = pressSpace;
108
123
  this.release = release;
124
+ this.pane = pane;
109
125
  this.done = new Promise((resolve) => this.resolve = resolve);
110
126
  this.max = setTimeout(() => this.abort("Dictation stopped: it ran past 5 minutes"), MAX_MS);
111
127
  this.touch();
@@ -114,11 +130,13 @@ var Dictation = class {
114
130
  deps;
115
131
  pressSpace;
116
132
  release;
133
+ pane;
117
134
  done;
118
135
  resolve;
119
136
  queue = [];
120
137
  stream = null;
121
138
  ended = false;
139
+ cancelling = false;
122
140
  finished = false;
123
141
  hold;
124
142
  lead;
@@ -132,7 +150,7 @@ var Dictation = class {
132
150
  else this.queue.push(pcm);
133
151
  }
134
152
  end() {
135
- if (this.finished || this.ended) return;
153
+ if (this.finished || this.ended || this.cancelling) return;
136
154
  this.ended = true;
137
155
  clearTimeout(this.idle);
138
156
  if (this.stream) this.drain(this.stream);
@@ -141,9 +159,39 @@ var Dictation = class {
141
159
  this.stream?.abort();
142
160
  this.finish(reason);
143
161
  }
162
+ cancel() {
163
+ if (this.finished || this.ended || this.cancelling) return;
164
+ this.cancelling = true;
165
+ clearTimeout(this.lead);
166
+ clearTimeout(this.idle);
167
+ this.queue = [];
168
+ this.stream?.abort();
169
+ this.stream = null;
170
+ if (!this.hold) return this.finish(null);
171
+ void this.cancelOnceListening(Date.now());
172
+ }
173
+ // The hold goes on until Claude shows it records, so the key lands while it still listens.
174
+ async cancelOnceListening(since) {
175
+ const pane = this.pane;
176
+ if (!pane) return this.finish(NOT_LISTENING);
177
+ while (!this.finished) {
178
+ const listening = await pane.listening().catch(() => false);
179
+ if (this.finished) return;
180
+ if (listening) {
181
+ clearInterval(this.hold);
182
+ try {
183
+ pane.pressCancel();
184
+ } catch {
185
+ }
186
+ return this.finish(null);
187
+ }
188
+ if (Date.now() - since >= CANCEL_WAIT_MS) return this.finish(NOT_LISTENING);
189
+ await new Promise((resolve) => setTimeout(resolve, CANCEL_POLL_MS));
190
+ }
191
+ }
144
192
  async begin() {
145
193
  const problem = voiceSettingsProblem(await this.deps.settings.read().catch(() => null));
146
- if (this.finished) return;
194
+ if (this.finished || this.cancelling) return;
147
195
  if (problem) return this.finish(problem);
148
196
  const press = () => {
149
197
  try {
@@ -156,7 +204,7 @@ var Dictation = class {
156
204
  this.lead = setTimeout(() => this.play(), LEAD_MS);
157
205
  }
158
206
  play() {
159
- if (this.finished) return;
207
+ if (this.finished || this.cancelling) return;
160
208
  let stream;
161
209
  try {
162
210
  stream = this.deps.player.open();
@@ -164,7 +212,9 @@ var Dictation = class {
164
212
  return this.finish(errorText(e));
165
213
  }
166
214
  this.stream = stream;
167
- stream.played.catch((e) => this.finish(errorText(e)));
215
+ stream.played.catch((e) => {
216
+ if (!this.cancelling) this.finish(errorText(e));
217
+ });
168
218
  for (const pcm of this.queue.splice(0)) stream.write(pcm);
169
219
  if (this.ended) this.drain(stream);
170
220
  }
@@ -201,13 +251,13 @@ var ClaudeVoice = class {
201
251
  available() {
202
252
  return this.deps.player.available();
203
253
  }
204
- start(pressSpace) {
254
+ start(pressSpace, pane) {
205
255
  if (this.busy) {
206
256
  const done = Promise.resolve("Claude voice is already in use on this machine");
207
- return { write: () => void 0, end: () => void 0, abort: () => void 0, done };
257
+ return { write: () => void 0, end: () => void 0, abort: () => void 0, cancel: () => void 0, done };
208
258
  }
209
259
  this.busy = true;
210
- return new Dictation(this.deps, pressSpace, () => this.busy = false);
260
+ return new Dictation(this.deps, pressSpace, () => this.busy = false, pane);
211
261
  }
212
262
  };
213
263
 
@@ -1288,7 +1338,7 @@ var PRE_AUTH_MAX_MESSAGE_BYTES = 8 * 1024;
1288
1338
  var MAX_AUTH_ATTEMPTS = 3;
1289
1339
  var MAX_HELD_MESSAGES = 16;
1290
1340
  var PAIRING_SECRET = /^[A-Za-z0-9_-]{43}$/;
1291
- var FEATURES = [AGENT_FEATURES.lastReply, AGENT_FEATURES.dictationKeys, AGENT_FEATURES.setDictationKey];
1341
+ var FEATURES = [AGENT_FEATURES.lastReply, AGENT_FEATURES.dictationKeys, AGENT_FEATURES.setDictationKey, AGENT_FEATURES.voiceCancel];
1292
1342
  var NoiseClientConnection = class {
1293
1343
  constructor(socket, staticPrivate, options) {
1294
1344
  this.socket = socket;
@@ -2517,7 +2567,7 @@ var ConnectionHandler = class {
2517
2567
  case "voiceStart": {
2518
2568
  const { workspaceId, id } = message;
2519
2569
  this.cancelVoice("Superseded by a newer dictation");
2520
- const voice = { id, workspaceId, session: null, queue: [], ended: false, cancelled: null };
2570
+ const voice = { id, workspaceId, session: null, queue: [], ended: false, cancelled: null, discarded: false };
2521
2571
  this.voice = voice;
2522
2572
  const reply = (error) => {
2523
2573
  if (this.voice === voice) this.voice = null;
@@ -2528,9 +2578,15 @@ var ConnectionHandler = class {
2528
2578
  const tmuxSession = (await this.deps.tmux.listSessions(this.deps.config.sessionPrefix)).find((s) => s.name === workspaceId);
2529
2579
  if (!tmuxSession || !runsClaude(tmuxSession.command)) return reply("Claude voice works only in a Claude Code workspace");
2530
2580
  if (tmuxSession.program?.state === "blocked") return reply("Answer Claude\u2019s question first, then dictate");
2581
+ if (voice.discarded) return reply(null);
2531
2582
  if (voice.cancelled) return reply(voice.cancelled);
2532
- const session = this.deps.claudeVoice.start(() => {
2533
- if (this.attachment?.workspaceId === workspaceId) this.attachment.session.write(" ");
2583
+ const typeInto = (data) => {
2584
+ if (this.attachment?.workspaceId === workspaceId) this.attachment.session.write(data);
2585
+ };
2586
+ const tmux2 = this.deps.tmux;
2587
+ const session = this.deps.claudeVoice.start(() => typeInto(" "), {
2588
+ listening: async () => !!tmux2.capturePane && claudeVoiceListening(await tmux2.capturePane(workspaceId, 0)),
2589
+ pressCancel: () => typeInto(CANCEL_KEY)
2534
2590
  });
2535
2591
  voice.session = session;
2536
2592
  for (const pcm of voice.queue.splice(0)) session.write(pcm);
@@ -2546,9 +2602,17 @@ var ConnectionHandler = class {
2546
2602
  else voice.queue.push(pcm);
2547
2603
  return;
2548
2604
  }
2605
+ case "voiceCancel": {
2606
+ const voice = this.voice;
2607
+ if (voice?.id !== message.id || voice.ended) return;
2608
+ voice.ended = true;
2609
+ voice.discarded = true;
2610
+ voice.session?.cancel();
2611
+ return;
2612
+ }
2549
2613
  case "voiceEnd": {
2550
2614
  const voice = this.voice;
2551
- if (voice?.id !== message.id) return;
2615
+ if (voice?.id !== message.id || voice.discarded) return;
2552
2616
  voice.ended = true;
2553
2617
  voice.session?.end();
2554
2618
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tarfa/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",