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.
- package/adapters/codex/skills/tfx-live/SKILL.md +25 -5
- package/bin/tfx-live.mjs +836 -38
- package/package.json +1 -1
- package/skills/tfx-live/SKILL.md +25 -5
|
@@ -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
|
-
|
|
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` 전송
|
|
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
|
|
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 (
|
|
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 =
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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 (
|
|
2304
|
-
|
|
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
|
-
|
|
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 =
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
package/skills/tfx-live/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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` 전송
|
|
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
|
|