triflux 10.47.0 → 10.48.0

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
@@ -1,13 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } from "node:child_process";
3
3
  import { mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import {
5
+ mkdir,
6
+ open,
7
+ readdir,
8
+ readFile,
9
+ rm,
10
+ writeFile,
11
+ } from "node:fs/promises";
5
12
  import { homedir, tmpdir } from "node:os";
6
13
  import { join as pathJoin, resolve as pathResolve } from "node:path";
7
14
  import { fileURLToPath } from "node:url";
8
15
  import { promisify } from "node:util";
9
16
  import { runHygiene } from "../cto/hygiene.mjs";
10
17
  import { notifyCtoHygieneOnce } from "../cto/hygiene-notify.mjs";
18
+ import {
19
+ codexThreadNames,
20
+ readCodexSessionRecords,
21
+ registryDir,
22
+ } from "../hub/lib/codex-session-registry.mjs";
11
23
  import { resolveHardCeilingMs } from "../hub/lib/worker-lifecycle.mjs";
12
24
  import { createNotifier } from "../hub/team/notify.mjs";
13
25
  import {
@@ -58,7 +70,8 @@ function usage() {
58
70
  " tfx-live interrupt --session NAME [--cli codex|claude] [--transport tmux|uds|auto] [--short SHORT | --session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 5]",
59
71
  " tfx-live stop --session NAME [--cli codex|claude] [--remote HOST]",
60
72
  " tfx-live probe [--short SHORT] [--session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 10]",
61
- " tfx-live list-sessions --cli codex [--transport tmux|uds] [--codex-socket PATH|default] [--cwd DIR] [--remote HOST (tmux)]",
73
+ " tfx-live list-sessions --cli codex|claude [--transport tmux|uds] [--codex-socket PATH|default] [--cwd DIR] [--remote HOST (codex tmux only)]",
74
+ " Claude list-sessions supports local tmux only; --transport uds requires --cli codex.",
62
75
  " tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
63
76
  " 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]",
64
77
  " tfx-live peer [--cli-a codex] [--cli-b claude] [--model-a ID] [--model-b ID] [--effort-a TIER] [--effort-b TIER] [--session-a NAME[:WINDOW.PANE]] [--session-b NAME[:WINDOW.PANE]] [--attach-a] [--attach-b] [--transport-a tmux|uds|auto] [--transport-b tmux|uds|auto] [--short-a SHORT] [--short-b SHORT] [--session-id-a ID] [--session-id-b ID] [--thread-a ID|auto] [--thread-b ID|auto] [--codex-socket-a PATH|default] [--codex-socket-b PATH|default] [--bridge ABS] [--remote HOST] [--cwd DIR] [--if-busy wait|fail] [--if-busy-a POLICY] [--if-busy-b POLICY] [--busy-timeout 60] [--max-turn SECONDS] [--rounds 4] [--mode counting|freeform] [--seed TEXT] [--timeout 60]",
@@ -538,7 +551,26 @@ async function inspectCodexTmuxPane(pane, deps = {}) {
538
551
  };
539
552
  }
540
553
 
541
- function parseCodexTmuxSessions(stdout, cwd = null, codexTargets = null) {
554
+ const TMUX_DISCOVERY_FORMAT = [
555
+ "#{session_name}",
556
+ "#{session_created}",
557
+ "#{session_attached}",
558
+ "#{window_index}",
559
+ "#{pane_index}",
560
+ "#{pane_current_path}",
561
+ "#{pane_current_command}",
562
+ "#{pane_start_command}",
563
+ "#{pane_tty}",
564
+ "#{pane_pid}",
565
+ "#{pane_id}",
566
+ ].join("\t");
567
+
568
+ function parseCodexTmuxSessions(
569
+ stdout,
570
+ cwd = null,
571
+ codexTargets = null,
572
+ paneThreads = new Map(),
573
+ ) {
542
574
  const cwdFilter = normalizeCwd(cwd);
543
575
  const sessions = new Map();
544
576
 
@@ -553,6 +585,9 @@ function parseCodexTmuxSessions(stdout, cwd = null, codexTargets = null) {
553
585
  paneCwd,
554
586
  currentCommand,
555
587
  startCommand,
588
+ ,
589
+ ,
590
+ paneId,
556
591
  ] = line.split("\t");
557
592
  if (
558
593
  !session ||
@@ -577,6 +612,7 @@ function parseCodexTmuxSessions(stdout, cwd = null, codexTargets = null) {
577
612
  target: `${session}:${windowIndex}.${paneIndex}`,
578
613
  cwd: normalizedPaneCwd,
579
614
  command: currentCommand,
615
+ ...(paneThreads.get(paneId) ?? {}),
580
616
  };
581
617
  if (existing) {
582
618
  if (!existing.cwd && normalizedPaneCwd) {
@@ -608,27 +644,15 @@ async function discoverCodexTmuxSessions(
608
644
  { cwd = null, remote = null } = {},
609
645
  deps = {},
610
646
  ) {
611
- const format = [
612
- "#{session_name}",
613
- "#{session_created}",
614
- "#{session_attached}",
615
- "#{window_index}",
616
- "#{pane_index}",
617
- "#{pane_current_path}",
618
- "#{pane_current_command}",
619
- "#{pane_start_command}",
620
- "#{pane_tty}",
621
- "#{pane_pid}",
622
- ].join("\t");
623
-
624
647
  try {
625
648
  const { stdout } = await (deps.runTmux ?? runTmux)(remote, [
626
649
  "list-panes",
627
650
  "-a",
628
651
  "-F",
629
- format,
652
+ TMUX_DISCOVERY_FORMAT,
630
653
  ]);
631
654
  const codexTargets = new Set();
655
+ const panePids = new Map();
632
656
  for (const line of String(stdout).split(/\r?\n/)) {
633
657
  if (!line) continue;
634
658
  const [
@@ -642,18 +666,62 @@ async function discoverCodexTmuxSessions(
642
666
  startCommand,
643
667
  paneTty,
644
668
  panePid,
669
+ paneId,
645
670
  ] = line.split("\t");
671
+ panePids.set(paneId, Number(panePid));
646
672
  const result = await inspectCodexTmuxPane(
647
673
  { currentCommand, startCommand, paneTty, panePid, remote },
648
674
  deps,
649
675
  );
650
676
  if (result.isCodex) codexTargets.add(`${session}:${window}.${pane}`);
651
677
  }
678
+ const paneThreads = new Map();
679
+ if (!remote) {
680
+ const env = deps.env ?? process.env;
681
+ const records = (deps.readCodexSessionRecords ?? readCodexSessionRecords)(
682
+ {
683
+ dir: registryDir(env),
684
+ },
685
+ );
686
+ const parents = new Map();
687
+ try {
688
+ const processes = await (deps.psExec ?? execFileAsync)(
689
+ "ps",
690
+ ["-eo", "pid=,ppid="],
691
+ { timeout: 1000, maxBuffer: MAX_BUFFER },
692
+ );
693
+ for (const line of String(processes.stdout).split(/\r?\n/)) {
694
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s*$/);
695
+ if (match) parents.set(Number(match[1]), Number(match[2]));
696
+ }
697
+ } catch {
698
+ // Without ancestry evidence only the pane process itself can match.
699
+ }
700
+ const names = codexThreadNames({
701
+ indexPath: pathJoin(
702
+ env.CODEX_HOME || pathJoin(env.HOME || homedir(), ".codex"),
703
+ "session_index.jsonl",
704
+ ),
705
+ });
706
+ for (const record of records) {
707
+ const panePid = panePids.get(record.tmuxPane);
708
+ if (!Number.isSafeInteger(panePid) || panePid <= 0) continue;
709
+ let ancestor = record.pid;
710
+ for (let depth = 0; depth < 8 && ancestor !== panePid; depth++) {
711
+ ancestor = parents.get(ancestor);
712
+ }
713
+ if (ancestor !== panePid) continue;
714
+ paneThreads.set(record.tmuxPane, {
715
+ threadId: record.sessionId,
716
+ name: names.get(record.sessionId) ?? null,
717
+ });
718
+ }
719
+ }
652
720
  return {
653
721
  ok: true,
654
722
  cli: "codex",
655
723
  cwd: normalizeCwd(cwd),
656
- sessions: parseCodexTmuxSessions(stdout, cwd, codexTargets),
724
+ sessions: parseCodexTmuxSessions(stdout, cwd, codexTargets, paneThreads),
657
725
  };
658
726
  } catch (error) {
659
727
  return {
@@ -667,6 +735,185 @@ async function discoverCodexTmuxSessions(
667
735
  }
668
736
  }
669
737
 
738
+ function isSessionPidAlive(pid) {
739
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
740
+ try {
741
+ process.kill(pid, 0);
742
+ return true;
743
+ } catch (error) {
744
+ return error.code === "EPERM";
745
+ }
746
+ }
747
+
748
+ async function claudeConversationTitle(sessionId, env) {
749
+ if (!sessionId || /[/\\]/.test(sessionId)) return null;
750
+ const projectsDir =
751
+ env.TFX_CLAUDE_PROJECTS_DIR ||
752
+ pathJoin(env.HOME || homedir(), ".claude", "projects");
753
+ let projects;
754
+ try {
755
+ projects = await readdir(projectsDir);
756
+ } catch {
757
+ return null;
758
+ }
759
+ for (const project of projects.sort()) {
760
+ let handle;
761
+ try {
762
+ handle = await open(
763
+ pathJoin(projectsDir, project, `${sessionId}.jsonl`),
764
+ "r",
765
+ );
766
+ const stat = await handle.stat();
767
+ if (!stat.isFile()) continue;
768
+ const length = Math.min(stat.size, 262144);
769
+ const buffer = Buffer.alloc(length);
770
+ const start = stat.size - length;
771
+ let total = 0;
772
+ while (total < length) {
773
+ const { bytesRead } = await handle.read(
774
+ buffer,
775
+ total,
776
+ length - total,
777
+ start + total,
778
+ );
779
+ if (!bytesRead) break;
780
+ total += bytesRead;
781
+ }
782
+ const lines = buffer.subarray(0, total).toString("utf8").split("\n");
783
+ for (let i = lines.length - 1; i >= 0; i--) {
784
+ try {
785
+ const record = JSON.parse(lines[i]);
786
+ if (record?.type !== "custom-title" && record?.type !== "ai-title")
787
+ continue;
788
+ const title = record.customTitle ?? record.aiTitle;
789
+ if (typeof title === "string" && title.trim()) return title.trim();
790
+ } catch {
791
+ // The tail may start mid-line or end with a concurrent partial write.
792
+ }
793
+ }
794
+ } catch {
795
+ // Missing or unreadable transcripts cannot break session discovery.
796
+ } finally {
797
+ await handle?.close().catch(() => {});
798
+ }
799
+ }
800
+ return null;
801
+ }
802
+
803
+ async function discoverClaudeTmuxSessions({ cwd = null } = {}, deps = {}) {
804
+ const result = {
805
+ ok: true,
806
+ cli: "claude",
807
+ cwd: normalizeCwd(cwd),
808
+ sessions: [],
809
+ };
810
+ let stdout;
811
+ try {
812
+ ({ stdout } = await (deps.runTmux ?? runTmux)(null, [
813
+ "list-panes",
814
+ "-a",
815
+ "-F",
816
+ TMUX_DISCOVERY_FORMAT,
817
+ ]));
818
+ } catch (error) {
819
+ return {
820
+ ...result,
821
+ ok: false,
822
+ reason: "tmux-unavailable",
823
+ error: error.message,
824
+ };
825
+ }
826
+ const panes = new Map();
827
+ for (const line of String(stdout).split(/\r?\n/)) {
828
+ const [session, , , window, pane, paneCwd, , , , pid, paneId] =
829
+ line.split("\t");
830
+ if (paneId)
831
+ panes.set(paneId, {
832
+ session,
833
+ target: `${session}:${window}.${pane}`,
834
+ cwd: normalizeCwd(paneCwd),
835
+ pid: Number(pid),
836
+ });
837
+ }
838
+ const parents = new Map();
839
+ try {
840
+ const processes = await (deps.psExec ?? execFileAsync)(
841
+ "ps",
842
+ ["-eo", "pid=,ppid="],
843
+ {
844
+ timeout: 1000,
845
+ maxBuffer: MAX_BUFFER,
846
+ },
847
+ );
848
+ for (const line of String(processes.stdout).split(/\r?\n/)) {
849
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s*$/);
850
+ if (match) parents.set(Number(match[1]), Number(match[2]));
851
+ }
852
+ } catch {
853
+ // Without ancestry evidence only the pane process itself can match.
854
+ }
855
+ const env = deps.env ?? process.env;
856
+ const dir =
857
+ env.TFX_CLAUDE_SESSIONS_DIR ||
858
+ pathJoin(env.HOME || homedir(), ".claude", "sessions");
859
+ let files;
860
+ try {
861
+ files = await readdir(dir);
862
+ } catch {
863
+ return result;
864
+ }
865
+ for (const file of files.sort()) {
866
+ if (!file.endsWith(".json")) continue;
867
+ try {
868
+ const record = JSON.parse(await readFile(pathJoin(dir, file), "utf8"));
869
+ // Match from the right: session names may themselves contain ':' and '.'.
870
+ const coordinate =
871
+ typeof record.tmux === "string"
872
+ ? record.tmux.match(/^(.*):@(\d+)\.(%\d+)$/)
873
+ : null;
874
+ if (
875
+ !coordinate ||
876
+ typeof record.sessionId !== "string" ||
877
+ !record.sessionId ||
878
+ !Number.isSafeInteger(record.pid) ||
879
+ record.pid <= 0
880
+ )
881
+ continue;
882
+ const pane = panes.get(coordinate[3]);
883
+ if (!pane || (result.cwd && pane.cwd !== result.cwd)) continue;
884
+ if (!(deps.isAlive ?? isSessionPidAlive)(record.pid)) continue;
885
+ let ancestor = record.pid;
886
+ for (let depth = 0; depth < 4 && ancestor !== pane.pid; depth++) {
887
+ ancestor = parents.get(ancestor);
888
+ }
889
+ if (ancestor !== pane.pid) continue;
890
+ const name = record.name ?? null;
891
+ const title =
892
+ record.nameSource === "derived" ||
893
+ !name ||
894
+ (typeof name === "string" && !name.trim())
895
+ ? await claudeConversationTitle(record.sessionId, env)
896
+ : name;
897
+ result.sessions.push({
898
+ session: pane.session,
899
+ target: pane.target,
900
+ paneId: coordinate[3],
901
+ cwd: pane.cwd,
902
+ pid: record.pid,
903
+ sessionId: record.sessionId,
904
+ short: record.sessionId.slice(0, 8),
905
+ name,
906
+ title,
907
+ nameSource: record.nameSource ?? null,
908
+ status: record.status ?? null,
909
+ });
910
+ } catch {
911
+ // A malformed or concurrently removed record cannot break discovery.
912
+ }
913
+ }
914
+ return result;
915
+ }
916
+
670
917
  function sleep(ms) {
671
918
  return new Promise((resolve) => setTimeout(resolve, ms));
672
919
  }
@@ -2536,8 +2783,20 @@ async function probe(flags) {
2536
2783
 
2537
2784
  async function listSessions(flags) {
2538
2785
  const adapter = selectAdapter(flags);
2786
+ if (adapter.cli === "claude") {
2787
+ if (flags.remote || flags.transport === "uds") {
2788
+ throw new Error(
2789
+ "Claude list-sessions supports local tmux only; --remote and --transport uds are unavailable",
2790
+ );
2791
+ }
2792
+ if (flags.transport && !["tmux", "auto"].includes(flags.transport)) {
2793
+ throw new Error("Claude list-sessions --transport must be tmux or auto");
2794
+ }
2795
+ printJson(await discoverClaudeTmuxSessions({ cwd: flags.cwd }));
2796
+ return;
2797
+ }
2539
2798
  if (adapter.cli !== "codex") {
2540
- throw new Error("list-sessions currently supports only --cli codex");
2799
+ throw new Error("list-sessions supports --cli codex or --cli claude");
2541
2800
  }
2542
2801
  if (flags.transport === "uds") {
2543
2802
  const { listCodexAppServerThreads } = await import(
@@ -3550,6 +3809,7 @@ export {
3550
3809
  createPeerSignalController,
3551
3810
  ctoHygieneNotify,
3552
3811
  derivePeerStatus,
3812
+ discoverClaudeTmuxSessions,
3553
3813
  discoverCodexTmuxSessions,
3554
3814
  doAskViaTmux,
3555
3815
  extractAssistantResponse,
@@ -109,7 +109,7 @@
109
109
  "fallback": {
110
110
  "default_mode": "codex-default",
111
111
  "default_cli": "codex",
112
- "default_model": "gpt-6-sol",
112
+ "default_model": "gpt-6.1-sol",
113
113
  "triggers": [
114
114
  "hud_null",
115
115
  "hud_stale_gte_15min",
@@ -3,6 +3,7 @@
3
3
  import { spawn } from "node:child_process";
4
4
  import { argv, exit, stdin, stdout } from "node:process";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { writeCodexSessionRecord } from "../hub/lib/codex-session-registry.mjs";
6
7
  import { drainPendingSynapse as defaultDrainPendingSynapse } from "../hub/team/synapse-http.mjs";
7
8
  import {
8
9
  heartbeatInteractiveSession as defaultHeartbeatInteractiveSession,
@@ -145,6 +146,7 @@ export async function runCodexSessionHook(stdinData, opts = {}) {
145
146
  const mode = parsed.ok
146
147
  ? normalizeMode(opts.argvMode ?? argv[2], parsed.payload)
147
148
  : "";
149
+ const writeSessionRecord = opts.writeSessionRecord || writeCodexSessionRecord;
148
150
  const hubEnsureRun = opts.hubEnsureRun || defaultHubEnsureRun;
149
151
  const registerInteractiveSession =
150
152
  opts.registerInteractiveSession || defaultRegisterInteractiveSession;
@@ -157,6 +159,11 @@ export async function runCodexSessionHook(stdinData, opts = {}) {
157
159
 
158
160
  try {
159
161
  await runHookSideEffectsWithStdoutSuppressed(async () => {
162
+ if (mode === "register" || mode === "heartbeat") {
163
+ try {
164
+ writeSessionRecord(parsed.payload);
165
+ } catch {}
166
+ }
160
167
  if (mode === "register") {
161
168
  try {
162
169
  launchPresenceRegistration(parsed.payload);
@@ -45,7 +45,7 @@ function makeFallback(policy, trigger, inputsHash, now) {
45
45
  {
46
46
  cli: fb.default_cli ?? "codex",
47
47
  accountId: null,
48
- model: fb.default_model ?? "gpt-6-sol",
48
+ model: fb.default_model ?? "gpt-6.1-sol",
49
49
  riskTier: "safe",
50
50
  reason: trigger,
51
51
  },