triflux 10.60.0 → 10.61.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.
@@ -29,6 +29,8 @@ tfx-live ask --cli codex --session cx1 --prompt "현재 변경사항을 요약
29
29
  tfx-live stop --cli codex --session cx1
30
30
  ```
31
31
 
32
+ macOS의 iTerm2에서 로컬 tmux 세션을 `start`하면 리드가 있는 창에 attach 탭이나 패인을 연다. `LC_TERMINAL=iTerm2` 또는 `TERM_PROGRAM=iTerm.app`일 때만 적용하며, 원격과 tmux 세션이 없는 daemon 경로는 제외한다. 같은 `--group NAME`은 한 탭에서 세로로 나누고 다른 그룹은 새 탭을 연다. `--group`을 생략하면 대상 CLI인 `claude`, `codex`, `agy`를 그룹으로 쓴다. `stop --session`은 tmux 종료 뒤 남은 iTerm 패인도 닫는다. 연동이 실패해도 세션 시작과 종료는 계속하며 결과의 `itermWarning`에 원인을 남긴다. 탭 제목은 iTerm2 Python 런타임으로 고정하며 처음 쓸 때 iTerm2 Scripts 폴더에 스크립트 하나를 설치하고, 런타임이 없으면 제목만 바뀌지 않는다.
33
+
32
34
  새 세션 이름은 `<월>.<일> <주제>`로 짓는다. `start --name`을 생략하면 자동으로 만들고 `nameGenerated: true`를 보고한다. `--resume` 또는 `--resume-last`는 `--name`을 명시한 경우에만 이름을 적용한다. 승계 세션에는 기존 이름 뒤에 ` 2`, 이후 ` 3`을 붙인다. 이름 적용을 요청한 경우 결과의 `nameApplied: false`는 미적용을 뜻한다. agy의 `namePending: true`는 첫 일반 ask에서 적용할 이름이 보류됐다는 뜻이다. Codex `start`는 `/rename` 제출을 입력창이 빌 때까지 확인하고, 이름으로 찾은 thread UUID를 `threadId`로 돌려주며 tmux 세션 옵션 `@tfx_codex_thread`에 남긴다. 로컬 Codex `--resume-last`는 Codex가 고른 thread를 사후에 찾는다. 띄운 뒤 공유 app-server daemon에 로드된 같은 cwd의 thread 중 rollout에 그 시각 이후의 resume 기록(`thread_settings_applied`)이 생긴 것이 정확히 하나이고 1.5초 뒤에도 같을 때만 `threadId`로 쓴다. 이 확인은 5초 안에 끝난다. TUI가 daemon을 쓰지 않거나 같은 cwd에서 동시에 resume이 일어나면 `null`이다. 기존 Codex UDS thread는 `tfx-live rename --cli codex --transport uds --thread ID --name "<이름>"`으로 이름을 바꾼다.
33
35
 
34
36
  ## agy 세션
package/bin/tfx-live.mjs CHANGED
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
5
  import {
6
+ chmod,
6
7
  mkdir,
7
8
  open,
8
9
  readdir,
@@ -93,7 +94,7 @@ const BUG_REPORT_DIR =
93
94
  function usage(command) {
94
95
  const lines = [
95
96
  "Usage:",
96
- " tfx-live start --session NAME [--name NAME] [--cli codex|claude|agy] [--sandbox] [--unset-env PATTERNS] [--model ID] [--effort TIER] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
97
+ " tfx-live start --session NAME [--name NAME] [--group NAME] [--cli codex|claude|agy] [--sandbox] [--unset-env PATTERNS] [--model ID] [--effort TIER] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
97
98
  " tfx-live ask --session NAME[:WINDOW.PANE] --prompt TEXT [--cli codex|claude|agy] [--if-busy wait|fail|interrupt|queue] [--busy-timeout 60] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
98
99
  " tfx-live ask --cli codex [--transport queue|tmux] (--session NAME[:WINDOW.PANE] | --thread UUID) --prompt TEXT [--from NAME] [--timeout 60] [--no-wait]",
99
100
  " Codex ask defaults to the app-server queue (thread/queue/add, `codex queue --message` only when add is unavailable; delivered when the TUI picks it up); tmux paste is the reported fallback and the path for slash commands.",
@@ -2470,6 +2471,151 @@ async function resumedCodexThread(
2470
2471
  return matches.length === 1 ? matches[0] : null;
2471
2472
  }
2472
2473
 
2474
+ const ITERM_TITLE_SCRIPT_NAME = "com.triflux.live-title.py";
2475
+ const ITERM_TITLE_SCRIPT = String.raw`#!/usr/bin/env python3
2476
+ import sys
2477
+
2478
+ import iterm2
2479
+
2480
+
2481
+ async def main(connection):
2482
+ session_id, group, name = sys.argv[1:]
2483
+ app = await iterm2.async_get_app(connection)
2484
+ session = app.get_session_by_id(session_id)
2485
+ if session is None:
2486
+ raise RuntimeError(f"iTerm session not found: {session_id}")
2487
+ update = iterm2.LocalWriteOnlyProfile()
2488
+ update.set_allow_title_setting(False)
2489
+ update.set_name(name)
2490
+ await session.async_set_profile_properties(update)
2491
+ await session.tab.async_set_variable("user.tfxLiveGroup", group)
2492
+ await session.tab.async_set_title(r"\(user.tfxLiveGroup)")
2493
+ await session.async_set_variable("user.tfxLiveTitleApplied", "1")
2494
+
2495
+
2496
+ iterm2.run_until_complete(main)
2497
+ `;
2498
+
2499
+ function appleScriptString(value) {
2500
+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value))
2501
+ throw new Error("iTerm values must not contain control characters");
2502
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2503
+ }
2504
+
2505
+ function shouldUseIterm(
2506
+ remote,
2507
+ platform = process.platform,
2508
+ env = process.env,
2509
+ ) {
2510
+ return (
2511
+ !remote &&
2512
+ platform === "darwin" &&
2513
+ (env.LC_TERMINAL === "iTerm2" || env.TERM_PROGRAM === "iTerm.app")
2514
+ );
2515
+ }
2516
+
2517
+ async function openItermSession(session, group) {
2518
+ const groupLiteral = appleScriptString(group);
2519
+ const sessionLiteral = appleScriptString(session);
2520
+ const leaderId = appleScriptString(
2521
+ process.env.ITERM_SESSION_ID?.split(":")[1] ?? "",
2522
+ );
2523
+ // tmux 안의 리드는 ITERM_SESSION_ID 가 tmux 를 처음 띄운 터미널 값이라, 지금 붙어 있는 클라이언트 tty 로 창을 찾는다.
2524
+ const clients = process.env.TMUX_PANE
2525
+ ? await runTmux(null, [
2526
+ "list-clients",
2527
+ "-t",
2528
+ process.env.TMUX_PANE,
2529
+ "-F",
2530
+ "#{client_activity} #{client_tty}",
2531
+ ]).catch(() => ({ stdout: "" }))
2532
+ : { stdout: "" };
2533
+ const leaderTty = appleScriptString(
2534
+ clients.stdout
2535
+ .trim()
2536
+ .split("\n")
2537
+ .filter(Boolean)
2538
+ .map((line) => line.split(" "))
2539
+ .sort((a, b) => Number(b[0]) - Number(a[0]))[0]?.[1] ?? "",
2540
+ );
2541
+ const { stdout: tmuxPath } = await execFileAsync("which", ["tmux"], {
2542
+ timeout: 2000,
2543
+ });
2544
+ const command = appleScriptString(
2545
+ `${shellQuote([realpathSync(tmuxPath.trim()), "attach-session", "-t"])} '=${session.replace(/'/g, `'"'"'`)}'`,
2546
+ );
2547
+ const script = `tell application "iTerm2"
2548
+ set leaderWindow to current window
2549
+ set ttyWindow to missing value
2550
+ set idWindow to missing value
2551
+ repeat with candidateWindow in windows
2552
+ repeat with candidateTab in tabs of candidateWindow
2553
+ repeat with candidateSession in sessions of candidateTab
2554
+ if ${leaderTty} is not "" and tty of candidateSession is ${leaderTty} then set ttyWindow to candidateWindow
2555
+ if ${leaderId} is not "" and id of candidateSession is ${leaderId} then set idWindow to candidateWindow
2556
+ end repeat
2557
+ end repeat
2558
+ end repeat
2559
+ if ttyWindow is not missing value then
2560
+ set leaderWindow to ttyWindow
2561
+ else if idWindow is not missing value then
2562
+ set leaderWindow to idWindow
2563
+ end if
2564
+ set groupSession to missing value
2565
+ repeat with candidateTab in tabs of leaderWindow
2566
+ repeat with candidateSession in sessions of candidateTab
2567
+ try
2568
+ tell candidateSession to set candidateGroup to variable named "user.tfx_group"
2569
+ if candidateGroup is ${groupLiteral} then
2570
+ set groupSession to candidateSession
2571
+ exit repeat
2572
+ end if
2573
+ end try
2574
+ end repeat
2575
+ if groupSession is not missing value then exit repeat
2576
+ end repeat
2577
+ set didSplit to groupSession is not missing value
2578
+ if didSplit then
2579
+ tell groupSession to set newSession to split vertically with default profile command ${command}
2580
+ else
2581
+ tell leaderWindow to set newTab to create tab with default profile command ${command}
2582
+ set newSession to current session of newTab
2583
+ end if
2584
+ tell newSession
2585
+ set variable named "user.tfx_group" to ${groupLiteral}
2586
+ set variable named "user.tfx_session" to ${sessionLiteral}
2587
+ end tell
2588
+ return (id of newSession) & linefeed & (didSplit as text)
2589
+ end tell`;
2590
+ const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", script], {
2591
+ timeout: 5000,
2592
+ maxBuffer: 64 * 1024,
2593
+ });
2594
+ const [sessionId, split] = stdout.trim().split("\n");
2595
+ if (!sessionId || !["true", "false"].includes(split))
2596
+ throw new Error("iTerm did not return a session ID and split status");
2597
+ return { sessionId, group, split: split === "true" };
2598
+ }
2599
+
2600
+ async function closeItermSession(sessionId) {
2601
+ const script = `tell application "iTerm2"
2602
+ repeat with candidateWindow in windows
2603
+ repeat with candidateTab in tabs of candidateWindow
2604
+ repeat with candidateSession in sessions of candidateTab
2605
+ if id of candidateSession is ${appleScriptString(sessionId)} then
2606
+ tell candidateSession to close
2607
+ return
2608
+ end if
2609
+ end repeat
2610
+ end repeat
2611
+ end repeat
2612
+ end tell`;
2613
+ await execFileAsync("/usr/bin/osascript", ["-e", script], {
2614
+ timeout: 5000,
2615
+ maxBuffer: 64 * 1024,
2616
+ });
2617
+ }
2618
+
2473
2619
  async function doStart(adapter, opts) {
2474
2620
  const {
2475
2621
  session,
@@ -2657,6 +2803,66 @@ async function doStart(adapter, opts) {
2657
2803
  "@tfx_codex_thread",
2658
2804
  threadId,
2659
2805
  ]).catch(() => {});
2806
+ let iterm;
2807
+ let itermWarning;
2808
+ if (ready && shouldUseIterm(remote)) {
2809
+ try {
2810
+ iterm = await openItermSession(session, opts.group || adapter.cli);
2811
+ await runTmux(null, [
2812
+ "set-option",
2813
+ "-t",
2814
+ session,
2815
+ "@tfx_iterm_session",
2816
+ iterm.sessionId,
2817
+ ]);
2818
+ const scriptsDir = pathJoin(
2819
+ homedir(),
2820
+ "Library",
2821
+ "Application Support",
2822
+ "iTerm2",
2823
+ "Scripts",
2824
+ );
2825
+ const scriptPath = pathJoin(scriptsDir, ITERM_TITLE_SCRIPT_NAME);
2826
+ await mkdir(scriptsDir, { recursive: true });
2827
+ const installed = await readFile(scriptPath, "utf8").catch((error) => {
2828
+ if (error.code === "ENOENT") return null;
2829
+ throw error;
2830
+ });
2831
+ if (installed !== ITERM_TITLE_SCRIPT)
2832
+ await writeFile(scriptPath, ITERM_TITLE_SCRIPT, { mode: 0o700 });
2833
+ await chmod(scriptPath, 0o700);
2834
+ const titleArgs = [iterm.sessionId, iterm.group, opts.name ?? session]
2835
+ .map(appleScriptString)
2836
+ .join(", ");
2837
+ // 절대 경로는 최초 설치 직후의 메뉴 캐시 지연을 피하고, 적용 표식은 비동기 실패를 잡는다.
2838
+ const titleScript = `tell application "iTerm2"
2839
+ set titleSession to missing value
2840
+ repeat with candidateWindow in windows
2841
+ repeat with candidateTab in tabs of candidateWindow
2842
+ repeat with candidateSession in sessions of candidateTab
2843
+ if id of candidateSession is ${appleScriptString(iterm.sessionId)} then set titleSession to candidateSession
2844
+ end repeat
2845
+ end repeat
2846
+ end repeat
2847
+ if titleSession is missing value then error "iTerm title session not found"
2848
+ launch API script named ${appleScriptString(scriptPath)} arguments {${titleArgs}}
2849
+ repeat 30 times
2850
+ try
2851
+ tell titleSession to set titleApplied to variable named "user.tfxLiveTitleApplied"
2852
+ if titleApplied is "1" then return
2853
+ end try
2854
+ delay 0.1
2855
+ end repeat
2856
+ error "iTerm title update was not confirmed; check the iTerm2 Python runtime and Script Console"
2857
+ end tell`;
2858
+ await execFileAsync("/usr/bin/osascript", ["-e", titleScript], {
2859
+ timeout: 5000,
2860
+ maxBuffer: 64 * 1024,
2861
+ });
2862
+ } catch (error) {
2863
+ itermWarning = error.message;
2864
+ }
2865
+ }
2660
2866
  return addLoginGuard(adapter, raw, {
2661
2867
  cli: adapter.cli,
2662
2868
  session,
@@ -2673,6 +2879,8 @@ async function doStart(adapter, opts) {
2673
2879
  : {}),
2674
2880
  unsetEnv: opts.unsetEnv ?? [],
2675
2881
  unsetEnvVerified,
2882
+ ...(iterm ? { iterm } : {}),
2883
+ ...(itermWarning ? { itermWarning } : {}),
2676
2884
  ...(unsetEnvWarning ? { unsetEnvWarning } : {}),
2677
2885
  ...(name && adapter.cli === "codex" && !nameApplied
2678
2886
  ? {
@@ -3567,12 +3775,36 @@ async function doStop(adapter, opts) {
3567
3775
  throw new Error(
3568
3776
  "stop --session accepts only a session name; pass the session name without a pane target",
3569
3777
  );
3778
+ let itermSessionId;
3779
+ let itermWarning;
3780
+ if (shouldUseIterm(remote)) {
3781
+ try {
3782
+ const { stdout } = await runTmux(null, [
3783
+ "show-options",
3784
+ "-qv",
3785
+ "-t",
3786
+ session,
3787
+ "@tfx_iterm_session",
3788
+ ]);
3789
+ itermSessionId = stdout.trim();
3790
+ } catch (error) {
3791
+ itermWarning = error.message;
3792
+ }
3793
+ }
3570
3794
  await runTmux(remote, ["kill-session", "-t", session]);
3795
+ if (itermSessionId) {
3796
+ try {
3797
+ await closeItermSession(itermSessionId);
3798
+ } catch (error) {
3799
+ itermWarning = error.message;
3800
+ }
3801
+ }
3571
3802
  return {
3572
3803
  cli: adapter.cli,
3573
3804
  session,
3574
3805
  remote: remote ?? null,
3575
3806
  stopped: true,
3807
+ ...(itermWarning ? { itermWarning } : {}),
3576
3808
  };
3577
3809
  }
3578
3810
 
@@ -3705,6 +3937,7 @@ function startOpts(flags) {
3705
3937
  model: flags.model,
3706
3938
  effort: flags.effort,
3707
3939
  name: flags.name,
3940
+ group: flags.group,
3708
3941
  sandbox: Object.hasOwn(flags, "sandbox"),
3709
3942
  unsetEnv,
3710
3943
  readyTimeoutMs: secondsFlag(
@@ -5147,6 +5380,7 @@ export {
5147
5380
  ADAPTERS,
5148
5381
  agyStepText,
5149
5382
  agyWaitVerdict,
5383
+ appleScriptString,
5150
5384
  buildLaunchKeys,
5151
5385
  buildPeerClosurePrompt,
5152
5386
  buildRemoteLiveCommand,
@@ -5173,6 +5407,7 @@ export {
5173
5407
  resolveAskTransport,
5174
5408
  resolveCodexDaemonSocket,
5175
5409
  resumedCodexThread,
5410
+ shouldUseIterm,
5176
5411
  splitTmuxTarget,
5177
5412
  stopOpts,
5178
5413
  tmuxBufferName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.60.0",
3
+ "version": "10.61.0",
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": {
@@ -25,6 +25,8 @@ tfx-live ask --cli codex --session cx1 --prompt "현재 변경사항을 요약
25
25
  tfx-live stop --cli codex --session cx1
26
26
  ```
27
27
 
28
+ macOS의 iTerm2에서 로컬 tmux 세션을 `start`하면 리드가 있는 창에 attach 탭이나 패인을 연다. `LC_TERMINAL=iTerm2` 또는 `TERM_PROGRAM=iTerm.app`일 때만 적용하며, 원격과 tmux 세션이 없는 daemon 경로는 제외한다. 같은 `--group NAME`은 한 탭에서 세로로 나누고 다른 그룹은 새 탭을 연다. `--group`을 생략하면 대상 CLI인 `claude`, `codex`, `agy`를 그룹으로 쓴다. `stop --session`은 tmux 종료 뒤 남은 iTerm 패인도 닫는다. 연동이 실패해도 세션 시작과 종료는 계속하며 결과의 `itermWarning`에 원인을 남긴다. 탭 제목은 iTerm2 Python 런타임으로 고정하며 처음 쓸 때 iTerm2 Scripts 폴더에 스크립트 하나를 설치하고, 런타임이 없으면 제목만 바뀌지 않는다.
29
+
28
30
  새 세션 이름은 `<월>.<일> <주제>`로 짓는다. `start --name`을 생략하면 자동으로 만들고 `nameGenerated: true`를 보고한다. `--resume` 또는 `--resume-last`는 `--name`을 명시한 경우에만 이름을 적용한다. 승계 세션에는 기존 이름 뒤에 ` 2`, 이후 ` 3`을 붙인다. 이름 적용을 요청한 경우 결과의 `nameApplied: false`는 미적용을 뜻한다. agy의 `namePending: true`는 첫 일반 ask에서 적용할 이름이 보류됐다는 뜻이다. Codex `start`는 `/rename` 제출을 입력창이 빌 때까지 확인하고, 이름으로 찾은 thread UUID를 `threadId`로 돌려주며 tmux 세션 옵션 `@tfx_codex_thread`에 남긴다. 로컬 Codex `--resume-last`는 Codex가 고른 thread를 사후에 찾는다. 띄운 뒤 공유 app-server daemon에 로드된 같은 cwd의 thread 중 rollout에 그 시각 이후의 resume 기록(`thread_settings_applied`)이 생긴 것이 정확히 하나이고 1.5초 뒤에도 같을 때만 `threadId`로 쓴다. 이 확인은 5초 안에 끝난다. TUI가 daemon을 쓰지 않거나 같은 cwd에서 동시에 resume이 일어나면 `null`이다. 기존 Codex UDS thread는 `tfx-live rename --cli codex --transport uds --thread ID --name "<이름>"`으로 이름을 바꾼다.
29
31
 
30
32
  ## agy 세션