triflux 10.59.2 → 10.60.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,25 @@ 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
+ 새 세션 이름은 `<월>.<일> <주제>`로 짓는다. `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
+
34
+ ## agy 세션
35
+
36
+ ```bash
37
+ tfx-live start --cli agy --session ag1 --cwd ~/Projects --name "10.11 구현 검토" --effort low --unset-env 'TOSSINVEST_*,PRIVATE_KEY'
38
+ tfx-live ask --cli agy --session ag1 --prompt "현재 변경사항을 요약해줘" --no-wait
39
+ tfx-live wait --cli agy --session ag1 --request-id <requestId> --timeout 120
40
+ tfx-live list-sessions --cli agy --cwd ~/Projects
41
+ tfx-live stop --cli agy --session ag1
42
+ ```
43
+
44
+ `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`로 보고한다.
45
+
46
+ 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를 바꾼다.
47
+
48
+ `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`은 지원하지 않는다.
49
+
50
+ `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
51
 
34
52
  ## 리드 운영
35
53
 
@@ -74,7 +92,7 @@ tfx-live queue --cli codex --thread <UUID> --delete <queuedMessageId>
74
92
  | Codex | 15% | 18% | 20% | 22% |
75
93
  | agy | 12% | 없음 | 15% | 18% |
76
94
 
77
- Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 `tfx-live` 전송 대상이 아니다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
95
+ Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
78
96
 
