triflux 10.59.2 → 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.
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: tfx-live
3
3
  description: >
4
- Claude·Codex 세션을 실시간으로 생성·질의·대기·압축·종료하거나,
4
+ Claude·Codex·agy 세션을 실시간으로 생성·질의·대기·압축·종료하거나,
5
5
  tmux/daemon UDS 연결과 peer 중계를 운영할 때 사용한다.
6
- argument-hint: "<start|ask|wait|queue|compact|stop|rename|interrupt|probe|list-sessions|peer|converse|goal-driven> ..."
6
+ argument-hint: "<start|ask|wait|queue|compact|stop|rename|interrupt|probe|list-sessions|peer|converse|goal-driven> [--cli codex|claude|agy] ..."
7
7
  ---
8
8
 
9
9
  # tfx-live
@@ -12,7 +12,7 @@ argument-hint: "<start|ask|wait|queue|compact|stop|rename|interrupt|probe|list-s
12
12
 
13
13
  Codex에서는 `$tfx-live`로 호출한다. CLI의 `--cli`는 현재 호스트가 아니라 대상 세션을 선택한다.
14
14
 
15
- `tfx-live`는 Claude Code와 Codex 세션을 tmux 또는 daemon UDS로 제어한다.
15
+ `tfx-live`는 Claude Code와 Codex 세션을 tmux 또는 daemon UDS로, agy 세션을 로컬 tmux로 제어한다.
16
16
  `tfx-live --help`와 `tfx-live <동사> --help`로 사용법을 확인한다.
17
17
 
18
18
  ## 전송 경로
@@ -29,7 +29,27 @@ tfx-live ask --cli codex --session cx1 --prompt "현재 변경사항을 요약
29
29
  tfx-live stop --cli codex --session cx1
