triflux 10.35.0 → 10.35.2

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 =
@@ -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",
@@ -805,6 +1101,63 @@ async function probeDaemon(bridgePath, ref, timeoutMs) {
805
1101
  }
806
1102
  }
807
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
+
808
1161
  async function doStart(adapter, opts) {
809
1162
  const {
810
1163
  session,
@@ -880,7 +1233,7 @@ async function doAskViaTmux(adapter, opts) {
880
1233
  const doneSignal =
881
1234
  !adapter.isBusy(visible) &&
882
1235
  ready &&
883
- hasAssistantResponseAfterPrompt(adapter, visible, prompt);
1236
+ hasTmuxAssistantResponseAfterPrompt(adapter, visible, prompt, beforeRaw);
884
1237
  if (doneSignal && quietPolls >= FALLBACK_QUIET_POLLS) {
885
1238
  done = true;
886
1239
  break;
@@ -891,6 +1244,15 @@ async function doAskViaTmux(adapter, opts) {
891
1244
 
892
1245
  raw = await capturePane(remote, session);
893
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
+ }
894
1256
  contextPctAfter = adapter.contextPct(raw);
895
1257
 
896
1258
  return addLoginGuard(adapter, raw, {
@@ -901,6 +1263,7 @@ async function doAskViaTmux(adapter, opts) {
901
1263
  response,
902
1264
  contextPctBefore,
903
1265
  contextPctAfter,
1266
+ matchedCompletion: done,
904
1267
  done,
905
1268
  raw,
906
1269
  });
@@ -924,6 +1287,10 @@ async function doAsk(adapter, opts) {
924
1287
  );
925
1288
  }
926
1289
 
1290
+ if (opts.remote) {
1291
+ return doAskViaRemoteLive(adapter, opts);
1292
+ }
1293
+
927
1294
  if (transport === "uds") {
928
1295
  if (!opts.bridgePath) {
929
1296
  throw new Error(
@@ -936,6 +1303,16 @@ async function doAsk(adapter, opts) {
936
1303
  return doAskAuto(adapter, opts);
937
1304
  }
938
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
+
939
1316
  async function doAskViaDaemon(opts, meta = {}) {
940
1317
  const { bridgePath, prompt, timeoutMs, short, sessionId, configDir } = opts;
941
1318
  const payload = { prompt, timeoutMs };
@@ -1087,23 +1464,26 @@ async function runTmuxFallback(adapter, opts, reason, udsResult) {
1087
1464
  }
1088
1465
 
1089
1466
  async function doAskAuto(adapter, opts) {
1090
- const probe = await probeDaemon(
1091
- opts.bridgePath,
1092
- { short: opts.short, sessionId: opts.sessionId, configDir: opts.configDir },
1093
- opts.timeoutMs,
1094
- );
1095
- // daemon-probe returns the full session list (it does not filter by the
1096
- // requested short), so confirm the target is actually attachable here rather
1097
- // than firing a doomed attach at a missing short.
1098
- const targetAttachable = daemonProbeTargetAttachable(probe, opts);
1467
+ const resolution = await resolveAskTransport(adapter, opts);
1468
+ const probe = resolution.daemonProbe;
1099
1469
 
1100
- if (targetAttachable) {
1101
- const daemonConfigDir = opts.configDir ?? probe.raw?.daemon?.configDir;
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;
1102
1482
  const udsResult = await doAskViaDaemon(
1103
1483
  { ...opts, configDir: daemonConfigDir },
1104
1484
  {
1105
1485
  transportSelected: "uds",
1106
- transportProbe: "ok",
1486
+ transportProbe: resolution.transportProbe,
1107
1487
  },
1108
1488
  );
1109
1489
  if (udsResult.matchedCompletion) {
@@ -1123,27 +1503,48 @@ async function doAskAuto(adapter, opts) {
1123
1503
  probe,
1124
1504
  attachError,
1125
1505
  });
1126
- const fallback = await runTmuxFallback(
1127
- adapter,
1128
- opts,
1129
- "uds-attach-incomplete",
1130
- udsResult,
1131
- );
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
+ };
1132
1525
  await reportPromise.catch(() => {});
1133
1526
  return fallback;
1134
1527
  }
1135
1528
 
1136
1529
  // uds is unavailable: daemon unreachable, or reachable but target not listed.
1137
- const reason = !probe.ok
1138
- ? (probe.reason ?? "probe-failed")
1139
- : "target-not-found";
1530
+ const reason = resolution.transportProbe?.daemonReason ?? "target-not-found";
1140
1531
  const reportPromise = writeUdsBugReport(reason, {
1141
1532
  short: opts.short,
1142
1533
  sessionId: opts.sessionId,
1143
1534
  bridgePath: opts.bridgePath,
1144
1535
  probe,
1145
1536
  });
1146
- 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
+ };
1147
1548
  await reportPromise.catch(() => {});
1148
1549
  return fallback;
1149
1550
  }
@@ -1206,6 +1607,16 @@ async function doInterruptViaDaemon(opts, meta = {}) {
1206
1607
  };
1207
1608
  }
1208
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
+
1209
1620
  async function doInterrupt(adapter, opts) {
1210
1621
  const transport = opts.transport ?? "tmux";
1211
1622
  if (transport === "tmux") {
@@ -1221,6 +1632,9 @@ async function doInterrupt(adapter, opts) {
1221
1632
  `--transport ${transport} requires --short or --session-id`,
1222
1633
  );
1223
1634
  }
1635
+ if (opts.remote) {
1636
+ return doInterruptViaRemoteLive(adapter, opts);
1637
+ }
1224
1638
  if (!opts.bridgePath) {
1225
1639
  throw new Error(
1226
1640
  `--transport ${transport} requires a bridge path (--bridge, TFX_BRIDGE, or TFX_REPO_ROOT)`,
@@ -1811,10 +2225,27 @@ async function main() {
1811
2225
  }
1812
2226
  }
1813
2227
 
1814
- main().catch((error) => {
1815
- printJson({
1816
- ok: false,
1817
- 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;
1818
2250
  });
1819
- process.exitCode = 1;
1820
- });
2251
+ }
package/bin/triflux.mjs CHANGED
@@ -3328,72 +3328,42 @@ async function cmdDoctor(options = {}) {
3328
3328
  }
3329
3329
  }
3330
3330
 
3331
- // 4.5 Serena MCP
3331
+ // 4.5 Serena MCP — 2026-06-10 core 에서 제거됨([Serena Core Removal]).
3332
+ // serena 부재가 정상 상태이며, 잔존 시 부활로 간주해 제거를 권고한다(should-be-absent).
3332
3333
  section("Serena MCP");
3333
3334
  if (existsSync(CODEX_CONFIG_PATH)) {
3334
3335
  const codexConfig = readFileSync(CODEX_CONFIG_PATH, "utf8");
3335
3336
  const serenaConfig = inspectSerenaMcpConfig(codexConfig);
3336
3337
  if (!serenaConfig.present) {
3337
- warn("serena MCP 설정 없음");
3338
- info(
3339
- "권장: [mcp_servers.serena]에 --project-from-cwd, --context codex, startup_timeout_sec=30+ 설정",
3340
- );
3338
+ ok("serena 미설정 (정상 — 2026-06-10 core 제거)");
3341
3339
  addDoctorCheck(report, {
3342
3340
  name: "serena-mcp",
3343
- status: "missing",
3341
+ status: "ok",
3344
3342
  path: CODEX_CONFIG_PATH,
3345
- fix: "Codex config에 Serena MCP 설정을 추가하세요.",
3343
+ note: "serena removed from core 2026-06-10; absence is expected.",
3346
3344
  });
3347
- issues++;
3348
3345
  } else {
3349
- const hasSerenaIssues =
3350
- !serenaConfig.hasProjectBinding || !serenaConfig.timeoutRecommended;
3351
-
3352
- if (serenaConfig.hasProjectBinding) ok("project binding: 정상");
3353
- else {
3354
- warn("project binding 없음");
3355
- info("권장: --project-from-cwd 또는 --project <path>");
3356
- issues++;
3357
- }
3358
-
3359
- if (serenaConfig.hasContextCodex) info("context codex: 설정됨");
3360
- else info("context codex: 미설정");
3361
-
3362
- if (serenaConfig.startupTimeoutSec === null) {
3363
- warn("startup_timeout_sec 미설정");
3364
- info("권장: startup_timeout_sec = 30 이상");
3365
- issues++;
3366
- } else if (serenaConfig.timeoutRecommended) {
3367
- ok(`startup timeout: ${serenaConfig.startupTimeoutSec}s`);
3368
- } else {
3369
- warn(`startup timeout 낮음: ${serenaConfig.startupTimeoutSec}s`);
3370
- info("권장: startup_timeout_sec = 30 이상");
3371
- issues++;
3372
- }
3373
-
3346
+ // 제거 결정 이후 serena 가 다시 나타났다 — 부활 감지(should-be-absent 위반).
3347
+ warn("serena MCP 설정 잔존 — core 에서 제거됨(2026-06-10). 부활 감지");
3348
+ info("제거 권장: ~/.codex/config.toml 의 [mcp_servers.serena] 삭제");
3374
3349
  addDoctorCheck(report, {
3375
3350
  name: "serena-mcp",
3376
- status: hasSerenaIssues ? "issues" : "ok",
3351
+ status: "issues",
3377
3352
  path: CODEX_CONFIG_PATH,
3378
- project_binding: serenaConfig.hasProjectBinding,
3379
- context_codex: serenaConfig.hasContextCodex,
3380
- startup_timeout_sec: serenaConfig.startupTimeoutSec,
3381
- ...(hasSerenaIssues
3382
- ? {
3383
- fix: "Serena MCP에 --project-from-cwd 와 startup_timeout_sec=30+ 를 설정하세요.",
3384
- }
3385
- : {}),
3353
+ resurrected: true,
3354
+ fix: "serena 는 core 에서 제거되었습니다. ~/.codex/config.toml 의 [mcp_servers.serena] 항목을 삭제하세요.",
3386
3355
  });
3356
+ issues++;
3387
3357
  }
3388
3358
  } else {
3359
+ // config.toml 미존재 — serena 부재는 정상. 이슈로 집계하지 않는다.
3360
+ ok("config.toml 미존재 — serena 진단 불필요");
3389
3361
  addDoctorCheck(report, {
3390
3362
  name: "serena-mcp",
3391
- status: "missing",
3363
+ status: "ok",
3392
3364
  path: CODEX_CONFIG_PATH,
3393
- fix: "Codex config 생성하고 Serena MCP 설정을 추가하세요.",
3365
+ note: "config.toml absent; serena not required.",
3394
3366
  });
3395
- warn("config.toml 미존재 — Serena MCP 진단 건너뜀");
3396
- issues++;
3397
3367
  }
3398
3368
 
3399
3369
  // 5. Antigravity CLI
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import fs from "node:fs/promises";
2
+ import { readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -8,7 +8,7 @@ import { refreshClaudeSessionProjectionCwd } from "../hub/team/claude-session-pr
8
8
 
9
9
  function readStdin() {
10
10
  try {
11
- return fs.readFile(0, "utf8");
11
+ return Promise.resolve(readFileSync(0, "utf8"));
12
12
  } catch {
13
13
  return Promise.resolve("");
14
14
  }
@@ -47,6 +47,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
47
47
  class WebViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler {
48
48
  var webView: WKWebView!
49
49
  var port: String = "27888"
50
+ let canonicalPort = "27888"
51
+ var consecutiveFailures = 0
50
52
  var retryWorkItem: DispatchWorkItem?
51
53
  var onFocusComplete: (() -> Void)?
52
54
 
@@ -101,6 +103,14 @@ class WebViewController: NSViewController, WKNavigationDelegate, WKScriptMessage
101
103
 
102
104
  func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
103
105
  logError("Failed to load: \(error.localizedDescription)")
106
+ consecutiveFailures += 1
107
+ if consecutiveFailures >= 3 && port != canonicalPort {
108
+ port = canonicalPort
109
+ consecutiveFailures = 0
110
+ logError("Switching to canonical port \(canonicalPort) after repeated failures")
111
+ loadTray()
112
+ return
113
+ }
104
114
  let retry = DispatchWorkItem { [weak self] in
105
115
  self?.loadTray()
106
116
  }
@@ -111,6 +121,7 @@ class WebViewController: NSViewController, WKNavigationDelegate, WKScriptMessage
111
121
  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
112
122
  retryWorkItem?.cancel()
113
123
  retryWorkItem = nil
124
+ consecutiveFailures = 0
114
125
  logError("Successfully loaded URL")
115
126
  }
116
127
 
package/hub/server.mjs CHANGED
@@ -517,7 +517,7 @@ function getRequestPath(url = "/") {
517
517
  }
518
518
  }
519
519
 
520
- function isLoopbackRemoteAddress(remoteAddress) {
520
+ export function isLoopbackRemoteAddress(remoteAddress) {
521
521
  return (
522
522
  typeof remoteAddress === "string" &&
523
523
  LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress)
@@ -563,7 +563,7 @@ function safeTokenCompare(a, b) {
563
563
  return timingSafeEqual(ha, hb);
564
564
  }
565
565
 
566
- function isAuthorizedRequest(req, path, hubToken) {
566
+ export function isAuthorizedRequest(req, path, hubToken) {
567
567
  if (!hubToken) {
568
568
  return isLoopbackRemoteAddress(req.socket.remoteAddress);
569
569
  }
package/hub/tray.mjs CHANGED
@@ -9,12 +9,26 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { resolveHubPortForContext } from "./hub-lifecycle.mjs";
12
13
  import { IS_MAC, IS_WINDOWS } from "./platform.mjs";
13
14
  import { ensureHubForTray } from "./tray-lifecycle.mjs";
14
15
 
15
16
  const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
16
17
  const DEFAULT_HUB_PORT = "27888";
17
18
 
19
+ export function resolveTrayHubPort({
20
+ env = process.env,
21
+ cwd = process.cwd(),
22
+ } = {}) {
23
+ return String(
24
+ resolveHubPortForContext({
25
+ env,
26
+ cwd,
27
+ defaultPort: Number(DEFAULT_HUB_PORT),
28
+ }),
29
+ );
30
+ }
31
+
18
32
  function sleep(ms) {
19
33
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
20
34
  }
@@ -416,7 +430,7 @@ async function shutdown(reason = "shutdown") {
416
430
  export async function startTray() {
417
431
  if (IS_MAC) {
418
432
  const trayScript = fileURLToPath(import.meta.url);
419
- const port = process.env.TFX_HUB_PORT || DEFAULT_HUB_PORT;
433
+ const port = resolveTrayHubPort();
420
434
  const serverPath = join(dirname(trayScript), "server.mjs");
421
435
  await ensureHubForTray({ port, serverPath });
422
436
  const reaped = reapExistingMacTrayProcesses({ scriptPath: trayScript });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.35.0",
3
+ "version": "10.35.2",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/setup.mjs CHANGED
@@ -1374,13 +1374,25 @@ function ensureCriticalSetup() {
1374
1374
 
1375
1375
  try {
1376
1376
  ensureCodexProfiles();
1377
- } catch {}
1377
+ } catch (error) {
1378
+ process.stderr.write(
1379
+ `[tfx-setup] ensureCodexProfiles 실패: ${error?.message || error}\n`,
1380
+ );
1381
+ }
1378
1382
  try {
1379
1383
  ensureCodexHooks();
1380
- } catch {}
1384
+ } catch (error) {
1385
+ process.stderr.write(
1386
+ `[tfx-setup] ensureCodexHooks 실패: ${error?.message || error}\n`,
1387
+ );
1388
+ }
1381
1389
  try {
1382
1390
  ensureAgyHooks();
1383
- } catch {}
1391
+ } catch (error) {
1392
+ process.stderr.write(
1393
+ `[tfx-setup] ensureAgyHooks 실패: ${error?.message || error}\n`,
1394
+ );
1395
+ }
1384
1396
  }
1385
1397
 
1386
1398
  export {
@@ -1,8 +1,9 @@
1
1
  ---
2
2
  name: tfx-ship
3
3
  description: >
4
- triflux 전용 릴리즈 자동화. 기존 scripts/release/* 래퍼 + AskUserQuestion 기반 버전 선택 +
5
- CHANGELOG 편집 게이트 + Co-Authored-By/AI trailer 금지 강제. 'ship', '배포', '릴리즈',
4
+ triflux 전용 릴리즈 자동화. **GitHub Actions(release.yml dispatch / npm-publish.yml) 기반 CI 릴리즈가 기본 경로 —
5
+ npm publish OIDC Trusted Publishing 으로 CI 수행하므로 로컬 npm login 불필요.** scripts/release/* 래퍼 +
6
+ AskUserQuestion 기반 버전 선택 + CHANGELOG 편집 게이트 + Co-Authored-By/AI trailer 금지 강제. 'ship', '배포', '릴리즈',
6
7
  'release', 'tfx-ship', 'publish' 같은 요청에 반드시 사용.
7
8
  argument-hint: "[patch|minor|major|<version>] [--skip-tests] [--no-publish] [--dry-run]"
8
9
  ---
@@ -27,10 +28,11 @@ triflux 는 아래 3채널로 동시 배포. 각 채널의 버전은 반드시
27
28
  | # | 채널 | 명령 | 우선순위 |
28
29
  |---|------|------|---------|
29
30
  | 1 | **GitHub Releases** | `gh release create vX.Y.Z --notes-file <notes>` | 공지 + changelog 공식 소스 |
30
- | 2 | **npm registry** | `node scripts/release/publish.mjs --execute` | primary distribution |
31
+ | 2 | **npm registry** | **CI `npm-publish.yml`** (`v*` 태그 push 자동 / OIDC Trusted Publishing, `NPM_TOKEN` secret) | primary distribution |
31
32
  | 3 | **Claude Code marketplace** | `.claude-plugin/marketplace.json` (`source: npm` 참조) | `claude plugin add triflux` |
32
33
  | 4 | **pypi** (future, 비활성) | 현재 `pyproject.toml` 없음 | 활성화 시 Step 10.5 신설 |
33
34
 
35
+ - **npm publish 는 로컬에서 하지 않는다** — `release.yml` dispatch 또는 `v*` 태그 push 시 `npm-publish.yml` 이 OIDC 로 발행한다. 로컬 `publish.mjs --execute` 는 CI 불가 시 폴백 전용.
34
36
  - marketplace 는 자체 publish 명령이 없음. marketplace.json 의 version 만 갱신하면 git push 로 반영됨 (GitHub 호스팅).
35
37
  - `release:check-sync` 가 package.json + marketplace.json + package-lock.json 3곳 version 일치를 강제한다.
36
38
  - pypi 는 triflux 가 Python 모듈을 가지게 되면 활성화. 현 단계는 플레이스홀더.
@@ -40,12 +42,47 @@ triflux 는 아래 3채널로 동시 배포. 각 채널의 버전은 반드시
40
42
  ## 전제 조건
41
43
 
42
44
  - `~/.claude/scripts/tfx-route.sh` 불필요 (CLI 워커 호출 없음)
43
- - `gh` CLI 인증됨 (`gh auth status`)
44
- - `npm` 사용 가능 + npm login 완료
45
- - 현재 브랜치: `main` (feature 브랜치에서는 차단)
46
- - `origin/main` 과 동기화된 상태 (behind 면 먼저 pull)
45
+ - `gh` CLI 인증됨 (`gh auth status`) — `release.yml` dispatch / GitHub release 에 필요. 깨졌으면 웹 UI 대체
46
+ - **npm 인증 불필요** — npm publish 는 CI(`npm-publish.yml`)가 OIDC/`NPM_TOKEN` 으로 수행 (로컬 `npm login` 필요 없음)
47
+ - 릴리즈할 코드가 `origin/main` 에 있을 것 (CI `--ref main` 으로 origin 을 checkout)
47
48
 
48
- ## 실행 플로우
49
+ ## 기본 경로 — CI 릴리즈 (권장)
50
+
51
+ > **npm publish 는 로컬에서 하지 않는다.** GitHub Actions 가 OIDC(Trusted Publishing)로 발행한다.
52
+ > 워크플로우: `.github/workflows/{release,npm-publish,ci}.yml`
53
+
54
+ - **`release.yml`** (`workflow_dispatch`, inputs: `version`, `channel`): 한 번의 dispatch 로 prepare(테스트/버전 bump) → 태그 + GitHub release(`publish.mjs --skip-npm`) → `npm-publish.yml` dispatch → npm publish 완료 대기 → `verify.mjs` 까지 전부 CI(ubuntu, node 24)에서 수행.
55
+ - **`npm-publish.yml`** (`on: push tags ['v*']` + dispatch): `v*` 태그가 push 되면 자동 npm publish (`--provenance --access public`, `NPM_TOKEN` secret). 루트/core/triflux 3패키지 각각, 이미 게시된 버전은 skip.
56
+
57
+ ### 권장 실행 — release.yml 디스패치
58
+
59
+ ```bash
60
+ gh workflow run release.yml --ref main -f version=<X.Y.Z> -f channel=stable
61
+ # 진행 관찰
62
+ gh run list --workflow release.yml -L1
63
+ gh run watch "$(gh run list --workflow release.yml -L1 --json databaseId -q '.[0].databaseId')"
64
+ ```
65
+
66
+ - gh 인증이 없는 환경(예: SSH→m2, hosts.yml PAT 만료)이면 **gh 가 정상인 머신(m5)에서** dispatch 하거나 **GitHub 웹 UI**(Actions → release → Run workflow)로 대체.
67
+ - CHANGELOG 가 필요하면 dispatch 전에 main 에 별도 커밋으로 반영(아래 로컬 플로우 Step 4 양식 참조).
68
+
69
+ ### 경량 경로 — 태그만 push
70
+
71
+ 이미 검증된 hotfix 등 prepare/verify 없이 npm publish 만 트리거하려면:
72
+
73
+ ```bash
74
+ # main 에서 bump 커밋(버전 3곳 동기화) 후
75
+ git tag v<X.Y.Z> && git push origin v<X.Y.Z> # → npm-publish.yml 자동 발동
76
+ ```
77
+
78
+ > ⚠️ 이 경로는 GitHub release / verify 를 건너뛴다. 버전 동기화(package.json + marketplace.json + lock)는 직접 보장.
79
+
80
+ ---
81
+
82
+ ## 로컬 수동 플로우 (폴백 — CI 불가 시에만)
83
+
84
+ > CI 가 정석이다. 아래는 Actions 가 막혔거나 로컬 디버깅 시의 수동 절차다.
85
+ > **Step 10 의 로컬 `npm publish` 는 CI 와 중복이므로 평상시 실행 금지.**
49
86
 
50
87
  ### Step 0 — 환경 확인
51
88
 
@@ -205,21 +242,15 @@ gh release create "v${TARGET_VERSION}" \
205
242
  - 노트 본문 검증: Co-Authored-By / AI trailer 포함됐는지 grep 후 제거
206
243
  - `--draft` 로 초안 생성 후 수동 publish 도 가능 (안전 모드)
207
244
 
208
- ### Step 10 — npm publish
245
+ ### Step 10 — npm publish (= CI 가 수행, 로컬 금지가 기본)
209
246
 
210
- `--no-publish` 플래그 없으면 **AskUserQuestion**:
247
+ > **평상시 로컬에서 `npm publish` 하지 않는다.** Step 8 에서 `git push origin v<X.Y.Z>` 로 태그가 올라가면 `npm-publish.yml` OIDC 로 자동 발행한다.
248
+ > CI run 관찰: `gh run list --workflow npm-publish.yml -L1` → `gh run watch <id>`
211
249
 
212
- ```
213
- npm registry 에 배포하시겠습니까?
214
-
215
- A) node scripts/release/publish.mjs --execute
216
- B) dry-run 으로 먼저 검증 (node scripts/release/publish.mjs --dry-run)
217
- C) 건너뜀 (수동 배포)
218
- ```
250
+ CI 가 완전히 불가능한 비상시에만, npm 인증을 갖춘 환경에서 수동 폴백:
219
251
 
220
- 선택 A:
221
252
  ```bash
222
- node scripts/release/publish.mjs --execute
253
+ node scripts/release/publish.mjs --execute # 비상 폴백 전용 — 중복 publish 주의
223
254
  ```
224
255
 
225
256
  ### Step 10.5 — pypi publish (future, 현재 비활성)
@@ -277,14 +308,14 @@ github: https://github.com/tellang/triflux/releases/tag/v${TARGET_VERSION}
277
308
  | Step 7 | commit 메시지에 AI trailer 감지 | 하드 차단 + 재작성 요청 |
278
309
  | Step 8 | push 거부 (remote 변경됨) | `git pull --rebase origin main` 후 재시도 |
279
310
  | Step 9 | gh release create 실패 | `gh auth status` 확인, 수동 재시도 |
280
- | Step 10 | npm publish 실패 | `npm login` 확인, 수동 재시도 |
311
+ | Step 10 | npm publish(CI) 실패 | `gh run view <id> --log-failed` 로 `npm-publish.yml` 로그 확인. `NPM_TOKEN` secret / OIDC 설정 점검 |
281
312
 
282
313
  ## 플래그
283
314
 
284
315
  | 플래그 | 동작 |
285
316
  |--------|------|
286
317
  | `--skip-tests` | Step 5 의 `npm test` 건너뜀. stderr 경고 출력. 긴급 hotfix 전용 |
287
- | `--no-publish` | Step 10 `npm publish` 건너뜀. git push + GitHub release 만 |
318
+ | `--no-publish` | 태그를 push 하지 않아 `npm-publish.yml` 미발동. main 커밋 + (수동) GitHub release 만 |
288
319
  | `--dry-run` | 모든 git push / publish 호출을 출력만 하고 skip. 검증 전용 |
289
320
 
290
321
  ## AI trailer 방지 상세
@@ -313,6 +344,6 @@ github: https://github.com/tellang/triflux/releases/tag/v${TARGET_VERSION}
313
344
 
314
345
  - 버전 불일치: `npm run release:check-sync --fix`
315
346
  - pack CRLF 경고: 실제 변경 파일만 선별 `git add packages/triflux/...`
316
- - gh CLI 미인증: `gh auth login`
317
- - npm login 필요: `npm login`
347
+ - gh CLI 미인증: `gh auth login` (또는 gh 정상인 머신/웹 UI 로 release.yml dispatch)
348
+ - npm publish CI(`npm-publish.yml`, OIDC)가 수행 — 로컬 `npm login` 불필요. CI 실패 시 `gh run view <id> --log-failed`
318
349
  - prepare.mjs stall: `scripts/release/prepare.mjs` 가 `stdio: ["ignore","pipe","pipe"]` + 10분 timeout 적용됨 (v10.9.32 fix 739da2d)