79
97
  결과에는 `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
98
 
package/bin/tfx-live.mjs CHANGED
@@ -81,6 +81,7 @@ const BOOLEAN_FLAGS = new Set([
81
81
  "attach-b",
82
82
  "no-wait",
83
83
  "no-relay-tag",
84
+ "sandbox",
84
85
  ]);
85
86
  const PEER_HOP_DONE_MARKER = "<<<TFX_PEER_HOP_DONE>>>";
86
87
  // uds-fallback diagnostics land here, written async so a failed daemon attach
@@ -92,8 +93,8 @@ const BUG_REPORT_DIR =
92
93
  function usage(command) {
93
94
  const lines = [
94
95
  "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]",
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 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
98
  " tfx-live ask --cli codex [--transport queue|tmux] (--session NAME[:WINDOW.PANE] | --thread UUID) --prompt TEXT [--from NAME] [--timeout 60] [--no-wait]",
98
99
  " 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
100
  " 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 +103,17 @@ function usage(command) {
102
103
  " tfx-live compact --cli claude --session NAME [--instructions TEXT] [--if-busy fail|wait] [--timeout 60]",
103
104
  " 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
105
  " tfx-live wait --cli codex (--session NAME[:WINDOW.PANE] | --thread UUID) --request-id ID [--timeout 60] [--poll-interval 1500]",
106
+ " tfx-live wait --cli agy --session NAME[:WINDOW.PANE] [--request-id ID] [--timeout 60] [--poll-interval 1500]",
105
107
  " tfx-live queue --cli codex (--session NAME[:WINDOW.PANE] | --thread UUID) [--request-id ID] [--delete QUEUED_ID|all]",
106
108
  " 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
109
  " tfx-live rename --cli codex --transport uds --thread ID --name NAME [--codex-socket PATH|default]",
108
110
  " 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]",
111
+ " 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]",
112
+ " tfx-live stop --session NAME [--cli codex|claude|agy] [--remote HOST]",
111
113
  " tfx-live stop --cli claude (--short SHORT | --session-id ID) [--config-dir DIR]",
112
114
  " 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.",
115
+ " tfx-live list-sessions --cli codex|claude|agy [--transport tmux|uds] [--codex-socket PATH|default] [--cwd DIR] [--remote HOST (codex tmux only)]",
116
+ " Claude and agy list-sessions support local tmux only; --transport uds requires --cli codex.",
115
117
  " tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
116
118
  " 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
119
  " 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 +216,7 @@ function contextPctFlag(flags, name, defaultValue) {
214
216
  }
215
217
 
216
218
  function contextOpts(flags, cli) {
219
+ if (cli === "agy") return {};
217
220
  return {
218
221
  warnContextPct: contextPctFlag(
219
222
  flags,
@@ -236,6 +239,12 @@ function selectAdapter(flags) {
236
239
  `--cli must be one of: ${Object.keys(ADAPTERS).join(", ")}`,
237
240
  );
238
241
  }
242
+ if (cli === "agy" && flags.remote)
243
+ throw new Error("agy supports local tmux only; --remote is unavailable");
244
+ if (flags.sandbox && cli !== "agy")
245
+ throw new Error("--sandbox is only supported with --cli agy");
246
+ if (flags["if-busy"] === "queue" && cli !== "agy")
247
+ throw new Error("--if-busy queue is only supported with --cli agy");
239
248
  return adapter;
240
249
  }
241
250
 
@@ -261,6 +270,270 @@ function shellQuote(args) {
261
270
  .join(" ");
262
271
  }
263
272
 
273
+ function withUnsetEnv(command, patterns) {
274
+ if (!patterns.length) return command;
275
+ if (patterns.some((pattern) => !/^[A-Za-z_][A-Za-z0-9_]*\*?$/.test(pattern)))
276
+ throw new Error(
277
+ "--unset-env requires comma-separated environment names or prefixes ending in '*'",
278
+ );
279
+ if (process.platform === "win32")
280
+ throw new Error("--unset-env is unavailable on Windows");
281
+ const exact = patterns.filter((pattern) => !pattern.endsWith("*"));
282
+ const prefixes = patterns.filter((pattern) => pattern.endsWith("*"));
283
+ const expressions = prefixes.flatMap((pattern) => [
284
+ "-e",
285
+ `s/^\\(${pattern.slice(0, -1)}[A-Za-z0-9_]*\\)=.*/-u \\1/p`,
286
+ ]);
287
+ return [
288
+ "env",
289
+ ...exact.flatMap((name) => ["-u", name]),
290
+ ...(expressions.length
291
+ ? [`$(env | sed -n ${shellQuote(expressions)})`]
292
+ : []),
293
+ command,
294
+ ].join(" ");
295
+ }
296
+
297
+ function inspectUnsetEnv(environment, patterns, platform) {
298
+ const readable =
299
+ platform === "darwin"
300
+ ? /(?:^|\s)PATH=/.test(environment)
301
+ : environment.length > 0;
302
+ if (!readable) return { readable: false, remaining: [] };
303
+ const names =
304
+ platform === "darwin"
305
+ ? [...environment.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=/g)].map(
306
+ (match) => match[1],
307
+ )
308
+ : environment.split("\0").map((entry) => entry.split("=", 1)[0]);
309
+ return {
310
+ readable,
311
+ remaining: names.filter((name) =>
312
+ patterns.some((pattern) =>
313
+ pattern.endsWith("*")
314
+ ? name.startsWith(pattern.slice(0, -1))
315
+ : name === pattern,
316
+ ),
317
+ ),
318
+ };
319
+ }
320
+
321
+ async function paneCliProcess(session, cli, panePid) {
322
+ if (!panePid) {
323
+ const { stdout } = await runTmux(null, [
324
+ "display-message",
325
+ "-p",
326
+ "-t",
327
+ session,
328
+ "#{pane_pid}",
329
+ ]);
330
+ panePid = Number(stdout.trim());
331
+ }
332
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,args="], {
333
+ timeout: 5000,
334
+ maxBuffer: MAX_BUFFER,
335
+ });
336
+ for (const line of stdout.split(/\r?\n/)) {
337
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
338
+ if (!match) continue;
339
+ const pid = Number(match[1]);
340
+ if (pid !== panePid && Number(match[2]) !== panePid) continue;
341
+ if (
342
+ cli === "codex"
343
+ ? isCodexProcessCommand(match[3])
344
+ : new RegExp(`^(?:\\S*/)?${cli}(?:\\s|$)`).test(match[3])
345
+ )
346
+ return { pid, command: match[3] };
347
+ }
348
+ return null;
349
+ }
350
+
351
+ async function agyConversation(pid) {
352
+ if (!pid) return null;
353
+ try {
354
+ const { stdout } = await execFileAsync("lsof", ["-Fn", "-p", String(pid)], {
355
+ timeout: 5000,
356
+ maxBuffer: MAX_BUFFER,
357
+ });
358
+ const match = stdout.match(
359
+ /^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,
360
+ );
361
+ return match
362
+ ? {
363
+ conversationId: match[2],
364
+ dbPath: pathJoin(match[1], "conversations", `${match[2]}.db`),
365
+ }
366
+ : null;
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
371
+
372
+ async function discoverAgyTmuxSessions({ cwd = null } = {}) {
373
+ const result = { ok: true, cli: "agy", cwd: normalizeCwd(cwd), sessions: [] };
374
+ let stdout;
375
+ try {
376
+ ({ stdout } = await runTmux(null, [
377
+ "list-panes",
378
+ "-a",
379
+ "-F",
380
+ TMUX_DISCOVERY_FORMAT,
381
+ ]));
382
+ } catch (error) {
383
+ return {
384
+ ...result,
385
+ ok: false,
386
+ reason: "tmux-unavailable",
387
+ error: error.message,
388
+ };
389
+ }
390
+ for (const line of stdout.split(/\r?\n/)) {
391
+ const [session, , , window, pane, paneCwd, , , , pid, paneId] =
392
+ line.split("\t");
393
+ if (!paneId || (result.cwd && normalizeCwd(paneCwd) !== result.cwd))
394
+ continue;
395
+ const target = `${session}:${window}.${pane}`;
396
+ const agyProcess = await paneCliProcess(target, "agy", Number(pid));
397
+ if (!agyProcess) continue;
398
+ const conversation = await agyConversation(agyProcess.pid);
399
+ result.sessions.push({
400
+ session,
401
+ target,
402
+ paneId,
403
+ cwd: normalizeCwd(paneCwd),
404
+ ...agyProcess,
405
+ conversationId: conversation?.conversationId ?? null,
406
+ });
407
+ }
408
+ return result;
409
+ }
410
+
411
+ function agyStepText(payload, stepType) {
412
+ const fieldPath = stepType === 14 ? [19, 2] : [20, 1];
413
+ let bytes = Buffer.from(payload);
414
+ for (const wanted of fieldPath) {
415
+ let offset = 0;
416
+ let found = null;
417
+ const varint = () => {
418
+ let value = 0;
419
+ let factor = 1;
420
+ for (let count = 0; count < 10 && offset < bytes.length; count++) {
421
+ const byte = bytes[offset++];
422
+ value += (byte & 127) * factor;
423
+ if (!(byte & 128)) return value;
424
+ factor *= 128;
425
+ }
426
+ throw new Error("Invalid agy protobuf varint");
427
+ };
428
+ while (offset < bytes.length) {
429
+ const tag = varint();
430
+ const field = Math.floor(tag / 8);
431
+ const wire = tag % 8;
432
+ if (!field) throw new Error("Invalid agy protobuf field");
433
+ if (wire === 0) {
434
+ varint();
435
+ } else if (wire === 1 || wire === 5) {
436
+ offset += wire === 1 ? 8 : 4;
437
+ if (offset > bytes.length)
438
+ throw new Error("Invalid agy protobuf length");
439
+ } else if (wire === 2) {
440
+ const length = varint();
441
+ if (!Number.isSafeInteger(length) || offset + length > bytes.length)
442
+ throw new Error("Invalid agy protobuf length");
443
+ if (field === wanted) found = bytes.subarray(offset, offset + length);
444
+ offset += length;
445
+ } else {
446
+ throw new Error(`Unsupported agy protobuf wire type: ${wire}`);
447
+ }
448
+ }
449
+ if (!found) return "";
450
+ bytes = found;
451
+ }
452
+ return bytes.toString("utf8");
453
+ }
454
+
455
+ function agyWaitVerdict(steps, requestId, visible) {
456
+ const request = steps.findLastIndex(
457
+ (step) =>
458
+ step.step_type === 14 &&
459
+ (!requestId ||
460
+ agyStepText(step.step_payload, 14).includes(
461
+ `[tfx-live req=${requestId}]`,
462
+ )),
463
+ );
464
+ if (request < 0)
465
+ return {
466
+ status: isAgyQueuedCapture(visible) ? "queued" : "submitted",
467
+ done: false,
468
+ response: "",
469
+ };
470
+ const nextRequest = steps.findIndex(
471
+ (step, index) => index > request && step.step_type === 14,
472
+ );
473
+ // 다음 사용자 입력이 시작됐으면 그 턴의 진행 상태는 이 요청의 완료를 막지 않는다.
474
+ const turn = steps.slice(
475
+ request + 1,
476
+ nextRequest < 0 ? undefined : nextRequest,
477
+ );
478
+ let response = "";
479
+ for (const step of turn) {
480
+ if (step.step_type !== 15) continue;
481
+ const text = agyStepText(step.step_payload, 15);
482
+ if (text) response = text;
483
+ }
484
+ const done =
485
+ turn.some((step) => step.step_type === 15) &&
486
+ turn.every((step) => step.status === 3) &&
487
+ (nextRequest >= 0 || !ADAPTERS.agy.isBusy(visible));
488
+ return { status: done ? "completed" : "working", done, response };
489
+ }
490
+
491
+ async function waitAgy({
492
+ session,
493
+ requestId = null,
494
+ timeoutMs,
495
+ pollIntervalMs,
496
+ }) {
497
+ let DatabaseSync;
498
+ try {
499
+ ({ DatabaseSync } = await import("node:sqlite"));
500
+ } catch {
501
+ throw new Error("agy wait requires node:sqlite (Node 22.13+)");
502
+ }
503
+ const agyProcess = await paneCliProcess(session, "agy");
504
+ if (!agyProcess)
505
+ throw new Error(`no live agy process in tmux target ${session}`);
506
+ const conversation = await agyConversation(agyProcess.pid);
507
+ if (!conversation)
508
+ throw new Error(`agy conversation unavailable in tmux target ${session}`);
509
+ // 처음 연 DB 를 고정해 대기 중 /clear 로 바뀐 대화를 따라가지 않는다.
510
+ const db = new DatabaseSync(conversation.dbPath, { readOnly: true });
511
+ const deadline = Date.now() + timeoutMs;
512
+ try {
513
+ const query = db.prepare(
514
+ "SELECT idx, step_type, status, step_payload FROM steps ORDER BY idx",
515
+ );
516
+ for (;;) {
517
+ const visible = await captureVisible(null, session);
518
+ const verdict = agyWaitVerdict(query.all(), requestId, visible);
519
+ const timedOut = !verdict.done && Date.now() >= deadline;
520
+ const result = {
521
+ ok: true,
522
+ cli: "agy",
523
+ ...verdict,
524
+ timedOut,
525
+ requestId,
526
+ conversationId: conversation.conversationId,
527
+ session,
528
+ };
529
+ if (verdict.done || timedOut) return result;
530
+ await sleep(Math.min(pollIntervalMs, deadline - Date.now()));
531
+ }
532
+ } finally {
533
+ db.close();
534
+ }
535
+ }
536
+
264
537
  function buildTmuxCommand(remote, tmuxArgs) {
265
538
  if (!remote) {
266
539
  return { command: "tmux", args: tmuxArgs };
@@ -1139,7 +1412,8 @@ function promptLineIndex(lines, prompt) {
1139
1412
  return -1;
1140
1413
  }
1141
1414
 
1142
- function isComposerPromptLine(line) {
1415
+ function isComposerPromptLine(line, cli) {
1416
+ if (cli === "agy") return /^\s*>(?:\s|$)/.test(stripAnsi(line));
1143
1417
  return hasCodexComposerPrompt(line) || hasClaudeComposerPrompt(line);
1144
1418
  }
1145
1419
 
@@ -1156,21 +1430,21 @@ function promptComposerNeedle(prompt) {
1156
1430
  );
1157
1431
  }
1158
1432
 
1159
- function composerLineMatchesPrompt(line, prompt) {
1433
+ function composerLineMatchesPrompt(line, prompt, cli) {
1160
1434
  const needle = promptComposerNeedle(prompt);
1161
1435
  return (
1162
1436
  Boolean(needle) &&
1163
- isComposerPromptLine(line) &&
1437
+ isComposerPromptLine(line, cli) &&
1164
1438
  String(line).includes(needle)
1165
1439
  );
1166
1440
  }
1167
1441
 
1168
- function composerShowsPrompt(text, prompt) {
1442
+ function composerShowsPrompt(text, prompt, cli) {
1169
1443
  const activeComposerLine = String(text)
1170
1444
  .split("\n")
1171
- .filter(isComposerPromptLine)
1445
+ .filter((line) => isComposerPromptLine(line, cli))
1172
1446
  .at(-1);
1173
- return composerLineMatchesPrompt(activeComposerLine, prompt);
1447
+ return composerLineMatchesPrompt(activeComposerLine, prompt, cli);
1174
1448
  }
1175
1449
 
1176
1450
  function nextComposerPromptIndex(lines, startIndex) {
@@ -1671,6 +1945,28 @@ async function dismissClaudeTourPrompt(remote, session) {
1671
1945
  return { dismissed: true, raw };
1672
1946
  }
1673
1947
 
1948
+ function isAgyTrustPrompt(text) {
1949
+ return /Do you trust the contents of this project\?/.test(stripAnsi(text));
1950
+ }
1951
+
1952
+ function isAgyQueuedCapture(text) {
1953
+ return /Press up to edit queued messages/i.test(stripAnsi(text));
1954
+ }
1955
+
1956
+ async function dismissAgyTrustPrompt(remote, session) {
1957
+ let raw = await captureVisible(remote, session);
1958
+ for (let attempt = 0; attempt < 4 && isAgyTrustPrompt(raw); attempt++) {
1959
+ if (/^\s*>\s+Yes, I trust this folder\s*$/m.test(stripAnsi(raw))) {
1960
+ await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
1961
+ return { dismissed: true, raw };
1962
+ }
1963
+ await runTmux(remote, ["send-keys", "-t", session, "Up"]);
1964
+ await sleep(200);
1965
+ raw = await captureVisible(remote, session);
1966
+ }
1967
+ return { dismissed: false, raw };
1968
+ }
1969
+
1674
1970
  const ADAPTERS = {
1675
1971
  codex: {
1676
1972
  cli: "codex",
@@ -1774,12 +2070,36 @@ const ADAPTERS = {
1774
2070
  },
1775
2071
  ],
1776
2072
  },
2073
+ agy: {
2074
+ cli: "agy",
2075
+ launchKeys: ["agy", "Enter"],
2076
+ resumeById: (id) => shellQuote(["agy", "--conversation", id]),
2077
+ resumeLast: () => shellQuote(["agy", "--continue"]),
2078
+ isReady: (text) =>
2079
+ !isAgyTrustPrompt(text) && /^\s*>(?:\s|$)/m.test(stripAnsi(text)),
2080
+ isBusy: (text) =>
2081
+ /esc to cancel/i.test(stripAnsi(text)) || isAgyQueuedCapture(text),
2082
+ contextPct: () => null,
2083
+ hasLoginGuard: () => false,
2084
+ startupScreens: [
2085
+ {
2086
+ name: "trust",
2087
+ isPresent: isAgyTrustPrompt,
2088
+ dismiss: dismissAgyTrustPrompt,
2089
+ },
2090
+ ],
2091
+ },
1777
2092
  };
1778
2093
 
1779
- function buildLaunchKeys(adapter, { model, effort, name } = {}) {
2094
+ function buildLaunchKeys(adapter, { model, effort, name, sandbox } = {}) {
1780
2095
  const hasModel = model !== undefined;
1781
2096
  const hasEffort = effort !== undefined;
1782
- if (!hasModel && !hasEffort && !(adapter.cli === "claude" && name)) {
2097
+ if (
2098
+ !hasModel &&
2099
+ !hasEffort &&
2100
+ !sandbox &&
2101
+ !(adapter.cli === "claude" && name)
2102
+ ) {
1783
2103
  return adapter.launchKeys;
1784
2104
  }
1785
2105
 
@@ -1802,6 +2122,16 @@ function buildLaunchKeys(adapter, { model, effort, name } = {}) {
1802
2122
  if (hasEffort) {
1803
2123
  overrideArgs.push("--effort", effort);
1804
2124
  }
2125
+ } else if (adapter.cli === "agy") {
2126
+ if (hasModel) overrideArgs.push("--model", model);
2127
+ if (hasEffort) {
2128
+ if (!["low", "medium", "high", "xhigh", "max"].includes(effort))
2129
+ throw new Error(
2130
+ "agy --effort must be low, medium, high, xhigh, or max",
2131
+ );
2132
+ overrideArgs.push("--effort", effort);
2133
+ }
2134
+ if (sandbox) overrideArgs.push("--sandbox");
1805
2135
  } else {
1806
2136
  throw new Error(`Unsupported adapter: ${adapter.cli}`);
1807
2137
  }
@@ -2164,11 +2494,27 @@ async function doStart(adapter, opts) {
2164
2494
  (!/^\d{1,2}\.\d{1,2} \S/u.test(name) || /[\r\n]/u.test(name))
2165
2495
  )
2166
2496
  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 });
2497
+ const launchKeys = [
2498
+ ...(resume
2499
+ ? [adapter.resumeById(resume), "Enter"]
2500
+ : resumeLast
2501
+ ? [adapter.resumeLast(), "Enter"]
2502
+ : buildLaunchKeys(adapter, {
2503
+ model,
2504
+ effort,
2505
+ name,
2506
+ sandbox: opts.sandbox,
2507
+ })),
2508
+ ];
2509
+ if (resumed && adapter.cli === "agy") {
2510
+ const overrides = buildLaunchKeys(adapter, {
2511
+ model,
2512
+ effort,
2513
+ sandbox: opts.sandbox,
2514
+ });
2515
+ launchKeys[0] += overrides[0].slice("agy".length);
2516
+ }
2517
+ launchKeys[0] = withUnsetEnv(launchKeys[0], opts.unsetEnv ?? []);
2172
2518
  if (resumed && name && adapter.cli === "claude")
2173
2519
  launchKeys[0] += ` ${shellQuote(["-n", name])}`;
2174
2520
  const resumeTarget = resume ?? (resumeLast ? "last" : null);
@@ -2207,6 +2553,41 @@ async function doStart(adapter, opts) {
2207
2553
  readyTimeoutMs,
2208
2554
  pollIntervalMs,
2209
2555
  );
2556
+ let unsetEnvVerified = false;
2557
+ let unsetEnvWarning;
2558
+ if (ready && !remote && opts.unsetEnv?.length) {
2559
+ let environment = "";
2560
+ try {
2561
+ const target = await paneCliProcess(session, adapter.cli);
2562
+ if (target)
2563
+ environment =
2564
+ process.platform === "darwin"
2565
+ ? (
2566
+ await execFileAsync(
2567
+ "ps",
2568
+ ["-E", "-ww", "-o", "command=", "-p", String(target.pid)],
2569
+ { timeout: 5000, maxBuffer: MAX_BUFFER },
2570
+ )
2571
+ ).stdout
2572
+ : await readFile(`/proc/${target.pid}/environ`, "utf8");
2573
+ } catch {
2574
+ // 읽기 실패는 변수 잔존의 증거가 아니므로 세션을 유지한다.
2575
+ }
2576
+ const { readable, remaining } = inspectUnsetEnv(
2577
+ environment,
2578
+ opts.unsetEnv,
2579
+ process.platform,
2580
+ );
2581
+ if (remaining.length) {
2582
+ await runTmux(null, ["kill-session", "-t", session]);
2583
+ throw new Error(
2584
+ "--unset-env verification failed: matching variables remain in CLI environment",
2585
+ );
2586
+ }
2587
+ unsetEnvVerified = readable;
2588
+ if (!readable)
2589
+ unsetEnvWarning = "CLI environment unreadable; removal not verified";
2590
+ }
2210
2591
  let nameApplied = Boolean(name) && adapter.cli === "claude" && ready;
2211
2592
  let threadId =
2212
2593
  adapter.cli === "codex" && isCodexThreadId(resume) ? resume : null;
@@ -2248,6 +2629,25 @@ async function doStart(adapter, opts) {
2248
2629
  nameApplied = false;
2249
2630
  }
2250
2631
  }
2632
+ if (name && adapter.cli === "agy" && ready) {
2633
+ try {
2634
+ nameApplied = await renameAgyConversation(
2635
+ session,
2636
+ name,
2637
+ Math.min(readyTimeoutMs, 5000),
2638
+ pollIntervalMs,
2639
+ );
2640
+ } catch {
2641
+ nameApplied = false;
2642
+ }
2643
+ }
2644
+ const namePending = adapter.cli === "agy" && Boolean(name) && !nameApplied;
2645
+ if (namePending)
2646
+ await runTmux(null, ["set-option", "-t", session, "@tfx_agy_name", name]);
2647
+ const conversation =
2648
+ adapter.cli === "agy" && ready
2649
+ ? await agyConversation((await paneCliProcess(session, "agy"))?.pid)
2650
+ : null;
2251
2651
  // ask 의 queue 전송이 레지스트리 없이도 thread 를 찾도록 세션에 남긴다.
2252
2652
  if (threadId && !remote)
2253
2653
  await runTmux(null, [
@@ -2268,6 +2668,12 @@ async function doStart(adapter, opts) {
2268
2668
  nameGenerated: !resumed && opts.name === undefined,
2269
2669
  nameApplied,
2270
2670
  ...(adapter.cli === "codex" ? { threadId } : {}),
2671
+ ...(adapter.cli === "agy"
2672
+ ? { conversationId: conversation?.conversationId ?? null, namePending }
2673
+ : {}),
2674
+ unsetEnv: opts.unsetEnv ?? [],
2675
+ unsetEnvVerified,
2676
+ ...(unsetEnvWarning ? { unsetEnvWarning } : {}),
2271
2677
  ...(name && adapter.cli === "codex" && !nameApplied
2272
2678
  ? {
2273
2679
  nameWarning:
@@ -2287,21 +2693,43 @@ async function doStart(adapter, opts) {
2287
2693
  }
2288
2694
 
2289
2695
  // 빠른 입력 직후의 Enter 는 Codex 입력창이 버릴 수 있어 입력창이 빌 때까지 다시 누른다.
2290
- async function submitComposerLine(remote, session, line) {
2696
+ async function submitComposerLine(remote, session, line, cli) {
2291
2697
  await sleep(300);
2292
2698
  for (let attempt = 1; attempt <= PROMPT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
2293
2699
  await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
2294
2700
  await sleep(300);
2295
- if (!composerShowsPrompt(await captureVisible(remote, session), line))
2701
+ if (!composerShowsPrompt(await captureVisible(remote, session), line, cli))
2296
2702
  return true;
2297
2703
  }
2298
2704
  return false;
2299
2705
  }
2300
2706
 
2707
+ async function renameAgyConversation(session, name, timeoutMs, pollIntervalMs) {
2708
+ const command = `/rename ${name}`;
2709
+ await runTmux(null, ["send-keys", "-t", session, "-l", "--", command]);
2710
+ if (!(await submitComposerLine(null, session, command, "agy"))) return false;
2711
+ const deadline = Date.now() + timeoutMs;
2712
+ do {
2713
+ const visible = stripAnsi(await captureVisible(null, session));
2714
+ if (visible.includes(`Conversation renamed to: ${name}`)) return true;
2715
+ await sleep(Math.min(pollIntervalMs, 200));
2716
+ } while (Date.now() < deadline);
2717
+ return false;
2718
+ }
2719
+
2301
2720
  async function waitForTmuxIdle(adapter, opts) {
2302
2721
  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");
2722
+ if (
2723
+ ![
2724
+ "wait",
2725
+ "fail",
2726
+ "interrupt",
2727
+ ...(adapter.cli === "agy" ? ["queue"] : []),
2728
+ ].includes(ifBusy)
2729
+ ) {
2730
+ throw new Error(
2731
+ "--if-busy must be wait, fail, or interrupt for tmux (agy also supports queue)",
2732
+ );
2305
2733
  }
2306
2734
  const startedAt = Date.now();
2307
2735
  let interrupted = false;
@@ -2309,9 +2737,9 @@ async function waitForTmuxIdle(adapter, opts) {
2309
2737
  const visible = await captureVisible(remote, session);
2310
2738
  const elapsedMs = Date.now() - startedAt;
2311
2739
  const busy =
2312
- isBusyByEsc(visible) ||
2740
+ adapter.isBusy(visible) ||
2313
2741
  (adapter.cli === "codex" && isCodexLoadingCapture(visible));
2314
- if (!busy) return elapsedMs;
2742
+ if (!busy || ifBusy === "queue") return elapsedMs;
2315
2743
  if (ifBusy === "fail") throw new Error("target busy (--if-busy fail)");
2316
2744
  if (elapsedMs >= busyTimeoutMs) {
2317
2745
  throw new Error(
@@ -2337,22 +2765,32 @@ async function doAskViaTmux(adapter, opts) {
2337
2765
  settleMs,
2338
2766
  pollIntervalMs,
2339
2767
  doneMarker,
2340
- ifBusy = "wait",
2768
+ ifBusy = adapter.cli === "agy" ? "queue" : "wait",
2341
2769
  busyTimeoutMs = timeoutMs,
2342
2770
  } = opts;
2771
+ const slashCommand = adapter.cli === "agy" && isSlashCommand(prompt);
2343
2772
  const busyWaitedMs = await waitForTmuxIdle(adapter, {
2344
2773
  session,
2345
2774
  remote,
2346
2775
  pollIntervalMs,
2347
2776
  busyTimeoutMs,
2348
- ifBusy,
2777
+ ifBusy: slashCommand ? "queue" : ifBusy,
2349
2778
  });
2779
+ const queued =
2780
+ adapter.cli === "agy" &&
2781
+ adapter.isBusy(await captureVisible(remote, session));
2350
2782
  const beforeRaw = await capturePane(remote, session);
2351
2783
  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);
2784
+ const context =
2785
+ adapter.cli === "agy"
2786
+ ? modelContext("agy", null)
2787
+ : opts.skipContextGuard
2788
+ ? {}
2789
+ : await tmuxContext(adapter, opts);
2790
+ const guard =
2791
+ opts.skipContextGuard || adapter.cli === "agy"
2792
+ ? {}
2793
+ : contextGuard(adapter.cli, context, opts);
2356
2794
  if (guard.ok === false)
2357
2795
  return {
2358
2796
  ...guard,
@@ -2376,13 +2814,13 @@ async function doAskViaTmux(adapter, opts) {
2376
2814
  ]);
2377
2815
  await sleep(settleMs);
2378
2816
 
2379
- let promptSubmitted = !doneMarker;
2380
- if (doneMarker || opts.noWait) {
2817
+ let promptSubmitted = adapter.cli === "agy" ? false : !doneMarker;
2818
+ if (doneMarker || opts.noWait || adapter.cli === "agy") {
2381
2819
  for (let attempt = 1; attempt <= PROMPT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
2382
2820
  await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
2383
2821
  await sleep(PROMPT_SUBMIT_RETRY_DELAY_MS);
2384
2822
  const submissionVisible = await captureVisible(remote, session);
2385
- if (!composerShowsPrompt(submissionVisible, prompt)) {
2823
+ if (!composerShowsPrompt(submissionVisible, prompt, adapter.cli)) {
2386
2824
  promptSubmitted = true;
2387
2825
  break;
2388
2826
  }
@@ -2396,7 +2834,61 @@ async function doAskViaTmux(adapter, opts) {
2396
2834
  await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
2397
2835
  }
2398
2836
 
2399
- if (opts.noWait) {
2837
+ let nameApplied;
2838
+ if (adapter.cli === "agy" && !slashCommand) {
2839
+ const { stdout } = await runTmux(null, [
2840
+ "show-options",
2841
+ "-qv",
2842
+ "-t",
2843
+ splitTmuxTarget(session).session,
2844
+ "@tfx_agy_name",
2845
+ ]);
2846
+ const pendingName = stdout.trim();
2847
+ if (pendingName) {
2848
+ nameApplied = false;
2849
+ let db;
2850
+ try {
2851
+ const { DatabaseSync } = await import("node:sqlite");
2852
+ const deadline = Date.now() + 5000;
2853
+ while (Date.now() < deadline) {
2854
+ if (!db) {
2855
+ const agyProcess = await paneCliProcess(session, "agy");
2856
+ const conversation = await agyConversation(agyProcess?.pid);
2857
+ if (conversation)
2858
+ db = new DatabaseSync(conversation.dbPath, { readOnly: true });
2859
+ }
2860
+ if (
2861
+ db
2862
+ ?.prepare("SELECT 1 FROM steps WHERE step_type = 14 LIMIT 1")
2863
+ .get()
2864
+ ) {
2865
+ nameApplied = await renameAgyConversation(
2866
+ session,
2867
+ pendingName,
2868
+ 5000,
2869
+ pollIntervalMs,
2870
+ );
2871
+ if (nameApplied)
2872
+ await runTmux(null, [
2873
+ "set-option",
2874
+ "-u",
2875
+ "-t",
2876
+ splitTmuxTarget(session).session,
2877
+ "@tfx_agy_name",
2878
+ ]);
2879
+ break;
2880
+ }
2881
+ await sleep(Math.min(pollIntervalMs, 200));
2882
+ }
2883
+ } catch {
2884
+ nameApplied = false;
2885
+ } finally {
2886
+ db?.close();
2887
+ }
2888
+ }
2889
+ }
2890
+
2891
+ if (opts.noWait || slashCommand) {
2400
2892
  return {
2401
2893
  ...context,
2402
2894
  ...guard,
@@ -2405,7 +2897,9 @@ async function doAskViaTmux(adapter, opts) {
2405
2897
  transport: "tmux",
2406
2898
  session,
2407
2899
  remote: remote ?? null,
2408
- status: "submitted",
2900
+ status: queued && !slashCommand ? "queued" : "submitted",
2901
+ ...(slashCommand ? { slashCommand: true } : {}),
2902
+ ...(nameApplied !== undefined ? { nameApplied } : {}),
2409
2903
  inputSent: true,
2410
2904
  done: false,
2411
2905
  submittedAt: new Date().toISOString(),
@@ -2415,6 +2909,22 @@ async function doAskViaTmux(adapter, opts) {
2415
2909
  };
2416
2910
  }
2417
2911
 
2912
+ if (adapter.cli === "agy") {
2913
+ return {
2914
+ ...context,
2915
+ ...(await waitAgy({
2916
+ session,
2917
+ requestId: opts.noRelayTag ? null : opts.requestId,
2918
+ timeoutMs,
2919
+ pollIntervalMs,
2920
+ })),
2921
+ transport: "tmux",
2922
+ inputSent: true,
2923
+ ...(nameApplied !== undefined ? { nameApplied } : {}),
2924
+ ifBusy,
2925
+ busyWaitedMs,
2926
+ };
2927
+ }
2418
2928
  const startedAt = Date.now();
2419
2929
  let raw = "";
2420
2930
  let visible = "";
@@ -2512,7 +3022,8 @@ async function doAsk(adapter, opts) {
2512
3022
  // Codex 는 접힌 발신자 표시가 없어 첫 줄 머리말로 보낸 세션을 드러낸다.
2513
3023
  // 슬래시 명령은 첫 글자가 / 여야 실행되므로 표식을 붙이지 않는다.
2514
3024
  const bare =
2515
- opts.noRelayTag || (adapter.cli === "codex" && isSlashCommand(opts.prompt));
3025
+ opts.noRelayTag ||
3026
+ (["codex", "agy"].includes(adapter.cli) && isSlashCommand(opts.prompt));
2516
3027
  const from =
2517
3028
  adapter.cli === "codex" && !bare
2518
3029
  ? (opts.from ?? (await resolveSenderName()))
@@ -3067,6 +3578,13 @@ async function doStop(adapter, opts) {
3067
3578
 
3068
3579
  async function doInterruptViaTmux(adapter, opts) {
3069
3580
  await runTmux(opts.remote, ["send-keys", "-t", opts.session, "Escape"]);
3581
+ if (adapter.cli === "agy")
3582
+ await waitForTmuxIdle(adapter, {
3583
+ ...opts,
3584
+ ifBusy: "wait",
3585
+ busyTimeoutMs: opts.timeoutMs,
3586
+ pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
3587
+ });
3070
3588
  return {
3071
3589
  cli: adapter.cli,
3072
3590
  session: opts.session,
@@ -3173,6 +3691,8 @@ async function doInterrupt(adapter, opts) {
3173
3691
  }
3174
3692
 
3175
3693
  function startOpts(flags) {
3694
+ const unsetEnv =
3695
+ flags["unset-env"] === undefined ? [] : flags["unset-env"].split(",");
3176
3696
  const session = requireFlag(flags, "session");
3177
3697
  if (session.includes(":"))
3178
3698
  throw new Error("start --session accepts only a session name without ':'");
@@ -3185,6 +3705,8 @@ function startOpts(flags) {
3185
3705
  model: flags.model,
3186
3706
  effort: flags.effort,
3187
3707
  name: flags.name,
3708
+ sandbox: Object.hasOwn(flags, "sandbox"),
3709
+ unsetEnv,
3188
3710
  readyTimeoutMs: secondsFlag(
3189
3711
  flags,
3190
3712
  "ready-timeout",
@@ -3243,6 +3765,8 @@ function askOpts(flags, adapter) {
3243
3765
  short,
3244
3766
  sessionId,
3245
3767
  );
3768
+ if (adapter.cli === "agy" && transport !== "tmux")
3769
+ throw new Error("agy ask supports local --transport tmux only");
3246
3770
  const codexUds = adapter.cli === "codex" && transport === "uds";
3247
3771
  const codexQueue = transport === "queue";
3248
3772
  if (adapter.cli === "codex" && transport === "auto")
@@ -3292,7 +3816,7 @@ function askOpts(flags, adapter) {
3292
3816
  ? resolveCodexDaemonSocket(flags["codex-socket"] ?? "default")
3293
3817
  : null,
3294
3818
  cwd: flags.cwd,
3295
- ifBusy: flags["if-busy"] ?? "wait",
3819
+ ifBusy: flags["if-busy"] ?? (adapter.cli === "agy" ? "queue" : "wait"),
3296
3820
  busyTimeoutMs: secondsFlag(
3297
3821
  flags,
3298
3822
  "busy-timeout",
@@ -3343,6 +3867,8 @@ function interruptOpts(flags, adapter) {
3343
3867
  const short = flags.short;
3344
3868
  const sessionId = flags["session-id"];
3345
3869
  const transport = transportFlag(flags, adapter, short, sessionId);
3870
+ if (adapter.cli === "agy" && transport !== "tmux")
3871
+ throw new Error("agy interrupt supports local --transport tmux only");
3346
3872
  if (transport === "queue")
3347
3873
  throw new Error("interrupt uses tmux; queue cannot send keys");
3348
3874
  if (adapter.cli === "codex" && transport === "uds")
@@ -3464,6 +3990,26 @@ async function queue(flags) {
3464
3990
  }
3465
3991
 
3466
3992
  async function wait(flags) {
3993
+ if (flags.cli === "agy") {
3994
+ selectAdapter(flags);
3995
+ if (flags.transport && flags.transport !== "tmux")
3996
+ throw new Error("agy wait supports local --transport tmux only");
3997
+ return printJson(
3998
+ await waitAgy({
3999
+ session: splitTmuxTarget(requireFlag(flags, "session")).target,
4000
+ requestId: flags["request-id"] ?? null,
4001
+ timeoutMs:
4002
+ flags.timeout === "0"
4003
+ ? 0
4004
+ : secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
4005
+ pollIntervalMs: msFlag(
4006
+ flags,
4007
+ "poll-interval",
4008
+ DEFAULT_POLL_INTERVAL_MS,
4009
+ ),
4010
+ }),
4011
+ );
4012
+ }
3467
4013
  if (flags.cli === "codex") return waitCodex(flags);
3468
4014
  if ((flags.cli ?? "claude") !== "claude")
3469
4015
  throw new Error("wait supports --cli claude or codex");
@@ -3568,6 +4114,8 @@ async function tmuxContext(adapter, opts) {
3568
4114
  }
3569
4115
 
3570
4116
  async function compact(flags) {
4117
+ if (flags.cli === "agy")
4118
+ throw new Error("agy has no compact; use /clear or a handoff");
3571
4119
  if (
3572
4120
  (flags.cli ?? "claude") !== "claude" ||
3573
4121
  flags.remote ||
@@ -3654,6 +4202,11 @@ async function probe(flags) {
3654
4202
 
3655
4203
  async function listSessions(flags) {
3656
4204
  const adapter = selectAdapter(flags);
4205
+ if (adapter.cli === "agy") {
4206
+ if (flags.transport && flags.transport !== "tmux")
4207
+ throw new Error("agy list-sessions supports local --transport tmux only");
4208
+ return printJson(await discoverAgyTmuxSessions({ cwd: flags.cwd }));
4209
+ }
3657
4210
  if (adapter.cli === "claude") {
3658
4211
  if (flags.remote || flags.transport === "uds") {
3659
4212
  throw new Error(
@@ -3788,6 +4341,7 @@ function parseFirstInteger(response) {
3788
4341
 
3789
4342
  async function converse(flags) {
3790
4343
  const adapter = selectAdapter(flags);
4344
+ if (adapter.cli === "agy") throw new Error("agy is unavailable for converse");
3791
4345
  const session = requireFlag(flags, "session");
3792
4346
  const promptsFile = requireFlag(flags, "prompts-file");
3793
4347
  const prompts = await readPromptsFile(promptsFile);
@@ -3823,6 +4377,8 @@ async function converse(flags) {
3823
4377
 
3824
4378
  async function goalDriven(flags) {
3825
4379
  const adapter = selectAdapter(flags);
4380
+ if (adapter.cli === "agy")
4381
+ throw new Error("agy is unavailable for goal-driven");
3826
4382
  const session = requireFlag(flags, "session");
3827
4383
  const goal = requireFlag(flags, "goal");
3828
4384
  const maxRounds = integerFlag(flags, "max-rounds", 8);
@@ -4317,6 +4873,8 @@ function addStopSpecOnce(stopSpecs, adapter, opts) {
4317
4873
  async function peer(flags) {
4318
4874
  const adapterA = selectAdapterName(flags["cli-a"] ?? "codex", "cli-a");
4319
4875
  const adapterB = selectAdapterName(flags["cli-b"] ?? "claude", "cli-b");
4876
+ if (adapterA.cli === "agy" || adapterB.cli === "agy")
4877
+ throw new Error("agy is unavailable for peer");
4320
4878
  peerSideBusyPolicy(
4321
4879
  flags,
4322
4880
  "a",
@@ -4587,12 +5145,15 @@ async function main() {
4587
5145
 
4588
5146
  export {
4589
5147
  ADAPTERS,
5148
+ agyStepText,
5149
+ agyWaitVerdict,
4590
5150
  buildLaunchKeys,
4591
5151
  buildPeerClosurePrompt,
4592
5152
  buildRemoteLiveCommand,
4593
5153
  buildTmuxCommand,
4594
5154
  callRemoteLive,
4595
5155
  classifyPeerExitReason,
5156
+ composerShowsPrompt,
4596
5157
  createPeerSignalController,
4597
5158
  derivePeerStatus,
4598
5159
  discoverClaudeTmuxSessions,
@@ -4603,6 +5164,7 @@ export {
4603
5164
  extractResponseSinceMarker,
4604
5165
  hasClaudeCompletedTaskListResponse,
4605
5166
  inspectCodexTmuxPane,
5167
+ inspectUnsetEnv,
4606
5168
  isCodexLoadingCapture,
4607
5169
  parseCodexTmuxSessions,
4608
5170
  parsePeerClosure,
@@ -4615,6 +5177,7 @@ export {
4615
5177
  stopOpts,
4616
5178
  tmuxBufferName,
4617
5179
  verifyAttachedPeerSide,
5180
+ withUnsetEnv,
4618
5181
  };
4619
5182
 
4620
5183
  function isMainModule() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.59.2",
3
+ "version": "10.60.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,25 @@ 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
+ 새 세션 이름은 `<월>.<일> <주제>`로 짓는다. `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
+
30
+ ## agy 세션
31
+
32
+ ```bash
33
+ tfx-live start --cli agy --session ag1 --cwd ~/Projects --name "10.11 구현 검토" --effort low --unset-env 'TOSSINVEST_*,PRIVATE_KEY'
34
+ tfx-live ask --cli agy --session ag1 --prompt "현재 변경사항을 요약해줘" --no-wait
35
+ tfx-live wait --cli agy --session ag1 --request-id <requestId> --timeout 120
36
+ tfx-live list-sessions --cli agy --cwd ~/Projects
37
+ tfx-live stop --cli agy --session ag1
38
+ ```
39
+
40
+ `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`로 보고한다.
41
+
42
+ 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를 바꾼다.
43
+
44
+ `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`은 지원하지 않는다.
45
+
46
+ `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
47
 
30
48
  ## 리드 운영
31
49
 
@@ -70,7 +88,7 @@ tfx-live queue --cli codex --thread <UUID> --delete <queuedMessageId>
70
88
  | Codex | 15% | 18% | 20% | 22% |
71
89
  | agy | 12% | 없음 | 15% | 18% |
72
90
 
73
- Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 `tfx-live` 전송 대상이 아니다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
91
+ Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
74
92
 
75
93
  결과에는 `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
94