30
30
  ```
31
31
 
32
- 새 세션 이름은 `<월>.<일> <주제>`로 짓는다. `start --name`을 생략하면 자동으로 만들고 `nameGenerated: true`를 보고한다. `--resume` 또는 `--resume-last`는 `--name`을 명시한 경우에만 이름을 적용한다. 승계 세션에는 기존 이름 뒤에 ` 2`, 이후 ` 3`을 붙인다. 이름 적용을 요청한 경우 결과의 `nameApplied: false`는 적용 실패를 뜻한다. 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 "<이름>"`으로 이름을 바꾼다.
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
+
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 "<이름>"`으로 이름을 바꾼다.
35
+
36
+ ## agy 세션
37
+
38
+ ```bash
39
+ tfx-live start --cli agy --session ag1 --cwd ~/Projects --name "10.11 구현 검토" --effort low --unset-env 'TOSSINVEST_*,PRIVATE_KEY'
40
+ tfx-live ask --cli agy --session ag1 --prompt "현재 변경사항을 요약해줘" --no-wait
41
+ tfx-live wait --cli agy --session ag1 --request-id <requestId> --timeout 120
42
+ tfx-live list-sessions --cli agy --cwd ~/Projects
43
+ tfx-live stop --cli agy --session ag1
44
+ ```
45
+
46
+ `start`는 `--model ID`, `--effort low|medium|high|xhigh|max`, `--sandbox`를 agy에 넘긴다. `--sandbox`의 실효는 미검증이다. `--resume ID`와 `--resume-last 1`로 대화를 재개한다. 준비 뒤 `/rename <이름>`의 확인 문구가 보이면 `nameApplied: true`다. 새 대화는 첫 메시지 전 `/rename`이 실패하므로 이름을 tmux 옵션 `@tfx_agy_name`에 남기고 `nameApplied: false`, `namePending: true`로 보류한다. 첫 일반 ask는 제출 확인 뒤 현재 대화 DB에 사용자 step이 생길 때까지 최대 5초 기다리고, 유휴를 기다리지 않고 `/rename`을 보낸다. 확인 문구가 보이면 옵션을 지우고 `nameApplied: true`를 반환하며, 실패하면 옵션을 남기고 `nameApplied: false`를 반환한다. `--no-wait`에도 적용한다. 열린 presence lock에서 찾은 대화 ID는 `conversationId`로 보고한다.
47
+
48
+ agy `ask`의 기본 `--if-busy queue`는 바쁜 턴에도 바로 전송한다. agy 자체 대기열에 쌓이며 앞 입력을 덮어쓰지 않는다. `wait|fail|interrupt`도 선택할 수 있다. `ask --no-wait`는 `inputSent: true`, `requestId`와 함께 유휴 시 `status: "submitted"`, 바쁠 때 `status: "queued"`를 반환한다. `queue` 정책은 agy 전용이다. 슬래시 명령은 중계 표식 없이 보내고 바빠도 즉시 실행한다. DB step을 만들지 않아 `wait`로 추적하지 않으며, `--no-wait` 여부와 무관하게 제출 확인 뒤 `status: "submitted"`, `slashCommand: true`로 반환한다. `/clear`는 대화 ID를 바꾼다.
49
+
50
+ `wait`는 `node:sqlite`(Node 22.13+)로 대화 DB의 사용자 입력 표식과 뒤따르는 step 상태를 읽는다. 요청 뒤부터 다음 사용자 입력 전까지 모델 응답 step이 하나 이상 있고 그 구간의 모든 step이 끝났으며, pane이 유휴 상태이거나 다음 요청이 시작됐으면 완료다. 같은 구간의 마지막 비어 있지 않은 모델 텍스트를 응답으로 쓴다. Escape로 취소된 step도 완료 상태로 기록된다. `--timeout 0`은 한 번만 확인한다. `/clear`는 대화 ID를 바꾸며 진행 중 `wait`는 처음 연 DB를 계속 본다. agy 설정의 `toolPermission`에 따라 도구 승인 화면이 뜰 수 있다. 승인을 기다리는 동안 턴이 멈추고 `wait`는 `working`으로 남는다. `tfx-live`는 agy에 `--dangerously-skip-permissions`를 쓰지 않는다. agy에는 compact가 없으므로 `/clear` 또는 handoff를 쓴다. `--remote`, `peer`, `converse`, `goal-driven`은 지원하지 않는다.
51
+
52
+ `start --unset-env 'PREFIX_*,EXACT_NAME'`은 모든 CLI에 쓸 수 있다. 쉼표로 구분한 환경변수 이름과 끝의 `*`로 지정한 접두만 허용한다. tmux pane 셸의 환경에서 변수를 지우고 CLI를 실행하며 resume에도 적용한다. 로컬에서는 준비 후 CLI 프로세스 환경을 확인한다. macOS는 `ps -E` 출력에 `PATH=`가 있고 Linux는 `/proc/PID/environ`이 비어 있지 않을 때만 환경을 읽은 것으로 판정한다. 일치하는 변수가 실제로 남은 경우에만 생성한 세션을 종료하고 오류를 낸다. 환경을 읽지 못하면 세션을 유지하고 `unsetEnvVerified: false`, `unsetEnvWarning: "CLI environment unreadable; removal not verified"`를 반환한다. 결과의 `unsetEnv`는 요청 목록이고 `unsetEnvVerified`는 확인 여부다. 원격에서는 확인하지 않아 `false`이고 Windows에서는 이 옵션을 지원하지 않는다.
33
53
 
34
54
  ## 리드 운영
35
55
 
@@ -74,7 +94,7 @@ tfx-live queue --cli codex --thread <UUID> --delete <queuedMessageId>
74
94
  | Codex | 15% | 18% | 20% | 22% |
75
95
  | agy | 12% | 없음 | 15% | 18% |
76
96
 
77
- Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 `tfx-live` 전송 대상이 아니다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
97
+ Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
78
98
 
79
99
  결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다. 판정에는 공식 창과 실행 한도 중 작은 값을 쓴다. 한도를 모르면 비율은 `null`로 두고 경고만 한다. 사용량이 없는 `probe`는 `context: null`이다. 컨텍스트를 확인하려면 직접 `tfx-live probe`를 실행한다. `interrupt`, 자동 `ask`의 daemon probe, `stop --short`는 transcript를 읽지 않는다.
80
100
 
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,
@@ -81,6 +82,7 @@ const BOOLEAN_FLAGS = new Set([
81
82
  "attach-b",
82
83
  "no-wait",
83
84
  "no-relay-tag",
85
+ "sandbox",
84
86
  ]);
85
87
  const PEER_HOP_DONE_MARKER = "<<<TFX_PEER_HOP_DONE>>>";
86
88
  // uds-fallback diagnostics land here, written async so a failed daemon attach
@@ -92,8 +94,8 @@ const BUG_REPORT_DIR =
92
94
  function usage(command) {
93
95
  const lines = [
94
96
  "Usage:",
95
- " tfx-live start --session NAME [--name NAME] [--cli codex|claude] [--model ID] [--effort TIER] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
96
- " tfx-live ask --session NAME[:WINDOW.PANE] --prompt TEXT [--cli codex|claude] [--if-busy wait|fail|interrupt] [--busy-timeout 60] [--timeout 60] [--remote HOST] [--settle 1500] [--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]",
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]",
97
99
  " tfx-live ask --cli codex [--transport queue|tmux] (--session NAME[:WINDOW.PANE] | --thread UUID) --prompt TEXT [--from NAME] [--timeout 60] [--no-wait]",
98
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.",
99
101
  " tfx-live ask --cli codex --transport uds --thread ID|auto --prompt TEXT [--codex-socket PATH|default] [--cwd DIR] [--if-busy wait|fail|steer] [--busy-timeout 60] [--timeout 60] [--max-turn SECONDS]",
@@ -102,16 +104,17 @@ function usage(command) {
102
104
  " tfx-live compact --cli claude --session NAME [--instructions TEXT] [--if-busy fail|wait] [--timeout 60]",
103
105
  " tfx-live wait --cli claude (--short SHORT | --session-id ID | --session NAME[:WINDOW.PANE]) [--request-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 60] [--poll-interval 1500]",
104
106
  " tfx-live wait --cli codex (--session NAME[:WINDOW.PANE] | --thread UUID) --request-id ID [--timeout 60] [--poll-interval 1500]",
107
+ " tfx-live wait --cli agy --session NAME[:WINDOW.PANE] [--request-id ID] [--timeout 60] [--poll-interval 1500]",
105
108
  " tfx-live queue --cli codex (--session NAME[:WINDOW.PANE] | --thread UUID) [--request-id ID] [--delete QUEUED_ID|all]",
106
109
  " queue lists or deletes items the TUI has not picked up yet (official app-server thread/queue API); missing means already delivered or gone.",
107
110
  " tfx-live rename --cli codex --transport uds --thread ID --name NAME [--codex-socket PATH|default]",
108
111
  " 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.",
109
- " tfx-live interrupt --session NAME [--cli codex|claude] [--transport tmux|uds|auto] [--short SHORT | --session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 5]",
110
- " tfx-live stop --session NAME [--cli codex|claude] [--remote HOST]",
112
+ " tfx-live interrupt --session NAME [--cli codex|claude|agy] [--transport tmux|uds|auto] [--short SHORT | --session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 5]",
113
+ " tfx-live stop --session NAME [--cli codex|claude|agy] [--remote HOST]",
111
114
  " tfx-live stop --cli claude (--short SHORT | --session-id ID) [--config-dir DIR]",
112
115
  " tfx-live probe [--short SHORT] [--session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 10]",
113
- " tfx-live list-sessions --cli codex|claude [--transport tmux|uds] [--codex-socket PATH|default] [--cwd DIR] [--remote HOST (codex tmux only)]",
114
- " Claude list-sessions supports local tmux only; --transport uds requires --cli codex.",
116
+ " tfx-live list-sessions --cli codex|claude|agy [--transport tmux|uds] [--codex-socket PATH|default] [--cwd DIR] [--remote HOST (codex tmux only)]",
117
+ " Claude and agy list-sessions support local tmux only; --transport uds requires --cli codex.",
115
118
  " tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
116
119
  " 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]",
117
120
  " 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]",
@@ -214,6 +217,7 @@ function contextPctFlag(flags, name, defaultValue) {
214
217
  }
215
218
 
216
219
  function contextOpts(flags, cli) {
220
+ if (cli === "agy") return {};
217
221
  return {
218
222
  warnContextPct: contextPctFlag(
219
223
  flags,
@@ -236,6 +240,12 @@ function selectAdapter(flags) {
236
240
  `--cli must be one of: ${Object.keys(ADAPTERS).join(", ")}`,
237
241
  );
238
242
  }
243
+ if (cli === "agy" && flags.remote)
244
+ throw new Error("agy supports local tmux only; --remote is unavailable");
245
+ if (flags.sandbox && cli !== "agy")
246
+ throw new Error("--sandbox is only supported with --cli agy");
247
+ if (flags["if-busy"] === "queue" && cli !== "agy")
248
+ throw new Error("--if-busy queue is only supported with --cli agy");
239
249
  return adapter;
240
250
  }
241
251
 
@@ -261,6 +271,270 @@ function shellQuote(args) {
261
271
  .join(" ");
262
272
  }
263
273
 
274
+ function withUnsetEnv(command, patterns) {
275
+ if (!patterns.length) return command;
276
+ if (patterns.some((pattern) => !/^[A-Za-z_][A-Za-z0-9_]*\*?$/.test(pattern)))
277
+ throw new Error(
278
+ "--unset-env requires comma-separated environment names or prefixes ending in '*'",
279
+ );
280
+ if (process.platform === "win32")
281
+ throw new Error("--unset-env is unavailable on Windows");
282
+ const exact = patterns.filter((pattern) => !pattern.endsWith("*"));
283
+ const prefixes = patterns.filter((pattern) => pattern.endsWith("*"));
284
+ const expressions = prefixes.flatMap((pattern) => [
285
+ "-e",
286
+ `s/^\\(${pattern.slice(0, -1)}[A-Za-z0-9_]*\\)=.*/-u \\1/p`,
287
+ ]);
288
+ return [
289
+ "env",
290
+ ...exact.flatMap((name) => ["-u", name]),
291
+ ...(expressions.length
292
+ ? [`$(env | sed -n ${shellQuote(expressions)})`]
293
+ : []),
294
+ command,
295
+ ].join(" ");
296
+ }
297
+
298
+ function inspectUnsetEnv(environment, patterns, platform) {
299
+ const readable =
300
+ platform === "darwin"
301
+ ? /(?:^|\s)PATH=/.test(environment)
302
+ : environment.length > 0;
303
+ if (!readable) return { readable: false, remaining: [] };
304
+ const names =
305
+ platform === "darwin"
306
+ ? [...environment.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=/g)].map(
307
+ (match) => match[1],
308
+ )
309
+ : environment.split("\0").map((entry) => entry.split("=", 1)[0]);
310
+ return {
311
+ readable,
312
+ remaining: names.filter((name) =>
313
+ patterns.some((pattern) =>
314
+ pattern.endsWith("*")
315
+ ? name.startsWith(pattern.slice(0, -1))
316
+ : name === pattern,
317
+ ),
318
+ ),
319
+ };
320
+ }
321
+
322
+ async function paneCliProcess(session, cli, panePid) {
323
+ if (!panePid) {
324
+ const { stdout } = await runTmux(null, [
325
+ "display-message",
326
+ "-p",
327
+ "-t",
328
+ session,
329
+ "#{pane_pid}",
330
+ ]);
331
+ panePid = Number(stdout.trim());
332
+ }
333
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,args="], {
334
+ timeout: 5000,
335
+ maxBuffer: MAX_BUFFER,
336
+ });
337
+ for (const line of stdout.split(/\r?\n/)) {
338
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
339
+ if (!match) continue;
340
+ const pid = Number(match[1]);
341
+ if (pid !== panePid && Number(match[2]) !== panePid) continue;
342
+ if (
343
+ cli === "codex"
344
+ ? isCodexProcessCommand(match[3])
345
+ : new RegExp(`^(?:\\S*/)?${cli}(?:\\s|$)`).test(match[3])
346
+ )
347
+ return { pid, command: match[3] };
348
+ }
349
+ return null;
350
+ }
351
+
352
+ async function agyConversation(pid) {
353
+ if (!pid) return null;
354
+ try {
355
+ const { stdout } = await execFileAsync("lsof", ["-Fn", "-p", String(pid)], {
356
+ timeout: 5000,
357
+ maxBuffer: MAX_BUFFER,
358
+ });
359
+ const match = stdout.match(
360
+ /^n(.*\/antigravity-cli)\/presence\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.lock$/im,
361
+ );
362
+ return match
363
+ ? {
364
+ conversationId: match[2],
365
+ dbPath: pathJoin(match[1], "conversations", `${match[2]}.db`),
366
+ }
367
+ : null;
368
+ } catch {
369
+ return null;
370
+ }
371
+ }
372
+
373
+ async function discoverAgyTmuxSessions({ cwd = null } = {}) {
374
+ const result = { ok: true, cli: "agy", cwd: normalizeCwd(cwd), sessions: [] };
375
+ let stdout;
376
+ try {
377
+ ({ stdout } = await runTmux(null, [
378
+ "list-panes",
379
+ "-a",
380
+ "-F",
381
+ TMUX_DISCOVERY_FORMAT,
382
+ ]));
383
+ } catch (error) {
384
+ return {
385
+ ...result,
386
+ ok: false,
387
+ reason: "tmux-unavailable",
388
+ error: error.message,
389
+ };
390
+ }
391
+ for (const line of stdout.split(/\r?\n/)) {
392
+ const [session, , , window, pane, paneCwd, , , , pid, paneId] =
393
+ line.split("\t");
394
+ if (!paneId || (result.cwd && normalizeCwd(paneCwd) !== result.cwd))
395
+ continue;
396
+ const target = `${session}:${window}.${pane}`;
397
+ const agyProcess = await paneCliProcess(target, "agy", Number(pid));
398
+ if (!agyProcess) continue;
399
+ const conversation = await agyConversation(agyProcess.pid);
400
+ result.sessions.push({
401
+ session,
402
+ target,
403
+ paneId,
404
+ cwd: normalizeCwd(paneCwd),
405
+ ...agyProcess,
406
+ conversationId: conversation?.conversationId ?? null,
407
+ });
408
+ }
409
+ return result;
410
+ }
411
+
412
+ function agyStepText(payload, stepType) {
413
+ const fieldPath = stepType === 14 ? [19, 2] : [20, 1];
414
+ let bytes = Buffer.from(payload);
415
+ for (const wanted of fieldPath) {
416
+ let offset = 0;
417
+ let found = null;
418
+ const varint = () => {
419
+ let value = 0;
420
+ let factor = 1;
421
+ for (let count = 0; count < 10 && offset < bytes.length; count++) {
422
+ const byte = bytes[offset++];
423
+ value += (byte & 127) * factor;
424
+ if (!(byte & 128)) return value;
425
+ factor *= 128;
426
+ }
427
+ throw new Error("Invalid agy protobuf varint");
428
+ };
429
+ while (offset < bytes.length) {
430
+ const tag = varint();
431
+ const field = Math.floor(tag / 8);
432
+ const wire = tag % 8;
433
+ if (!field) throw new Error("Invalid agy protobuf field");
434
+ if (wire === 0) {
435
+ varint();
436
+ } else if (wire === 1 || wire === 5) {
437
+ offset += wire === 1 ? 8 : 4;
438
+ if (offset > bytes.length)
439
+ throw new Error("Invalid agy protobuf length");
440
+ } else if (wire === 2) {
441
+ const length = varint();
442
+ if (!Number.isSafeInteger(length) || offset + length > bytes.length)
443
+ throw new Error("Invalid agy protobuf length");
444
+ if (field === wanted) found = bytes.subarray(offset, offset + length);
445
+ offset += length;
446
+ } else {
447
+ throw new Error(`Unsupported agy protobuf wire type: ${wire}`);
448
+ }
449
+ }
450
+ if (!found) return "";
451
+ bytes = found;
452
+ }
453
+ return bytes.toString("utf8");
454
+ }
455
+
456
+ function agyWaitVerdict(steps, requestId, visible) {
457
+ const request = steps.findLastIndex(
458
+ (step) =>
459
+ step.step_type === 14 &&
460
+ (!requestId ||
461
+ agyStepText(step.step_payload, 14).includes(
462
+ `[tfx-live req=${requestId}]`,
463
+ )),
464
+ );
465
+ if (request < 0)
466
+ return {
467
+ status: isAgyQueuedCapture(visible) ? "queued" : "submitted",
468
+ done: false,
469
+ response: "",
470
+ };
471
+ const nextRequest = steps.findIndex(
472
+ (step, index) => index > request && step.step_type === 14,
473
+ );
474
+ // 다음 사용자 입력이 시작됐으면 그 턴의 진행 상태는 이 요청의 완료를 막지 않는다.
475
+ const turn = steps.slice(
476
+ request + 1,
477
+ nextRequest < 0 ? undefined : nextRequest,
478
+ );
479
+ let response = "";
480
+ for (const step of turn) {
481
+ if (step.step_type !== 15) continue;
482
+ const text = agyStepText(step.step_payload, 15);
483
+ if (text) response = text;
484
+ }
485
+ const done =
486
+ turn.some((step) => step.step_type === 15) &&
487
+ turn.every((step) => step.status === 3) &&
488
+ (nextRequest >= 0 || !ADAPTERS.agy.isBusy(visible));
489
+ return { status: done ? "completed" : "working", done, response };
490
+ }
491
+
492
+ async function waitAgy({
493
+ session,
494
+ requestId = null,
495
+ timeoutMs,
496
+ pollIntervalMs,
497
+ }) {
498
+ let DatabaseSync;
499
+ try {
500
+ ({ DatabaseSync } = await import("node:sqlite"));
501
+ } catch {
502
+ throw new Error("agy wait requires node:sqlite (Node 22.13+)");
503
+ }
504
+ const agyProcess = await paneCliProcess(session, "agy");
505
+ if (!agyProcess)
506
+ throw new Error(`no live agy process in tmux target ${session}`);
507
+ const conversation = await agyConversation(agyProcess.pid);
508
+ if (!conversation)
509
+ throw new Error(`agy conversation unavailable in tmux target ${session}`);
510
+ // 처음 연 DB 를 고정해 대기 중 /clear 로 바뀐 대화를 따라가지 않는다.
511
+ const db = new DatabaseSync(conversation.dbPath, { readOnly: true });
512
+ const deadline = Date.now() + timeoutMs;
513
+ try {
514
+ const query = db.prepare(
515
+ "SELECT idx, step_type, status, step_payload FROM steps ORDER BY idx",
516
+ );
517
+ for (;;) {
518
+ const visible = await captureVisible(null, session);
519
+ const verdict = agyWaitVerdict(query.all(), requestId, visible);
520
+ const timedOut = !verdict.done && Date.now() >= deadline;
521
+ const result = {
522
+ ok: true,
523
+ cli: "agy",
524
+ ...verdict,
525
+ timedOut,
526
+ requestId,
527
+ conversationId: conversation.conversationId,
528
+ session,
529
+ };
530
+ if (verdict.done || timedOut) return result;
531
+ await sleep(Math.min(pollIntervalMs, deadline - Date.now()));
532
+ }
533
+ } finally {
534
+ db.close();
535
+ }
536
+ }
537
+
264
538
  function buildTmuxCommand(remote, tmuxArgs) {
265
539
  if (!remote) {
266
540
  return { command: "tmux", args: tmuxArgs };
@@ -1139,7 +1413,8 @@ function promptLineIndex(lines, prompt) {
1139
1413
  return -1;
1140
1414
  }
1141
1415
 
1142
- function isComposerPromptLine(line) {
1416
+ function isComposerPromptLine(line, cli) {
1417
+ if (cli === "agy") return /^\s*>(?:\s|$)/.test(stripAnsi(line));
1143
1418
  return hasCodexComposerPrompt(line) || hasClaudeComposerPrompt(line);
1144
1419
  }
1145
1420
 
@@ -1156,21 +1431,21 @@ function promptComposerNeedle(prompt) {
1156
1431
  );
1157
1432
  }
1158
1433
 
1159
- function composerLineMatchesPrompt(line, prompt) {
1434
+ function composerLineMatchesPrompt(line, prompt, cli) {
1160
1435
  const needle = promptComposerNeedle(prompt);
1161
1436
  return (
1162
1437
  Boolean(needle) &&
1163
- isComposerPromptLine(line) &&
1438
+ isComposerPromptLine(line, cli) &&
1164
1439
  String(line).includes(needle)
1165
1440
  );
1166
1441
  }
1167
1442
 
1168
- function composerShowsPrompt(text, prompt) {
1443
+ function composerShowsPrompt(text, prompt, cli) {
1169
1444
  const activeComposerLine = String(text)
1170
1445
  .split("\n")
1171
- .filter(isComposerPromptLine)
1446
+ .filter((line) => isComposerPromptLine(line, cli))
1172
1447
  .at(-1);
1173
- return composerLineMatchesPrompt(activeComposerLine, prompt);
1448
+ return composerLineMatchesPrompt(activeComposerLine, prompt, cli);
1174
1449
  }
1175
1450
 
1176
1451
  function nextComposerPromptIndex(lines, startIndex) {
@@ -1671,6 +1946,28 @@ async function dismissClaudeTourPrompt(remote, session) {
1671
1946
  return { dismissed: true, raw };
1672
1947
  }
1673
1948
 
1949
+ function isAgyTrustPrompt(text) {
1950
+ return /Do you trust the contents of this project\?/.test(stripAnsi(text));
1951
+ }
1952
+
1953
+ function isAgyQueuedCapture(text) {
1954
+ return /Press up to edit queued messages/i.test(stripAnsi(text));
1955
+ }
1956
+
1957
+ async function dismissAgyTrustPrompt(remote, session) {
1958
+ let raw = await captureVisible(remote, session);
1959
+ for (let attempt = 0; attempt < 4 && isAgyTrustPrompt(raw); attempt++) {
1960
+ if (/^\s*>\s+Yes, I trust this folder\s*$/m.test(stripAnsi(raw))) {
1961
+ await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
1962
+ return { dismissed: true, raw };
1963
+ }
1964
+ await runTmux(remote, ["send-keys", "-t", session, "Up"]);
1965
+ await sleep(200);
1966
+ raw = await captureVisible(remote, session);
1967
+ }
1968
+ return { dismissed: false, raw };
1969
+ }
1970
+
1674
1971
  const ADAPTERS = {
1675
1972
  codex: {
1676
1973
  cli: "codex",
@@ -1774,12 +2071,36 @@ const ADAPTERS = {
1774
2071
  },
1775
2072
  ],
1776
2073
  },
2074
+ agy: {
2075
+ cli: "agy",
2076
+ launchKeys: ["agy", "Enter"],
2077
+ resumeById: (id) => shellQuote(["agy", "--conversation", id]),
2078
+ resumeLast: () => shellQuote(["agy", "--continue"]),
2079
+ isReady: (text) =>
2080
+ !isAgyTrustPrompt(text) && /^\s*>(?:\s|$)/m.test(stripAnsi(text)),
2081
+ isBusy: (text) =>
2082
+ /esc to cancel/i.test(stripAnsi(text)) || isAgyQueuedCapture(text),
2083
+ contextPct: () => null,
2084
+ hasLoginGuard: () => false,
2085
+ startupScreens: [
2086
+ {
2087
+ name: "trust",
2088
+ isPresent: isAgyTrustPrompt,
2089
+ dismiss: dismissAgyTrustPrompt,
2090
+ },
2091
+ ],
2092
+ },
1777
2093
  };
1778
2094
 
1779
- function buildLaunchKeys(adapter, { model, effort, name } = {}) {
2095
+ function buildLaunchKeys(adapter, { model, effort, name, sandbox } = {}) {
1780
2096
  const hasModel = model !== undefined;
1781
2097
  const hasEffort = effort !== undefined;
1782
- if (!hasModel && !hasEffort && !(adapter.cli === "claude" && name)) {
2098
+ if (
2099
+ !hasModel &&
2100
+ !hasEffort &&
2101
+ !sandbox &&
2102
+ !(adapter.cli === "claude" && name)
2103
+ ) {
1783
2104
  return adapter.launchKeys;
1784
2105
  }
1785
2106
 
@@ -1802,6 +2123,16 @@ function buildLaunchKeys(adapter, { model, effort, name } = {}) {
1802
2123
  if (hasEffort) {
1803
2124
  overrideArgs.push("--effort", effort);
1804
2125
  }
2126
+ } else if (adapter.cli === "agy") {
2127
+ if (hasModel) overrideArgs.push("--model", model);
2128
+ if (hasEffort) {
2129
+ if (!["low", "medium", "high", "xhigh", "max"].includes(effort))
2130
+ throw new Error(
2131
+ "agy --effort must be low, medium, high, xhigh, or max",
2132
+ );
2133
+ overrideArgs.push("--effort", effort);
2134
+ }
2135
+ if (sandbox) overrideArgs.push("--sandbox");
1805
2136
  } else {
1806
2137
  throw new Error(`Unsupported adapter: ${adapter.cli}`);
1807
2138
  }
@@ -2140,6 +2471,151 @@ async function resumedCodexThread(
2140
2471
  return matches.length === 1 ? matches[0] : null;
2141
2472
  }
2142
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
+
2143
2619
  async function doStart(adapter, opts) {
2144
2620
  const {
2145
2621
  session,
@@ -2164,11 +2640,27 @@ async function doStart(adapter, opts) {
2164
2640
  (!/^\d{1,2}\.\d{1,2} \S/u.test(name) || /[\r\n]/u.test(name))
2165
2641
  )
2166
2642
  throw new Error("--name must use '<month>.<day> <topic>' on one line");
2167
- const launchKeys = resume
2168
- ? [adapter.resumeById(resume), "Enter"]
2169
- : resumeLast
2170
- ? [adapter.resumeLast(), "Enter"]
2171
- : buildLaunchKeys(adapter, { model, effort, name });
2643
+ const launchKeys = [
2644
+ ...(resume
2645
+ ? [adapter.resumeById(resume), "Enter"]
2646
+ : resumeLast
2647
+ ? [adapter.resumeLast(), "Enter"]
2648
+ : buildLaunchKeys(adapter, {
2649
+ model,
2650
+ effort,
2651
+ name,
2652
+ sandbox: opts.sandbox,
2653
+ })),
2654
+ ];
2655
+ if (resumed && adapter.cli === "agy") {
2656
+ const overrides = buildLaunchKeys(adapter, {
2657
+ model,
2658
+ effort,
2659
+ sandbox: opts.sandbox,
2660
+ });
2661
+ launchKeys[0] += overrides[0].slice("agy".length);
2662
+ }
2663
+ launchKeys[0] = withUnsetEnv(launchKeys[0], opts.unsetEnv ?? []);
2172
2664
  if (resumed && name && adapter.cli === "claude")
2173
2665
  launchKeys[0] += ` ${shellQuote(["-n", name])}`;
2174
2666
  const resumeTarget = resume ?? (resumeLast ? "last" : null);
@@ -2207,6 +2699,41 @@ async function doStart(adapter, opts) {
2207
2699
  readyTimeoutMs,
2208
2700
  pollIntervalMs,
2209
2701
  );
2702
+ let unsetEnvVerified = false;
2703
+ let unsetEnvWarning;
2704
+ if (ready && !remote && opts.unsetEnv?.length) {
2705
+ let environment = "";
2706
+ try {
2707
+ const target = await paneCliProcess(session, adapter.cli);
2708
+ if (target)
2709
+ environment =
2710
+ process.platform === "darwin"
2711
+ ? (
2712
+ await execFileAsync(
2713
+ "ps",
2714
+ ["-E", "-ww", "-o", "command=", "-p", String(target.pid)],
2715
+ { timeout: 5000, maxBuffer: MAX_BUFFER },
2716
+ )
2717
+ ).stdout
2718
+ : await readFile(`/proc/${target.pid}/environ`, "utf8");
2719
+ } catch {
2720
+ // 읽기 실패는 변수 잔존의 증거가 아니므로 세션을 유지한다.
2721
+ }
2722
+ const { readable, remaining } = inspectUnsetEnv(
2723
+ environment,
2724
+ opts.unsetEnv,
2725
+ process.platform,
2726
+ );
2727
+ if (remaining.length) {
2728
+ await runTmux(null, ["kill-session", "-t", session]);
2729
+ throw new Error(
2730
+ "--unset-env verification failed: matching variables remain in CLI environment",
2731
+ );
2732
+ }
2733
+ unsetEnvVerified = readable;
2734
+ if (!readable)
2735
+ unsetEnvWarning = "CLI environment unreadable; removal not verified";
2736
+ }
2210
2737
  let nameApplied = Boolean(name) && adapter.cli === "claude" && ready;
2211
2738
  let threadId =
2212
2739
  adapter.cli === "codex" && isCodexThreadId(resume) ? resume : null;
@@ -2248,6 +2775,25 @@ async function doStart(adapter, opts) {
2248
2775
  nameApplied = false;
2249
2776
  }
2250
2777
  }
2778
+ if (name && adapter.cli === "agy" && ready) {
2779
+ try {
2780
+ nameApplied = await renameAgyConversation(
2781
+ session,
2782
+ name,
2783
+ Math.min(readyTimeoutMs, 5000),
2784
+ pollIntervalMs,
2785
+ );
2786
+ } catch {
2787
+ nameApplied = false;
2788
+ }
2789
+ }
2790
+ const namePending = adapter.cli === "agy" && Boolean(name) && !nameApplied;
2791
+ if (namePending)
2792
+ await runTmux(null, ["set-option", "-t", session, "@tfx_agy_name", name]);
2793
+ const conversation =
2794
+ adapter.cli === "agy" && ready
2795
+ ? await agyConversation((await paneCliProcess(session, "agy"))?.pid)
2796
+ : null;
2251
2797
  // ask 의 queue 전송이 레지스트리 없이도 thread 를 찾도록 세션에 남긴다.
2252
2798
  if (threadId && !remote)
2253
2799
  await runTmux(null, [
@@ -2257,6 +2803,66 @@ async function doStart(adapter, opts) {
2257
2803
  "@tfx_codex_thread",
2258
2804
  threadId,
2259
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
+ }
2260
2866
  return addLoginGuard(adapter, raw, {
2261
2867
  cli: adapter.cli,
2262
2868
  session,
@@ -2268,6 +2874,14 @@ async function doStart(adapter, opts) {
2268
2874
  nameGenerated: !resumed && opts.name === undefined,
2269
2875
  nameApplied,
2270
2876
  ...(adapter.cli === "codex" ? { threadId } : {}),
2877
+ ...(adapter.cli === "agy"
2878
+ ? { conversationId: conversation?.conversationId ?? null, namePending }
2879
+ : {}),
2880
+ unsetEnv: opts.unsetEnv ?? [],
2881
+ unsetEnvVerified,
2882
+ ...(iterm ? { iterm } : {}),
2883
+ ...(itermWarning ? { itermWarning } : {}),
2884
+ ...(unsetEnvWarning ? { unsetEnvWarning } : {}),
2271
2885
  ...(name && adapter.cli === "codex" && !nameApplied
2272
2886
  ? {
2273
2887
  nameWarning:
@@ -2287,21 +2901,43 @@ async function doStart(adapter, opts) {
2287
2901
  }
2288
2902
 
2289
2903
  // 빠른 입력 직후의 Enter 는 Codex 입력창이 버릴 수 있어 입력창이 빌 때까지 다시 누른다.
2290
- async function submitComposerLine(remote, session, line) {
2904
+ async function submitComposerLine(remote, session, line, cli) {
2291
2905
  await sleep(300);
2292
2906
  for (let attempt = 1; attempt <= PROMPT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
2293
2907
  await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
2294
2908
  await sleep(300);
2295
- if (!composerShowsPrompt(await captureVisible(remote, session), line))
2909
+ if (!composerShowsPrompt(await captureVisible(remote, session), line, cli))
2296
2910
  return true;
2297
2911
  }
2298
2912
  return false;
2299
2913
  }
2300
2914
 
2915
+ async function renameAgyConversation(session, name, timeoutMs, pollIntervalMs) {
2916
+ const command = `/rename ${name}`;
2917
+ await runTmux(null, ["send-keys", "-t", session, "-l", "--", command]);
2918
+ if (!(await submitComposerLine(null, session, command, "agy"))) return false;
2919
+ const deadline = Date.now() + timeoutMs;
2920
+ do {
2921
+ const visible = stripAnsi(await captureVisible(null, session));
2922
+ if (visible.includes(`Conversation renamed to: ${name}`)) return true;
2923
+ await sleep(Math.min(pollIntervalMs, 200));
2924
+ } while (Date.now() < deadline);
2925
+ return false;
2926
+ }
2927
+
2301
2928
  async function waitForTmuxIdle(adapter, opts) {
2302
2929
  const { session, remote, pollIntervalMs, busyTimeoutMs, ifBusy } = opts;
2303
- if (!["wait", "fail", "interrupt"].includes(ifBusy)) {
2304
- throw new Error("--if-busy must be wait, fail, or interrupt for tmux");
2930
+ if (
2931
+ ![
2932
+ "wait",
2933
+ "fail",
2934
+ "interrupt",
2935
+ ...(adapter.cli === "agy" ? ["queue"] : []),
2936
+ ].includes(ifBusy)
2937
+ ) {
2938
+ throw new Error(
2939
+ "--if-busy must be wait, fail, or interrupt for tmux (agy also supports queue)",
2940
+ );
2305
2941
  }
2306
2942
  const startedAt = Date.now();
2307
2943
  let interrupted = false;
@@ -2309,9 +2945,9 @@ async function waitForTmuxIdle(adapter, opts) {
2309
2945
  const visible = await captureVisible(remote, session);
2310
2946
  const elapsedMs = Date.now() - startedAt;
2311
2947
  const busy =
2312
- isBusyByEsc(visible) ||
2948
+ adapter.isBusy(visible) ||
2313
2949
  (adapter.cli === "codex" && isCodexLoadingCapture(visible));
2314
- if (!busy) return elapsedMs;
2950
+ if (!busy || ifBusy === "queue") return elapsedMs;
2315
2951
  if (ifBusy === "fail") throw new Error("target busy (--if-busy fail)");
2316
2952
  if (elapsedMs >= busyTimeoutMs) {
2317
2953
  throw new Error(
@@ -2337,22 +2973,32 @@ async function doAskViaTmux(adapter, opts) {
2337
2973
  settleMs,
2338
2974
  pollIntervalMs,
2339
2975
  doneMarker,
2340
- ifBusy = "wait",
2976
+ ifBusy = adapter.cli === "agy" ? "queue" : "wait",
2341
2977
  busyTimeoutMs = timeoutMs,
2342
2978
  } = opts;
2979
+ const slashCommand = adapter.cli === "agy" && isSlashCommand(prompt);
2343
2980
  const busyWaitedMs = await waitForTmuxIdle(adapter, {
2344
2981
  session,
2345
2982
  remote,
2346
2983
  pollIntervalMs,
2347
2984
  busyTimeoutMs,
2348
- ifBusy,
2985
+ ifBusy: slashCommand ? "queue" : ifBusy,
2349
2986
  });
2987
+ const queued =
2988
+ adapter.cli === "agy" &&
2989
+ adapter.isBusy(await captureVisible(remote, session));
2350
2990
  const beforeRaw = await capturePane(remote, session);
2351
2991
  const contextPctBefore = adapter.contextPct(beforeRaw);
2352
- const context = opts.skipContextGuard ? {} : await tmuxContext(adapter, opts);
2353
- const guard = opts.skipContextGuard
2354
- ? {}
2355
- : contextGuard(adapter.cli, context, opts);
2992
+ const context =
2993
+ adapter.cli === "agy"
2994
+ ? modelContext("agy", null)
2995
+ : opts.skipContextGuard
2996
+ ? {}
2997
+ : await tmuxContext(adapter, opts);
2998
+ const guard =
2999
+ opts.skipContextGuard || adapter.cli === "agy"
3000
+ ? {}
3001
+ : contextGuard(adapter.cli, context, opts);
2356
3002
  if (guard.ok === false)
2357
3003
  return {
2358
3004
  ...guard,
@@ -2376,13 +3022,13 @@ async function doAskViaTmux(adapter, opts) {
2376
3022
  ]);
2377
3023
  await sleep(settleMs);
2378
3024
 
2379
- let promptSubmitted = !doneMarker;
2380
- if (doneMarker || opts.noWait) {
3025
+ let promptSubmitted = adapter.cli === "agy" ? false : !doneMarker;
3026
+ if (doneMarker || opts.noWait || adapter.cli === "agy") {
2381
3027
  for (let attempt = 1; attempt <= PROMPT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
2382
3028
  await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
2383
3029
  await sleep(PROMPT_SUBMIT_RETRY_DELAY_MS);
2384
3030
  const submissionVisible = await captureVisible(remote, session);
2385
- if (!composerShowsPrompt(submissionVisible, prompt)) {
3031
+ if (!composerShowsPrompt(submissionVisible, prompt, adapter.cli)) {
2386
3032
  promptSubmitted = true;
2387
3033
  break;
2388
3034
  }
@@ -2396,7 +3042,61 @@ async function doAskViaTmux(adapter, opts) {
2396
3042
  await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
2397
3043
  }
2398
3044
 
2399
- if (opts.noWait) {
3045
+ let nameApplied;
3046
+ if (adapter.cli === "agy" && !slashCommand) {
3047
+ const { stdout } = await runTmux(null, [
3048
+ "show-options",
3049
+ "-qv",
3050
+ "-t",
3051
+ splitTmuxTarget(session).session,
3052
+ "@tfx_agy_name",
3053
+ ]);
3054
+ const pendingName = stdout.trim();
3055
+ if (pendingName) {
3056
+ nameApplied = false;
3057
+ let db;
3058
+ try {
3059
+ const { DatabaseSync } = await import("node:sqlite");
3060
+ const deadline = Date.now() + 5000;
3061
+ while (Date.now() < deadline) {
3062
+ if (!db) {
3063
+ const agyProcess = await paneCliProcess(session, "agy");
3064
+ const conversation = await agyConversation(agyProcess?.pid);
3065
+ if (conversation)
3066
+ db = new DatabaseSync(conversation.dbPath, { readOnly: true });
3067
+ }
3068
+ if (
3069
+ db
3070
+ ?.prepare("SELECT 1 FROM steps WHERE step_type = 14 LIMIT 1")
3071
+ .get()
3072
+ ) {
3073
+ nameApplied = await renameAgyConversation(
3074
+ session,
3075
+ pendingName,
3076
+ 5000,
3077
+ pollIntervalMs,
3078
+ );
3079
+ if (nameApplied)
3080
+ await runTmux(null, [
3081
+ "set-option",
3082
+ "-u",
3083
+ "-t",
3084
+ splitTmuxTarget(session).session,
3085
+ "@tfx_agy_name",
3086
+ ]);
3087
+ break;
3088
+ }
3089
+ await sleep(Math.min(pollIntervalMs, 200));
3090
+ }
3091
+ } catch {
3092
+ nameApplied = false;
3093
+ } finally {
3094
+ db?.close();
3095
+ }
3096
+ }
3097
+ }
3098
+
3099
+ if (opts.noWait || slashCommand) {
2400
3100
  return {
2401
3101
  ...context,
2402
3102
  ...guard,
@@ -2405,7 +3105,9 @@ async function doAskViaTmux(adapter, opts) {
2405
3105
  transport: "tmux",
2406
3106
  session,
2407
3107
  remote: remote ?? null,
2408
- status: "submitted",
3108
+ status: queued && !slashCommand ? "queued" : "submitted",
3109
+ ...(slashCommand ? { slashCommand: true } : {}),
3110
+ ...(nameApplied !== undefined ? { nameApplied } : {}),
2409
3111
  inputSent: true,
2410
3112
  done: false,
2411
3113
  submittedAt: new Date().toISOString(),
@@ -2415,6 +3117,22 @@ async function doAskViaTmux(adapter, opts) {
2415
3117
  };
2416
3118
  }
2417
3119
 
3120
+ if (adapter.cli === "agy") {
3121
+ return {
3122
+ ...context,
3123
+ ...(await waitAgy({
3124
+ session,
3125
+ requestId: opts.noRelayTag ? null : opts.requestId,
3126
+ timeoutMs,
3127
+ pollIntervalMs,
3128
+ })),
3129
+ transport: "tmux",
3130
+ inputSent: true,
3131
+ ...(nameApplied !== undefined ? { nameApplied } : {}),
3132
+ ifBusy,
3133
+ busyWaitedMs,
3134
+ };
3135
+ }
2418
3136
  const startedAt = Date.now();
2419
3137
  let raw = "";
2420
3138
  let visible = "";
@@ -2512,7 +3230,8 @@ async function doAsk(adapter, opts) {
2512
3230
  // Codex 는 접힌 발신자 표시가 없어 첫 줄 머리말로 보낸 세션을 드러낸다.
2513
3231
  // 슬래시 명령은 첫 글자가 / 여야 실행되므로 표식을 붙이지 않는다.
2514
3232
  const bare =
2515
- opts.noRelayTag || (adapter.cli === "codex" && isSlashCommand(opts.prompt));
3233
+ opts.noRelayTag ||
3234
+ (["codex", "agy"].includes(adapter.cli) && isSlashCommand(opts.prompt));
2516
3235
  const from =
2517
3236
  adapter.cli === "codex" && !bare
2518
3237
  ? (opts.from ?? (await resolveSenderName()))
@@ -3056,17 +3775,48 @@ async function doStop(adapter, opts) {
3056
3775
  throw new Error(
3057
3776
  "stop --session accepts only a session name; pass the session name without a pane target",
3058
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
+ }
3059
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
+ }
3060
3802
  return {
3061
3803
  cli: adapter.cli,
3062
3804
  session,
3063
3805
  remote: remote ?? null,
3064
3806
  stopped: true,
3807
+ ...(itermWarning ? { itermWarning } : {}),
3065
3808
  };
3066
3809
  }
3067
3810
 
3068
3811
  async function doInterruptViaTmux(adapter, opts) {
3069
3812
  await runTmux(opts.remote, ["send-keys", "-t", opts.session, "Escape"]);
3813
+ if (adapter.cli === "agy")
3814
+ await waitForTmuxIdle(adapter, {
3815
+ ...opts,
3816
+ ifBusy: "wait",
3817
+ busyTimeoutMs: opts.timeoutMs,
3818
+ pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
3819
+ });
3070
3820
  return {
3071
3821
  cli: adapter.cli,
3072
3822
  session: opts.session,
@@ -3173,6 +3923,8 @@ async function doInterrupt(adapter, opts) {
3173
3923
  }
3174
3924
 
3175
3925
  function startOpts(flags) {
3926
+ const unsetEnv =
3927
+ flags["unset-env"] === undefined ? [] : flags["unset-env"].split(",");
3176
3928
  const session = requireFlag(flags, "session");
3177
3929
  if (session.includes(":"))
3178
3930
  throw new Error("start --session accepts only a session name without ':'");
@@ -3185,6 +3937,9 @@ function startOpts(flags) {
3185
3937
  model: flags.model,
3186
3938
  effort: flags.effort,
3187
3939
  name: flags.name,
3940
+ group: flags.group,
3941
+ sandbox: Object.hasOwn(flags, "sandbox"),
3942
+ unsetEnv,
3188
3943
  readyTimeoutMs: secondsFlag(
3189
3944
  flags,
3190
3945
  "ready-timeout",
@@ -3243,6 +3998,8 @@ function askOpts(flags, adapter) {
3243
3998
  short,
3244
3999
  sessionId,
3245
4000
  );
4001
+ if (adapter.cli === "agy" && transport !== "tmux")
4002
+ throw new Error("agy ask supports local --transport tmux only");
3246
4003
  const codexUds = adapter.cli === "codex" && transport === "uds";
3247
4004
  const codexQueue = transport === "queue";
3248
4005
  if (adapter.cli === "codex" && transport === "auto")
@@ -3292,7 +4049,7 @@ function askOpts(flags, adapter) {
3292
4049
  ? resolveCodexDaemonSocket(flags["codex-socket"] ?? "default")
3293
4050
  : null,
3294
4051
  cwd: flags.cwd,
3295
- ifBusy: flags["if-busy"] ?? "wait",
4052
+ ifBusy: flags["if-busy"] ?? (adapter.cli === "agy" ? "queue" : "wait"),
3296
4053
  busyTimeoutMs: secondsFlag(
3297
4054
  flags,
3298
4055
  "busy-timeout",
@@ -3343,6 +4100,8 @@ function interruptOpts(flags, adapter) {
3343
4100
  const short = flags.short;
3344
4101
  const sessionId = flags["session-id"];
3345
4102
  const transport = transportFlag(flags, adapter, short, sessionId);
4103
+ if (adapter.cli === "agy" && transport !== "tmux")
4104
+ throw new Error("agy interrupt supports local --transport tmux only");
3346
4105
  if (transport === "queue")
3347
4106
  throw new Error("interrupt uses tmux; queue cannot send keys");
3348
4107
  if (adapter.cli === "codex" && transport === "uds")
@@ -3464,6 +4223,26 @@ async function queue(flags) {
3464
4223
  }
3465
4224
 
3466
4225
  async function wait(flags) {
4226
+ if (flags.cli === "agy") {
4227
+ selectAdapter(flags);
4228
+ if (flags.transport && flags.transport !== "tmux")
4229
+ throw new Error("agy wait supports local --transport tmux only");
4230
+ return printJson(
4231
+ await waitAgy({
4232
+ session: splitTmuxTarget(requireFlag(flags, "session")).target,
4233
+ requestId: flags["request-id"] ?? null,
4234
+ timeoutMs:
4235
+ flags.timeout === "0"
4236
+ ? 0
4237
+ : secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
4238
+ pollIntervalMs: msFlag(
4239
+ flags,
4240
+ "poll-interval",
4241
+ DEFAULT_POLL_INTERVAL_MS,
4242
+ ),
4243
+ }),
4244
+ );
4245
+ }
3467
4246
  if (flags.cli === "codex") return waitCodex(flags);
3468
4247
  if ((flags.cli ?? "claude") !== "claude")
3469
4248
  throw new Error("wait supports --cli claude or codex");
@@ -3568,6 +4347,8 @@ async function tmuxContext(adapter, opts) {
3568
4347
  }
3569
4348
 
3570
4349
  async function compact(flags) {
4350
+ if (flags.cli === "agy")
4351
+ throw new Error("agy has no compact; use /clear or a handoff");
3571
4352
  if (
3572
4353
  (flags.cli ?? "claude") !== "claude" ||
3573
4354
  flags.remote ||
@@ -3654,6 +4435,11 @@ async function probe(flags) {
3654
4435
 
3655
4436
  async function listSessions(flags) {
3656
4437
  const adapter = selectAdapter(flags);
4438
+ if (adapter.cli === "agy") {
4439
+ if (flags.transport && flags.transport !== "tmux")
4440
+ throw new Error("agy list-sessions supports local --transport tmux only");
4441
+ return printJson(await discoverAgyTmuxSessions({ cwd: flags.cwd }));
4442
+ }
3657
4443
  if (adapter.cli === "claude") {
3658
4444
  if (flags.remote || flags.transport === "uds") {
3659
4445
  throw new Error(
@@ -3788,6 +4574,7 @@ function parseFirstInteger(response) {
3788
4574
 
3789
4575
  async function converse(flags) {
3790
4576
  const adapter = selectAdapter(flags);
4577
+ if (adapter.cli === "agy") throw new Error("agy is unavailable for converse");
3791
4578
  const session = requireFlag(flags, "session");
3792
4579
  const promptsFile = requireFlag(flags, "prompts-file");
3793
4580
  const prompts = await readPromptsFile(promptsFile);
@@ -3823,6 +4610,8 @@ async function converse(flags) {
3823
4610
 
3824
4611
  async function goalDriven(flags) {
3825
4612
  const adapter = selectAdapter(flags);
4613
+ if (adapter.cli === "agy")
4614
+ throw new Error("agy is unavailable for goal-driven");
3826
4615
  const session = requireFlag(flags, "session");
3827
4616
  const goal = requireFlag(flags, "goal");
3828
4617
  const maxRounds = integerFlag(flags, "max-rounds", 8);
@@ -4317,6 +5106,8 @@ function addStopSpecOnce(stopSpecs, adapter, opts) {
4317
5106
  async function peer(flags) {
4318
5107
  const adapterA = selectAdapterName(flags["cli-a"] ?? "codex", "cli-a");
4319
5108
  const adapterB = selectAdapterName(flags["cli-b"] ?? "claude", "cli-b");
5109
+ if (adapterA.cli === "agy" || adapterB.cli === "agy")
5110
+ throw new Error("agy is unavailable for peer");
4320
5111
  peerSideBusyPolicy(
4321
5112
  flags,
4322
5113
  "a",
@@ -4587,12 +5378,16 @@ async function main() {
4587
5378
 
4588
5379
  export {
4589
5380
  ADAPTERS,
5381
+ agyStepText,
5382
+ agyWaitVerdict,
5383
+ appleScriptString,
4590
5384
  buildLaunchKeys,
4591
5385
  buildPeerClosurePrompt,
4592
5386
  buildRemoteLiveCommand,
4593
5387
  buildTmuxCommand,
4594
5388
  callRemoteLive,
4595
5389
  classifyPeerExitReason,
5390
+ composerShowsPrompt,
4596
5391
  createPeerSignalController,
4597
5392
  derivePeerStatus,
4598
5393
  discoverClaudeTmuxSessions,
@@ -4603,6 +5398,7 @@ export {
4603
5398
  extractResponseSinceMarker,
4604
5399
  hasClaudeCompletedTaskListResponse,
4605
5400
  inspectCodexTmuxPane,
5401
+ inspectUnsetEnv,
4606
5402
  isCodexLoadingCapture,
4607
5403
  parseCodexTmuxSessions,
4608
5404
  parsePeerClosure,
@@ -4611,10 +5407,12 @@ export {
4611
5407
  resolveAskTransport,
4612
5408
  resolveCodexDaemonSocket,
4613
5409
  resumedCodexThread,
5410
+ shouldUseIterm,
4614
5411
  splitTmuxTarget,
4615
5412
  stopOpts,
4616
5413
  tmuxBufferName,
4617
5414
  verifyAttachedPeerSide,
5415
+ withUnsetEnv,
4618
5416
  };
4619
5417
 
4620
5418
  function isMainModule() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.59.2",
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": {
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: tfx-live
3
3
  description: >
4
- Claude·Codex 세션을 실시간으로 생성·질의·대기·압축·종료하거나,
4
+ Claude·Codex·agy 세션을 실시간으로 생성·질의·대기·압축·종료하거나,
5
5
  tmux/daemon UDS 연결과 peer 중계를 운영할 때 사용한다.
6
- argument-hint: "<start|ask|wait|queue|compact|stop|rename|interrupt|probe|list-sessions|peer|converse|goal-driven> ..."
6
+ argument-hint: "<start|ask|wait|queue|compact|stop|rename|interrupt|probe|list-sessions|peer|converse|goal-driven> [--cli codex|claude|agy] ..."
7
7
  ---
8
8
 
9
9
  # tfx-live
10
10
 
11
- `tfx-live`는 Claude Code와 Codex 세션을 tmux 또는 daemon UDS로 제어한다.
11
+ `tfx-live`는 Claude Code와 Codex 세션을 tmux 또는 daemon UDS로, agy 세션을 로컬 tmux로 제어한다.
12
12
  `tfx-live --help`와 `tfx-live <동사> --help`로 사용법을 확인한다.
13
13
 
14
14
  ## 전송 경로
@@ -25,7 +25,27 @@ tfx-live ask --cli codex --session cx1 --prompt "현재 변경사항을 요약
25
25
  tfx-live stop --cli codex --session cx1
26
26
  ```
