triflux 10.34.0 → 10.35.1

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/bin/tfx-live.mjs CHANGED
@@ -2,9 +2,15 @@
2
2
  import { execFile } from "node:child_process";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, tmpdir } from "node:os";
5
- import { join as pathJoin } from "node:path";
5
+ import { join as pathJoin, resolve as pathResolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
8
+ import {
9
+ escapePwshSingleQuoted as escapeRemotePwshSingleQuoted,
10
+ probeRemoteEnv as probeRemoteHostEnv,
11
+ shellQuote as remoteShellQuote,
12
+ validateHost as validateRemoteHost,
13
+ } from "../hub/team/remote-session.mjs";
8
14
 
9
15
  const execFileAsync = promisify(execFile);
10
16
 
@@ -23,6 +29,7 @@ const BRIDGE_TIMEOUT_BUFFER_MS = 15_000;
23
29
  // spill the JSON to a temp file and pass --payload-file instead.
24
30
  const PAYLOAD_FILE_THRESHOLD = 96 * 1024;
25
31
  const VALID_TRANSPORTS = ["tmux", "uds", "auto"];
32
+ const BOOLEAN_FLAGS = new Set(["json"]);
26
33
  // uds-fallback diagnostics land here, written async so a failed daemon attach
27
34
  // never blocks the tmux fallback path (see writeUdsBugReport / doAskAuto).
28
35
  const BUG_REPORT_DIR =
@@ -34,11 +41,11 @@ function usage() {
34
41
  "Usage:",
35
42
  " tfx-live start --session NAME [--cli codex|claude] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
36
43
  " tfx-live ask --session NAME --prompt TEXT [--cli codex|claude] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
37
- " tfx-live ask --transport uds|auto (--short SHORT | --session-id ID) --prompt TEXT [--bridge ABS] [--session NAME (auto fallback)] [--timeout 60]",
44
+ " tfx-live ask --transport uds|auto (--short SHORT | --session-id ID) --prompt TEXT [--config-dir DIR] [--bridge ABS] [--session NAME (auto fallback)] [--timeout 60]",
38
45
  " transport: auto is the default for Claude when --short/--session-id is present; otherwise tmux. bridge path: --bridge > $TFX_BRIDGE > $TFX_REPO_ROOT/hub/bridge.mjs > bundled Triflux hub/bridge.mjs.",
39
- " tfx-live interrupt --session NAME [--cli codex|claude] [--transport tmux|uds|auto] [--short SHORT | --session-id ID] [--bridge ABS] [--timeout 5]",
46
+ " tfx-live interrupt --session NAME [--cli codex|claude] [--transport tmux|uds|auto] [--short SHORT | --session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 5]",
40
47
  " tfx-live stop --session NAME [--cli codex|claude] [--remote HOST]",
41
- " tfx-live probe [--short SHORT] [--session-id ID] [--bridge ABS] [--timeout 10]",
48
+ " tfx-live probe [--short SHORT] [--session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 10]",
42
49
  " tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
43
50
  " tfx-live goal-driven --session NAME --goal TEXT [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500] [--max-rounds 8] [--done-token DONE]",
44
51
  " tfx-live peer [--cli-a codex] [--cli-b claude] [--session-a peerA] [--session-b peerB] [--transport-a tmux|uds|auto] [--transport-b tmux|uds|auto] [--short-a SHORT] [--short-b SHORT] [--session-id-a ID] [--session-id-b ID] [--bridge ABS] [--remote HOST] [--cwd DIR] [--rounds 4] [--mode counting|freeform] [--seed TEXT] [--timeout 60]",
@@ -65,6 +72,11 @@ function parseCli(argv) {
65
72
  throw new Error("Empty flag is not valid");
66
73
  }
67
74
 
75
+ if (BOOLEAN_FLAGS.has(key)) {
76
+ flags[key] = "1";
77
+ continue;
78
+ }
79
+
68
80
  const value = rest[index + 1];
69
81
  if (value === undefined) {
70
82
  throw new Error(`Missing value for --${key}`);
@@ -168,6 +180,152 @@ function buildTmuxCommand(remote, tmuxArgs) {
168
180
  };
169
181
  }
170
182
 
183
+ function timeoutSeconds(timeoutMs) {
184
+ return String(Math.max(1, Math.ceil((timeoutMs ?? 1000) / 1000)));
185
+ }
186
+
187
+ function buildRemoteLiveArgv(verb, opts) {
188
+ const args = [
189
+ "tfx-live",
190
+ verb,
191
+ "--cli",
192
+ "claude",
193
+ "--transport",
194
+ opts.transport ?? "uds",
195
+ ];
196
+ if (opts.short) args.push("--short", opts.short);
197
+ if (opts.sessionId) args.push("--session-id", opts.sessionId);
198
+ if (opts.session) args.push("--session", opts.session);
199
+ if (opts.configDir) args.push("--config-dir", opts.configDir);
200
+ if (verb === "ask") args.push("--prompt", opts.prompt ?? "");
201
+ args.push("--timeout", timeoutSeconds(opts.timeoutMs));
202
+ if (verb === "ask" && opts.settleMs) {
203
+ args.push("--settle", String(opts.settleMs));
204
+ }
205
+ if (verb === "ask" && opts.pollIntervalMs) {
206
+ args.push("--poll-interval", String(opts.pollIntervalMs));
207
+ }
208
+ return args;
209
+ }
210
+
211
+ function buildRemoteLiveCommand(host, verb, opts, env = {}) {
212
+ validateRemoteHost(host);
213
+ const argv = buildRemoteLiveArgv(verb, opts);
214
+ if (env.os === "win32") {
215
+ const [commandPath, ...args] = argv;
216
+ const command = [
217
+ `& '${escapeRemotePwshSingleQuoted(commandPath)}'`,
218
+ ...args.map((arg) => `'${escapeRemotePwshSingleQuoted(arg)}'`),
219
+ ].join(" ");
220
+ // Live-unverified for Windows remotes; apply the same SSH single-argument
221
+ // contract proven on darwin so the remote login shell does not re-split it.
222
+ const remoteCmd = `pwsh -NoProfile -Command ${remoteShellQuote(command)}`;
223
+ return {
224
+ command: "ssh",
225
+ args: [host, remoteCmd],
226
+ };
227
+ }
228
+
229
+ const shell = env.os === "darwin" && env.shell === "zsh" ? "zsh" : "sh";
230
+ const inner = argv.map(remoteShellQuote).join(" ");
231
+ const remoteCmd = `${shell} -lc ${remoteShellQuote(inner)}`;
232
+ return {
233
+ command: "ssh",
234
+ args: [host, remoteCmd],
235
+ };
236
+ }
237
+
238
+ function tryParseJsonObject(text) {
239
+ try {
240
+ return JSON.parse(text);
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ function parseRemoteLiveJson(stdout) {
247
+ const text = String(stdout).trim();
248
+ if (!text) {
249
+ throw new Error("remote tfx-live returned empty output");
250
+ }
251
+
252
+ const direct = tryParseJsonObject(text);
253
+ if (direct && typeof direct === "object" && !Array.isArray(direct)) {
254
+ return direct;
255
+ }
256
+
257
+ let parsed = null;
258
+ for (let start = 0; start < text.length; start += 1) {
259
+ if (text[start] !== "{") continue;
260
+ let depth = 0;
261
+ let inString = false;
262
+ let escaped = false;
263
+ for (let end = start; end < text.length; end += 1) {
264
+ const char = text[end];
265
+ if (inString) {
266
+ if (escaped) {
267
+ escaped = false;
268
+ } else if (char === "\\") {
269
+ escaped = true;
270
+ } else if (char === '"') {
271
+ inString = false;
272
+ }
273
+ continue;
274
+ }
275
+ if (char === '"') {
276
+ inString = true;
277
+ } else if (char === "{") {
278
+ depth += 1;
279
+ } else if (char === "}") {
280
+ depth -= 1;
281
+ if (depth === 0) {
282
+ const candidate = tryParseJsonObject(text.slice(start, end + 1));
283
+ if (
284
+ candidate &&
285
+ typeof candidate === "object" &&
286
+ !Array.isArray(candidate)
287
+ ) {
288
+ parsed = candidate;
289
+ }
290
+ break;
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ if (parsed) return parsed;
297
+ throw new Error(`remote tfx-live output is not JSON: ${text.slice(0, 200)}`);
298
+ }
299
+
300
+ async function callRemoteLive(verb, opts, deps = {}) {
301
+ const host = validateRemoteHost(opts.remote);
302
+ const probeRemoteEnv = deps.probeRemoteEnv ?? probeRemoteHostEnv;
303
+ const execRemote = deps.sshExec ?? execFileAsync;
304
+ const env = await probeRemoteEnv(host);
305
+ const plan = buildRemoteLiveCommand(host, verb, opts, env);
306
+ const execOptions = {
307
+ timeout: (opts.timeoutMs ?? DEFAULT_ANSWER_TIMEOUT_MS) + 30_000,
308
+ maxBuffer: MAX_BUFFER,
309
+ };
310
+
311
+ try {
312
+ const { stdout } = await execRemote(plan.command, plan.args, execOptions);
313
+ return parseRemoteLiveJson(stdout);
314
+ } catch (error) {
315
+ if (error?.stdout) {
316
+ try {
317
+ return parseRemoteLiveJson(error.stdout);
318
+ } catch {
319
+ // stdout was not a tfx-live JSON result; fall through.
320
+ }
321
+ }
322
+ const stderr = error?.stderr ? String(error.stderr).trim() : "";
323
+ throw new Error(
324
+ `remote tfx-live ${verb} failed: ${stderr || error.message}`,
325
+ );
326
+ }
327
+ }
328
+
171
329
  async function runTmux(remote, tmuxArgs, options = {}) {
172
330
  const { command, args } = buildTmuxCommand(remote, tmuxArgs);
173
331
  try {
@@ -397,6 +555,144 @@ function extractAssistantResponse(adapter, text, prompt = null) {
397
555
  return response.join("\n").trim();
398
556
  }
399
557
 
558
+ function normalizeClaudeTaskText(text) {
559
+ return String(text)
560
+ .replace(/[.…]+/g, " ")
561
+ .replace(/\s+/g, " ")
562
+ .trim()
563
+ .toLowerCase();
564
+ }
565
+
566
+ function isTaskListBoundary(line) {
567
+ const trimmed = line.trim();
568
+ return (
569
+ !trimmed ||
570
+ trimmed === "Working" ||
571
+ trimmed === "Completed" ||
572
+ /^[─-]{8,}$/.test(trimmed) ||
573
+ hasClaudeComposerPrompt(line) ||
574
+ /enter to open|space to reply|ctrl\+x to delete|\? for shortcuts/i.test(
575
+ trimmed,
576
+ )
577
+ );
578
+ }
579
+
580
+ function parseClaudeCompletedTaskListEntries(text) {
581
+ const lines = String(text)
582
+ .split("\n")
583
+ .map((line) => line.replace(/\s+$/g, ""));
584
+ const entries = [];
585
+ let inCompleted = false;
586
+
587
+ for (const line of lines) {
588
+ const trimmed = line.trim();
589
+ if (trimmed === "Completed") {
590
+ inCompleted = true;
591
+ continue;
592
+ }
593
+ if (!inCompleted) {
594
+ continue;
595
+ }
596
+ if (isTaskListBoundary(line)) {
597
+ if (trimmed) {
598
+ inCompleted = false;
599
+ }
600
+ continue;
601
+ }
602
+ if (!/^\s*✻\s+/.test(line)) {
603
+ continue;
604
+ }
605
+
606
+ const body = trimmed.replace(/^✻\s+/, "");
607
+ const columns = body
608
+ .split(/\s{2,}/)
609
+ .map((column) => column.trim())
610
+ .filter(Boolean);
611
+ if (columns.length < 2) {
612
+ continue;
613
+ }
614
+
615
+ let responseIndex = columns.length - 1;
616
+ const tail = columns[responseIndex];
617
+ if (/^(?:\d+\s*[smhdw]|now|just now)$/i.test(tail)) {
618
+ responseIndex -= 1;
619
+ }
620
+ if (responseIndex <= 0) {
621
+ continue;
622
+ }
623
+
624
+ const summary = columns.slice(0, responseIndex).join(" ").trim();
625
+ const response = columns[responseIndex].trim();
626
+ if (!summary || !response) {
627
+ continue;
628
+ }
629
+ entries.push({
630
+ summary,
631
+ response,
632
+ key: `${normalizeClaudeTaskText(summary)}\0${normalizeClaudeTaskText(
633
+ response,
634
+ )}`,
635
+ });
636
+ }
637
+
638
+ return entries;
639
+ }
640
+
641
+ function claudeTaskMatchesPrompt(entry, prompt) {
642
+ if (!prompt) {
643
+ return true;
644
+ }
645
+ const summary = normalizeClaudeTaskText(entry.summary);
646
+ const normalizedPrompt = normalizeClaudeTaskText(prompt);
647
+ return (
648
+ Boolean(summary && normalizedPrompt) &&
649
+ (summary.includes(normalizedPrompt) || normalizedPrompt.includes(summary))
650
+ );
651
+ }
652
+
653
+ function extractClaudeCompletedTaskListResponse(text, options = {}) {
654
+ const beforeEntries = parseClaudeCompletedTaskListEntries(options.beforeText);
655
+ const beforeKeys = new Set(beforeEntries.map((entry) => entry.key));
656
+ const newEntries = parseClaudeCompletedTaskListEntries(text).filter(
657
+ (entry) => !beforeKeys.has(entry.key),
658
+ );
659
+ if (newEntries.length === 0) {
660
+ return "";
661
+ }
662
+ const promptMatches = options.prompt
663
+ ? newEntries.filter((entry) =>
664
+ claudeTaskMatchesPrompt(entry, options.prompt),
665
+ )
666
+ : [];
667
+ if (promptMatches.length > 0) {
668
+ return promptMatches[0].response;
669
+ }
670
+ if (newEntries.length === 1) {
671
+ return newEntries[0].response;
672
+ }
673
+ return newEntries.at(-1).response;
674
+ }
675
+
676
+ function hasClaudeCompletedTaskListResponse(text, options = {}) {
677
+ return extractClaudeCompletedTaskListResponse(text, options).length > 0;
678
+ }
679
+
680
+ function hasTmuxAssistantResponseAfterPrompt(
681
+ adapter,
682
+ text,
683
+ prompt,
684
+ beforeText,
685
+ ) {
686
+ return (
687
+ hasAssistantResponseAfterPrompt(adapter, text, prompt) ||
688
+ (adapter.cli === "claude" &&
689
+ hasClaudeCompletedTaskListResponse(text, {
690
+ beforeText,
691
+ prompt,
692
+ }))
693
+ );
694
+ }
695
+
400
696
  async function capturePane(remote, session) {
401
697
  const { stdout } = await runTmux(remote, [
402
698
  "capture-pane",
@@ -787,18 +1083,81 @@ async function probeDaemon(bridgePath, ref, timeoutMs) {
787
1083
  const payload = {};
788
1084
  if (ref?.short) payload.short = ref.short;
789
1085
  if (ref?.sessionId) payload.sessionId = ref.sessionId;
1086
+ if (ref?.configDir) payload.configDir = ref.configDir;
790
1087
  const result = await callBridgeVerb(
791
1088
  bridgePath,
792
1089
  "daemon-probe",
793
1090
  payload,
794
1091
  timeoutMs,
795
1092
  );
796
- return { ok: result?.ok === true, sessions: result?.sessions, raw: result };
1093
+ return {
1094
+ ok: result?.ok === true,
1095
+ reason: result?.reason,
1096
+ sessions: result?.sessions,
1097
+ raw: result,
1098
+ };
797
1099
  } catch (error) {
798
1100
  return { ok: false, reason: error.message };
799
1101
  }
800
1102
  }
801
1103
 
1104
+ async function hasTmuxSession(adapter, opts) {
1105
+ if (!opts.session) return false;
1106
+ try {
1107
+ await runTmux(opts.remote, ["has-session", "-t", opts.session]);
1108
+ return true;
1109
+ } catch {
1110
+ return false;
1111
+ }
1112
+ }
1113
+
1114
+ function daemonProbeUnavailableReason(probe, targetAttachable) {
1115
+ if (targetAttachable) return null;
1116
+ if (!probe?.ok) return probe?.reason ?? "probe-failed";
1117
+ return "target-not-found";
1118
+ }
1119
+
1120
+ async function resolveAskTransport(adapter, opts, deps = {}) {
1121
+ const transport = opts.transport ?? "tmux";
1122
+ if (transport !== "auto") {
1123
+ return {
1124
+ transport,
1125
+ transportSelected: transport,
1126
+ transportProbe: null,
1127
+ };
1128
+ }
1129
+
1130
+ const tmuxProbe = deps.hasTmuxSession ?? hasTmuxSession;
1131
+ const daemonProbe = deps.probeDaemon ?? probeDaemon;
1132
+ const [tmux, probe] = await Promise.all([
1133
+ tmuxProbe(adapter, opts),
1134
+ daemonProbe(
1135
+ opts.bridgePath,
1136
+ {
1137
+ short: opts.short,
1138
+ sessionId: opts.sessionId,
1139
+ configDir: opts.configDir,
1140
+ },
1141
+ opts.timeoutMs,
1142
+ ),
1143
+ ]);
1144
+ const daemon = daemonProbeTargetAttachable(probe, opts);
1145
+ const daemonReason = daemonProbeUnavailableReason(probe, daemon);
1146
+ const transportProbe = {
1147
+ tmux: tmux === true,
1148
+ daemon,
1149
+ ...(daemonReason ? { daemonReason } : {}),
1150
+ };
1151
+
1152
+ return {
1153
+ transport: "auto",
1154
+ transportSelected: daemon ? "uds" : tmux ? "tmux" : "none",
1155
+ transportProbe,
1156
+ daemonProbe: probe,
1157
+ daemonConfigDir: opts.configDir ?? probe?.raw?.daemon?.configDir ?? null,
1158
+ };
1159
+ }
1160
+
802
1161
  async function doStart(adapter, opts) {
803
1162
  const {
804
1163
  session,
@@ -874,7 +1233,7 @@ async function doAskViaTmux(adapter, opts) {
874
1233
  const doneSignal =
875
1234
  !adapter.isBusy(visible) &&
876
1235
  ready &&
877
- hasAssistantResponseAfterPrompt(adapter, visible, prompt);
1236
+ hasTmuxAssistantResponseAfterPrompt(adapter, visible, prompt, beforeRaw);
878
1237
  if (doneSignal && quietPolls >= FALLBACK_QUIET_POLLS) {
879
1238
  done = true;
880
1239
  break;
@@ -885,6 +1244,15 @@ async function doAskViaTmux(adapter, opts) {
885
1244
 
886
1245
  raw = await capturePane(remote, session);
887
1246
  response = extractAssistantResponse(adapter, raw, prompt);
1247
+ if (!response && adapter.cli === "claude") {
1248
+ response = extractClaudeCompletedTaskListResponse(raw, {
1249
+ beforeText: beforeRaw,
1250
+ prompt,
1251
+ });
1252
+ if (response) {
1253
+ done = true;
1254
+ }
1255
+ }
888
1256
  contextPctAfter = adapter.contextPct(raw);
889
1257
 
890
1258
  return addLoginGuard(adapter, raw, {
@@ -895,6 +1263,7 @@ async function doAskViaTmux(adapter, opts) {
895
1263
  response,
896
1264
  contextPctBefore,
897
1265
  contextPctAfter,
1266
+ matchedCompletion: done,
898
1267
  done,
899
1268
  raw,
900
1269
  });
@@ -918,6 +1287,10 @@ async function doAsk(adapter, opts) {
918
1287
  );
919
1288
  }
920
1289
 
1290
+ if (opts.remote) {
1291
+ return doAskViaRemoteLive(adapter, opts);
1292
+ }
1293
+
921
1294
  if (transport === "uds") {
922
1295
  if (!opts.bridgePath) {
923
1296
  throw new Error(
@@ -930,11 +1303,22 @@ async function doAsk(adapter, opts) {
930
1303
  return doAskAuto(adapter, opts);
931
1304
  }
932
1305
 
1306
+ async function doAskViaRemoteLive(adapter, opts, deps = {}) {
1307
+ const result = await callRemoteLive("ask", opts, deps);
1308
+ return {
1309
+ ...result,
1310
+ cli: result.cli ?? adapter.cli,
1311
+ remote: opts.remote,
1312
+ remoteRelay: true,
1313
+ };
1314
+ }
1315
+
933
1316
  async function doAskViaDaemon(opts, meta = {}) {
934
- const { bridgePath, prompt, timeoutMs, short, sessionId } = opts;
1317
+ const { bridgePath, prompt, timeoutMs, short, sessionId, configDir } = opts;
935
1318
  const payload = { prompt, timeoutMs };
936
1319
  if (short) payload.short = short;
937
1320
  if (sessionId) payload.sessionId = sessionId;
1321
+ if (configDir) payload.configDir = configDir;
938
1322
 
939
1323
  const result = await callBridgeVerb(
940
1324
  bridgePath,
@@ -956,12 +1340,35 @@ async function doAskViaDaemon(opts, meta = {}) {
956
1340
  timedOut: result?.timedOut === true,
957
1341
  closed: result?.closed === true,
958
1342
  inputSent: result?.inputSent === true,
1343
+ daemon: result?.daemon ?? null,
1344
+ daemons: result?.daemons ?? [],
1345
+ matches: result?.matches ?? [],
1346
+ candidateResults: result?.candidateResults ?? [],
1347
+ callerProvenance: result?.callerProvenance ?? null,
959
1348
  done: matchedCompletion,
960
1349
  ...(result?.error ? { error: result.error } : {}),
961
1350
  ...meta,
962
1351
  };
963
1352
  }
964
1353
 
1354
+ function daemonProbeTargetAttachable(probe, opts) {
1355
+ if (!probe?.ok) return false;
1356
+ // New bridge responses carry `target`; daemon-control owns selection policy.
1357
+ if (probe.raw?.target) return true;
1358
+ // Compatibility only for older bridge binaries that list sessions but do not
1359
+ // expose `target` yet. Do not add new selection semantics here.
1360
+ if (!Array.isArray(probe.sessions)) return false;
1361
+ return probe.sessions.some(
1362
+ (entry) =>
1363
+ (opts.short && entry?.short === opts.short) ||
1364
+ (opts.sessionId &&
1365
+ (entry?.sessionId === opts.sessionId ||
1366
+ entry?.session_id === opts.sessionId ||
1367
+ entry?.dispatch?.sessionId === opts.sessionId ||
1368
+ entry?.d?.sessionId === opts.sessionId)),
1369
+ );
1370
+ }
1371
+
965
1372
  async function ensureTmuxSession(adapter, opts) {
966
1373
  if (!opts.session) return false;
967
1374
  try {
@@ -1004,6 +1411,13 @@ function writeUdsBugReport(reason, context) {
1004
1411
  .map((entry) => entry?.short)
1005
1412
  .filter(Boolean)
1006
1413
  : [],
1414
+ // Diagnostic-only bridge metadata; callers should not depend on this
1415
+ // shape as a versioned control contract.
1416
+ daemon: context.probe.raw?.daemon ?? null,
1417
+ daemons: context.probe.raw?.daemons ?? [],
1418
+ matches: context.probe.raw?.matches ?? [],
1419
+ candidateResults: context.probe.raw?.candidateResults ?? [],
1420
+ callerProvenance: context.probe.raw?.callerProvenance ?? null,
1007
1421
  }
1008
1422
  : null,
1009
1423
  attachError: context.attachError ?? null,
@@ -1050,28 +1464,28 @@ async function runTmuxFallback(adapter, opts, reason, udsResult) {
1050
1464
  }
1051
1465
 
1052
1466
  async function doAskAuto(adapter, opts) {
1053
- const probe = await probeDaemon(
1054
- opts.bridgePath,
1055
- { short: opts.short, sessionId: opts.sessionId },
1056
- opts.timeoutMs,
1057
- );
1058
- // daemon-probe returns the full session list (it does not filter by the
1059
- // requested short), so confirm the target is actually attachable here rather
1060
- // than firing a doomed attach at a missing short.
1061
- const targetAttachable =
1062
- probe.ok &&
1063
- Array.isArray(probe.sessions) &&
1064
- probe.sessions.some(
1065
- (entry) =>
1066
- (opts.short && entry?.short === opts.short) ||
1067
- (opts.sessionId && entry?.sessionId === opts.sessionId),
1068
- );
1467
+ const resolution = await resolveAskTransport(adapter, opts);
1468
+ const probe = resolution.daemonProbe;
1069
1469
 
1070
- if (targetAttachable) {
1071
- const udsResult = await doAskViaDaemon(opts, {
1072
- transportSelected: "uds",
1073
- transportProbe: "ok",
1074
- });
1470
+ if (resolution.transportSelected === "tmux") {
1471
+ const tmuxResult = await doAskViaTmux(adapter, opts);
1472
+ return {
1473
+ ...tmuxResult,
1474
+ transport: "auto",
1475
+ transportSelected: "tmux",
1476
+ transportProbe: resolution.transportProbe,
1477
+ };
1478
+ }
1479
+
1480
+ if (resolution.transportSelected === "uds") {
1481
+ const daemonConfigDir = resolution.daemonConfigDir;
1482
+ const udsResult = await doAskViaDaemon(
1483
+ { ...opts, configDir: daemonConfigDir },
1484
+ {
1485
+ transportSelected: "uds",
1486
+ transportProbe: resolution.transportProbe,
1487
+ },
1488
+ );
1075
1489
  if (udsResult.matchedCompletion) {
1076
1490
  return udsResult;
1077
1491
  }
@@ -1089,27 +1503,48 @@ async function doAskAuto(adapter, opts) {
1089
1503
  probe,
1090
1504
  attachError,
1091
1505
  });
1092
- const fallback = await runTmuxFallback(
1093
- adapter,
1094
- opts,
1095
- "uds-attach-incomplete",
1096
- udsResult,
1097
- );
1506
+ const fallback =
1507
+ resolution.transportProbe?.tmux === true
1508
+ ? await runTmuxFallback(
1509
+ adapter,
1510
+ opts,
1511
+ "uds-attach-incomplete",
1512
+ udsResult,
1513
+ )
1514
+ : {
1515
+ cli: adapter.cli,
1516
+ transport: "auto",
1517
+ transportSelected: "none",
1518
+ transportProbe: resolution.transportProbe,
1519
+ response: "",
1520
+ done: false,
1521
+ error:
1522
+ "uds unavailable (uds-attach-incomplete) and no tmux session for fallback",
1523
+ udsError: udsResult.error ?? attachError,
1524
+ };
1098
1525
  await reportPromise.catch(() => {});
1099
1526
  return fallback;
1100
1527
  }
1101
1528
 
1102
1529
  // uds is unavailable: daemon unreachable, or reachable but target not listed.
1103
- const reason = !probe.ok
1104
- ? (probe.reason ?? "probe-failed")
1105
- : "target-not-found";
1530
+ const reason = resolution.transportProbe?.daemonReason ?? "target-not-found";
1106
1531
  const reportPromise = writeUdsBugReport(reason, {
1107
1532
  short: opts.short,
1108
1533
  sessionId: opts.sessionId,
1109
1534
  bridgePath: opts.bridgePath,
1110
1535
  probe,
1111
1536
  });
1112
- const fallback = await runTmuxFallback(adapter, opts, reason, null);
1537
+ const fallback = {
1538
+ cli: adapter.cli,
1539
+ transport: "auto",
1540
+ transportSelected: "none",
1541
+ transportProbe: resolution.transportProbe,
1542
+ response: "",
1543
+ done: false,
1544
+ error: opts.session
1545
+ ? `uds unavailable (${reason}) and tmux session unavailable`
1546
+ : `uds unavailable (${reason}) and no --session for tmux fallback`,
1547
+ };
1113
1548
  await reportPromise.catch(() => {});
1114
1549
  return fallback;
1115
1550
  }
@@ -1139,10 +1574,11 @@ async function doInterruptViaTmux(adapter, opts) {
1139
1574
  }
1140
1575
 
1141
1576
  async function doInterruptViaDaemon(opts, meta = {}) {
1142
- const { bridgePath, timeoutMs, short, sessionId } = opts;
1577
+ const { bridgePath, timeoutMs, short, sessionId, configDir } = opts;
1143
1578
  const payload = { timeoutMs };
1144
1579
  if (short) payload.short = short;
1145
1580
  if (sessionId) payload.sessionId = sessionId;
1581
+ if (configDir) payload.configDir = configDir;
1146
1582
  const result = await callBridgeVerb(
1147
1583
  bridgePath,
1148
1584
  "daemon-interrupt",
@@ -1161,11 +1597,26 @@ async function doInterruptViaDaemon(opts, meta = {}) {
1161
1597
  inputSent: result?.inputSent === true,
1162
1598
  timedOut: result?.timedOut === true,
1163
1599
  closed: result?.closed === true,
1600
+ daemon: result?.daemon ?? null,
1601
+ daemons: result?.daemons ?? [],
1602
+ matches: result?.matches ?? [],
1603
+ candidateResults: result?.candidateResults ?? [],
1604
+ callerProvenance: result?.callerProvenance ?? null,
1164
1605
  ...(result?.error ? { error: result.error } : {}),
1165
1606
  ...meta,
1166
1607
  };
1167
1608
  }
1168
1609
 
1610
+ async function doInterruptViaRemoteLive(adapter, opts, deps = {}) {
1611
+ const result = await callRemoteLive("interrupt", opts, deps);
1612
+ return {
1613
+ ...result,
1614
+ cli: result.cli ?? adapter.cli,
1615
+ remote: opts.remote,
1616
+ remoteRelay: true,
1617
+ };
1618
+ }
1619
+
1169
1620
  async function doInterrupt(adapter, opts) {
1170
1621
  const transport = opts.transport ?? "tmux";
1171
1622
  if (transport === "tmux") {
@@ -1181,6 +1632,9 @@ async function doInterrupt(adapter, opts) {
1181
1632
  `--transport ${transport} requires --short or --session-id`,
1182
1633
  );
1183
1634
  }
1635
+ if (opts.remote) {
1636
+ return doInterruptViaRemoteLive(adapter, opts);
1637
+ }
1184
1638
  if (!opts.bridgePath) {
1185
1639
  throw new Error(
1186
1640
  `--transport ${transport} requires a bridge path (--bridge, TFX_BRIDGE, or TFX_REPO_ROOT)`,
@@ -1269,6 +1723,7 @@ function askOpts(flags, adapter) {
1269
1723
  transport === "tmux" ? requireFlag(flags, "session") : flags.session,
1270
1724
  short,
1271
1725
  sessionId,
1726
+ configDir: flags["config-dir"],
1272
1727
  transport,
1273
1728
  bridgePath: resolveBridgePath(flags),
1274
1729
  prompt: requireFlag(flags, "prompt"),
@@ -1295,6 +1750,7 @@ function interruptOpts(flags, adapter) {
1295
1750
  transport === "tmux" ? requireFlag(flags, "session") : flags.session,
1296
1751
  short,
1297
1752
  sessionId,
1753
+ configDir: flags["config-dir"],
1298
1754
  transport,
1299
1755
  bridgePath: resolveBridgePath(flags),
1300
1756
  remote: flags.remote,
@@ -1326,6 +1782,7 @@ async function probe(flags) {
1326
1782
  const payload = {};
1327
1783
  if (flags.short) payload.short = flags.short;
1328
1784
  if (flags["session-id"]) payload.sessionId = flags["session-id"];
1785
+ if (flags["config-dir"]) payload.configDir = flags["config-dir"];
1329
1786
  const timeoutMs = secondsFlag(flags, "timeout", 10_000);
1330
1787
  printJson(
1331
1788
  await callBridgeVerb(
@@ -1768,10 +2225,27 @@ async function main() {
1768
2225
  }
1769
2226
  }
1770
2227
 
1771
- main().catch((error) => {
1772
- printJson({
1773
- ok: false,
1774
- error: error.message,
2228
+ export {
2229
+ ADAPTERS,
2230
+ buildRemoteLiveCommand,
2231
+ callRemoteLive,
2232
+ extractAssistantResponse,
2233
+ extractClaudeCompletedTaskListResponse,
2234
+ hasClaudeCompletedTaskListResponse,
2235
+ parseRemoteLiveJson,
2236
+ resolveAskTransport,
2237
+ };
2238
+
2239
+ const isDirectRun =
2240
+ process.argv[1] &&
2241
+ fileURLToPath(import.meta.url) === pathResolve(process.argv[1]);
2242
+
2243
+ if (isDirectRun) {
2244
+ main().catch((error) => {
2245
+ printJson({
2246
+ ok: false,
2247
+ error: error.message,
2248
+ });
2249
+ process.exitCode = 1;
1775
2250
  });
1776
- process.exitCode = 1;
1777
- });
2251
+ }