triflux 10.60.0 → 10.61.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.
@@ -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 경로는 제외하고, ssh 로 들어온 셸처럼 리드가 이 기기의 iTerm2 창에 보이지 않으면 열지 않는다. 같은 `--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,154 @@ 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 ttyWindow to missing value
2549
+ set idWindow to missing value
2550
+ repeat with candidateWindow in windows
2551
+ repeat with candidateTab in tabs of candidateWindow
2552
+ repeat with candidateSession in sessions of candidateTab
2553
+ if ${leaderTty} is not "" and tty of candidateSession is ${leaderTty} then set ttyWindow to candidateWindow
2554
+ if ${leaderId} is not "" and id of candidateSession is ${leaderId} then set idWindow to candidateWindow
2555
+ end repeat
2556
+ end repeat
2557
+ end repeat
2558
+ -- 리드가 이 기기의 iTerm 창에 없으면(ssh 로 LC_TERMINAL 만 넘어온 경우 등) 열지 않는다.
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
+ else
2564
+ return ""
2565
+ end if
2566
+ set groupSession to missing value
2567
+ repeat with candidateTab in tabs of leaderWindow
2568
+ repeat with candidateSession in sessions of candidateTab
2569
+ try
2570
+ tell candidateSession to set candidateGroup to variable named "user.tfx_group"
2571
+ if candidateGroup is ${groupLiteral} then
2572
+ set groupSession to candidateSession
2573
+ exit repeat
2574
+ end if
2575
+ end try
2576
+ end repeat
2577
+ if groupSession is not missing value then exit repeat
2578
+ end repeat
2579
+ set didSplit to groupSession is not missing value
2580
+ if didSplit then
2581
+ tell groupSession to set newSession to split vertically with default profile command ${command}
2582
+ else
2583
+ tell leaderWindow to set newTab to create tab with default profile command ${command}
2584
+ set newSession to current session of newTab
2585
+ end if
2586
+ tell newSession
2587
+ set variable named "user.tfx_group" to ${groupLiteral}
2588
+ set variable named "user.tfx_session" to ${sessionLiteral}
2589
+ end tell
2590
+ return (id of newSession) & linefeed & (didSplit as text)
2591
+ end tell`;
2592
+ const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", script], {
2593
+ timeout: 5000,
2594
+ maxBuffer: 64 * 1024,
2595
+ });
2596
+ if (!stdout.trim()) return null;
2597
+ const [sessionId, split] = stdout.trim().split("\n");
2598
+ if (!sessionId || !["true", "false"].includes(split))
2599
+ throw new Error("iTerm did not return a session ID and split status");
2600
+ return { sessionId, group, split: split === "true" };
2601
+ }
2602
+
2603
+ async function closeItermSession(sessionId) {
2604
+ const script = `tell application "iTerm2"
2605
+ repeat with candidateWindow in windows
2606
+ repeat with candidateTab in tabs of candidateWindow
2607
+ repeat with candidateSession in sessions of candidateTab
2608
+ if id of candidateSession is ${appleScriptString(sessionId)} then
2609
+ tell candidateSession to close
2610
+ return
2611
+ end if
2612
+ end repeat
2613
+ end repeat
2614
+ end repeat
2615
+ end tell`;
2616
+ await execFileAsync("/usr/bin/osascript", ["-e", script], {
2617
+ timeout: 5000,
2618
+ maxBuffer: 64 * 1024,
2619
+ });
2620
+ }
2621
+
2473
2622
  async function doStart(adapter, opts) {
2474
2623
  const {
2475
2624
  session,
@@ -2657,6 +2806,68 @@ async function doStart(adapter, opts) {
2657
2806
  "@tfx_codex_thread",
2658
2807
  threadId,
2659
2808
  ]).catch(() => {});
2809
+ let iterm;
2810
+ let itermWarning;
2811
+ if (ready && shouldUseIterm(remote)) {
2812
+ try {
2813
+ iterm = await openItermSession(session, opts.group || adapter.cli);
2814
+ if (iterm) {
2815
+ await runTmux(null, [
2816
+ "set-option",
2817
+ "-t",
2818
+ session,
2819
+ "@tfx_iterm_session",
2820
+ iterm.sessionId,
2821
+ ]);
2822
+ const scriptsDir = pathJoin(
2823
+ homedir(),
2824
+ "Library",
2825
+ "Application Support",
2826
+ "iTerm2",
2827
+ "Scripts",
2828
+ );
2829
+ const scriptPath = pathJoin(scriptsDir, ITERM_TITLE_SCRIPT_NAME);
2830
+ await mkdir(scriptsDir, { recursive: true });
2831
+ const installed = await readFile(scriptPath, "utf8").catch((error) => {
2832
+ if (error.code === "ENOENT") return null;
2833
+ throw error;
2834
+ });
2835
+ if (installed !== ITERM_TITLE_SCRIPT)
2836
+ await writeFile(scriptPath, ITERM_TITLE_SCRIPT, { mode: 0o700 });
2837
+ await chmod(scriptPath, 0o700);
2838
+ const titleArgs = [iterm.sessionId, iterm.group, opts.name ?? session]
2839
+ .map(appleScriptString)
2840
+ .join(", ");
2841
+ // 절대 경로는 최초 설치 직후의 메뉴 캐시 지연을 피하고, 적용 표식은 비동기 실패를 잡는다.
2842
+ const titleScript = `tell application "iTerm2"
2843
+ set titleSession to missing value
2844
+ repeat with candidateWindow in windows
2845
+ repeat with candidateTab in tabs of candidateWindow
2846
+ repeat with candidateSession in sessions of candidateTab
2847
+ if id of candidateSession is ${appleScriptString(iterm.sessionId)} then set titleSession to candidateSession
2848
+ end repeat
2849
+ end repeat
2850
+ end repeat
2851
+ if titleSession is missing value then error "iTerm title session not found"
2852
+ launch API script named ${appleScriptString(scriptPath)} arguments {${titleArgs}}
2853
+ repeat 30 times
2854
+ try
2855
+ tell titleSession to set titleApplied to variable named "user.tfxLiveTitleApplied"
2856
+ if titleApplied is "1" then return
2857
+ end try
2858
+ delay 0.1
2859
+ end repeat
2860
+ error "iTerm title update was not confirmed; check the iTerm2 Python runtime and Script Console"
2861
+ end tell`;
2862
+ await execFileAsync("/usr/bin/osascript", ["-e", titleScript], {
2863
+ timeout: 5000,
2864
+ maxBuffer: 64 * 1024,
2865
+ });
2866
+ }
2867
+ } catch (error) {
2868
+ itermWarning = error.message;
2869
+ }
2870
+ }
2660
2871
  return addLoginGuard(adapter, raw, {
2661
2872
  cli: adapter.cli,
2662
2873
  session,
@@ -2673,6 +2884,8 @@ async function doStart(adapter, opts) {
2673
2884
  : {}),
2674
2885
  unsetEnv: opts.unsetEnv ?? [],
2675
2886
  unsetEnvVerified,
2887
+ ...(iterm ? { iterm } : {}),
2888
+ ...(itermWarning ? { itermWarning } : {}),
2676
2889
  ...(unsetEnvWarning ? { unsetEnvWarning } : {}),
2677
2890
  ...(name && adapter.cli === "codex" && !nameApplied
2678
2891
  ? {
@@ -3567,12 +3780,36 @@ async function doStop(adapter, opts) {
3567
3780
  throw new Error(
3568
3781
  "stop --session accepts only a session name; pass the session name without a pane target",
3569
3782
  );
3783
+ let itermSessionId;
3784
+ let itermWarning;
3785
+ if (shouldUseIterm(remote)) {
3786
+ try {
3787
+ const { stdout } = await runTmux(null, [
3788
+ "show-options",
3789
+ "-qv",
3790
+ "-t",
3791
+ session,
3792
+ "@tfx_iterm_session",
3793
+ ]);
3794
+ itermSessionId = stdout.trim();
3795
+ } catch (error) {
3796
+ itermWarning = error.message;
3797
+ }
3798
+ }
3570
3799
  await runTmux(remote, ["kill-session", "-t", session]);
3800
+ if (itermSessionId) {
3801
+ try {
3802
+ await closeItermSession(itermSessionId);
3803
+ } catch (error) {
3804
+ itermWarning = error.message;
3805
+ }
3806
+ }
3571
3807
  return {
3572
3808
  cli: adapter.cli,
3573
3809
  session,
3574
3810
  remote: remote ?? null,
3575
3811
  stopped: true,
3812
+ ...(itermWarning ? { itermWarning } : {}),
3576
3813
  };
3577
3814
  }
3578
3815
 
@@ -3705,6 +3942,7 @@ function startOpts(flags) {
3705
3942
  model: flags.model,
3706
3943
  effort: flags.effort,
3707
3944
  name: flags.name,
3945
+ group: flags.group,
3708
3946
  sandbox: Object.hasOwn(flags, "sandbox"),
3709
3947
  unsetEnv,
3710
3948
  readyTimeoutMs: secondsFlag(
@@ -5147,6 +5385,7 @@ export {
5147
5385
  ADAPTERS,
5148
5386
  agyStepText,
5149
5387
  agyWaitVerdict,
5388
+ appleScriptString,
5150
5389
  buildLaunchKeys,
5151
5390
  buildPeerClosurePrompt,
5152
5391
  buildRemoteLiveCommand,
@@ -5173,6 +5412,7 @@ export {
5173
5412
  resolveAskTransport,
5174
5413
  resolveCodexDaemonSocket,
5175
5414
  resumedCodexThread,
5415
+ shouldUseIterm,
5176
5416
  splitTmuxTarget,
5177
5417
  stopOpts,
5178
5418
  tmuxBufferName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.60.0",
3
+ "version": "10.61.1",
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 경로는 제외하고, ssh 로 들어온 셸처럼 리드가 이 기기의 iTerm2 창에 보이지 않으면 열지 않는다. 같은 `--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 세션