27
27
 
28
- 새 세션 이름은 `<월>.<일> <주제>`로 짓는다. `start --name`을 생략하면 자동으로 만들고 `nameGenerated: true`를 보고한다. `--resume` 또는 `--resume-last`는 `--name`을 명시한 경우에만 이름을 적용한다. 승계 세션에는 기존 이름 뒤에 ` 2`, 이후 ` 3`을 붙인다. 이름 적용을 요청한 경우 결과의 `nameApplied: false`는 적용 실패를 뜻한다. 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 "<이름>"`으로 이름을 바꾼다.
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
+
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 "<이름>"`으로 이름을 바꾼다.
31
+
32
+ ## agy 세션
33
+
34
+ ```bash
35
+ tfx-live start --cli agy --session ag1 --cwd ~/Projects --name "10.11 구현 검토" --effort low --unset-env 'TOSSINVEST_*,PRIVATE_KEY'
36
+ tfx-live ask --cli agy --session ag1 --prompt "현재 변경사항을 요약해줘" --no-wait
37
+ tfx-live wait --cli agy --session ag1 --request-id <requestId> --timeout 120
38
+ tfx-live list-sessions --cli agy --cwd ~/Projects
39
+ tfx-live stop --cli agy --session ag1
40
+ ```
41
+
42
+ `start`는 `--model ID`, `--effort low|medium|high|xhigh|max`, `--sandbox`를 agy에 넘긴다. `--sandbox`의 실효는 미검증이다. `--resume ID`와 `--resume-last 1`로 대화를 재개한다. 준비 뒤 `/rename <이름>`의 확인 문구가 보이면 `nameApplied: true`다. 새 대화는 첫 메시지 전 `/rename`이 실패하므로 이름을 tmux 옵션 `@tfx_agy_name`에 남기고 `nameApplied: false`, `namePending: true`로 보류한다. 첫 일반 ask는 제출 확인 뒤 현재 대화 DB에 사용자 step이 생길 때까지 최대 5초 기다리고, 유휴를 기다리지 않고 `/rename`을 보낸다. 확인 문구가 보이면 옵션을 지우고 `nameApplied: true`를 반환하며, 실패하면 옵션을 남기고 `nameApplied: false`를 반환한다. `--no-wait`에도 적용한다. 열린 presence lock에서 찾은 대화 ID는 `conversationId`로 보고한다.
43
+
44
+ agy `ask`의 기본 `--if-busy queue`는 바쁜 턴에도 바로 전송한다. agy 자체 대기열에 쌓이며 앞 입력을 덮어쓰지 않는다. `wait|fail|interrupt`도 선택할 수 있다. `ask --no-wait`는 `inputSent: true`, `requestId`와 함께 유휴 시 `status: "submitted"`, 바쁠 때 `status: "queued"`를 반환한다. `queue` 정책은 agy 전용이다. 슬래시 명령은 중계 표식 없이 보내고 바빠도 즉시 실행한다. DB step을 만들지 않아 `wait`로 추적하지 않으며, `--no-wait` 여부와 무관하게 제출 확인 뒤 `status: "submitted"`, `slashCommand: true`로 반환한다. `/clear`는 대화 ID를 바꾼다.
45
+
46
+ `wait`는 `node:sqlite`(Node 22.13+)로 대화 DB의 사용자 입력 표식과 뒤따르는 step 상태를 읽는다. 요청 뒤부터 다음 사용자 입력 전까지 모델 응답 step이 하나 이상 있고 그 구간의 모든 step이 끝났으며, pane이 유휴 상태이거나 다음 요청이 시작됐으면 완료다. 같은 구간의 마지막 비어 있지 않은 모델 텍스트를 응답으로 쓴다. Escape로 취소된 step도 완료 상태로 기록된다. `--timeout 0`은 한 번만 확인한다. `/clear`는 대화 ID를 바꾸며 진행 중 `wait`는 처음 연 DB를 계속 본다. agy 설정의 `toolPermission`에 따라 도구 승인 화면이 뜰 수 있다. 승인을 기다리는 동안 턴이 멈추고 `wait`는 `working`으로 남는다. `tfx-live`는 agy에 `--dangerously-skip-permissions`를 쓰지 않는다. agy에는 compact가 없으므로 `/clear` 또는 handoff를 쓴다. `--remote`, `peer`, `converse`, `goal-driven`은 지원하지 않는다.
47
+
48
+ `start --unset-env 'PREFIX_*,EXACT_NAME'`은 모든 CLI에 쓸 수 있다. 쉼표로 구분한 환경변수 이름과 끝의 `*`로 지정한 접두만 허용한다. tmux pane 셸의 환경에서 변수를 지우고 CLI를 실행하며 resume에도 적용한다. 로컬에서는 준비 후 CLI 프로세스 환경을 확인한다. macOS는 `ps -E` 출력에 `PATH=`가 있고 Linux는 `/proc/PID/environ`이 비어 있지 않을 때만 환경을 읽은 것으로 판정한다. 일치하는 변수가 실제로 남은 경우에만 생성한 세션을 종료하고 오류를 낸다. 환경을 읽지 못하면 세션을 유지하고 `unsetEnvVerified: false`, `unsetEnvWarning: "CLI environment unreadable; removal not verified"`를 반환한다. 결과의 `unsetEnv`는 요청 목록이고 `unsetEnvVerified`는 확인 여부다. 원격에서는 확인하지 않아 `false`이고 Windows에서는 이 옵션을 지원하지 않는다.
29
49
 
30
50
  ## 리드 운영
31
51
 
@@ -70,7 +90,7 @@ tfx-live queue --cli codex --thread <UUID> --delete <queuedMessageId>
70
90
  | Codex | 15% | 18% | 20% | 22% |
71
91
  | agy | 12% | 없음 | 15% | 18% |
72
92
 
73
- Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 `tfx-live` 전송 대상이 아니다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
93
+ Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
74
94
 
75
95
  결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다. 판정에는 공식 창과 실행 한도 중 작은 값을 쓴다. 한도를 모르면 비율은 `null`로 두고 경고만 한다. 사용량이 없는 `probe`는 `context: null`이다. 컨텍스트를 확인하려면 직접 `tfx-live probe`를 실행한다. `interrupt`, 자동 `ask`의 daemon probe, `stop --short`는 transcript를 읽지 않는다.
76
96