triflux 10.62.0 → 10.64.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 +19 -8
- package/bin/tfx-live.mjs +327 -94
- package/hub/team/claude-transcript.mjs +1 -0
- package/hub/team/session-context.mjs +41 -12
- package/package.json +1 -1
- package/scripts/pack.mjs +41 -29
- package/scripts/release/check-packages-mirror.mjs +25 -21
- package/skills/tfx-live/SKILL.md +19 -8
|
@@ -33,6 +33,8 @@ macOS의 iTerm2에서 로컬 tmux 세션을 `start`하면 리드가 있는 창
|
|
|
33
33
|
|
|
34
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
35
|
|
|
36
|
+
Codex 폴더 신뢰 화면이나 Claude 프로젝트 MCP 승인 화면이 뜨면 `start`는 화면에 답하지 않고 `ready: false`, `blockedBy`(`codex-trust-folder` 또는 `claude-mcp-approval`), 할 일을 적은 `warning`을 반환한다. 세션은 남아 있으므로 tmux 세션에 붙어 직접 고른 뒤 지시를 보낸다.
|
|
37
|
+
|
|
36
38
|
## agy 세션
|
|
37
39
|
|
|
38
40
|
```bash
|
|
@@ -57,11 +59,13 @@ agy `ask`의 기본 `--if-busy queue`는 바쁜 턴에도 바로 전송한다. a
|
|
|
57
59
|
|
|
58
60
|
| 동작 | 적용 조건과 실행 |
|
|
59
61
|
| --- | --- |
|
|
60
|
-
| compact | 같은 목표와 담당이 이어지고 파일과 git에서 상태를 복구할 수 있을 때. Claude는 `tfx-live compact
|
|
62
|
+
| compact | 같은 목표와 담당이 이어지고 파일과 git에서 상태를 복구할 수 있을 때. Claude와 Codex는 `tfx-live compact --then "다음 지시" --no-wait`로 압축 제출을 확인하고 다음 지시를 미리 넣는다. 압축 뒤에 필요한 파일 경로와 검증 명령도 `--then`에 싣는다. agy는 수동 compact가 없어 handoff한다. |
|
|
61
63
|
| clear | 무관한 작업으로 전환하거나 같은 문제를 두 번 넘게 고쳐도 실패할 때, 스펙에서 구현으로 또는 작성자에서 리뷰어로 바뀔 때. 미완료 상태를 정리한 뒤 Claude는 `/rename`과 `/clear`, Codex는 작업 종료 뒤 `/new <이름>`, agy는 `/clear`를 쓴다. |
|
|
62
64
|
| handoff | 담당·CLI·모델·소유자가 바뀌거나 긴 작업이 창을 넘길 때. 인계 파일에 목표, 완료 조건, 체크아웃과 변경 상태, 확정 결정, 증거 경로, PASS/FAIL/SKIP, 미완료 작업과 프로세스, 다음 행동과 소유자, `predecessorSessionId`를 적는다. 승계 세션이 경로를 확인하면 이전 세션을 닫는다. |
|
|
63
65
|
| close | 결과와 인계를 확인한 즉시 닫는다. Claude 백그라운드는 `claude stop <id>`, tmux는 `tfx-live stop --session`, Claude 팀원은 shutdown, Codex 서브에이전트는 `close_agent`, agy 서브에이전트는 `/agents` 패널의 K를 쓴다. |
|
|
64
66
|
|
|
67
|
+
Claude TUI는 바쁠 때 들어온 입력을 대기열에 넣고 현재 턴(압축 포함)이 끝나면 순서대로 처리한다. transcript에는 `queue-operation`의 `enqueue`와 `dequeue`로 남는다. Codex app-server 큐도 바쁜 턴 뒤에 순서대로 처리한다. 리드는 compact를 보내고 `--then`으로 다음 지시를 넣는 것을 기본 절차로 쓴다.
|
|
68
|
+
|
|
65
69
|
두 번째 compact 전에는 handoff가 맞는지 점검한다. compact 횟수는 고정하지 않는다.
|
|
66
70
|
|
|
67
71
|
리드 역할 절차(역할별 모델, 지시서, 교차 리뷰, 릴리스 조율)는 `tfx-lead` 스킬을 따른다.
|
|
@@ -103,30 +107,37 @@ Claude와 Codex는 네이티브 goal을 우선 쓰고, goal-driven은 agy처럼
|
|
|
103
107
|
|
|
104
108
|
### 컨텍스트 판단
|
|
105
109
|
|
|
106
|
-
분모는 공식 모델
|
|
110
|
+
Claude의 분모는 공식 모델 창과 실행 한도 중 작은 값이다. Codex의 분모는 rollout이 보고한 실행 창이고, 없을 때만 공식 창이다. 아래 비율은 공식 권고가 아닌 **[추론] 운영값**이다. 모델 문서가 바뀌면 한도도 갱신한다.
|
|
107
111
|
|
|
108
112
|
| CLI | 준비와 경고 | compact | 신규 배정 중지·handoff | 전송 거부 |
|
|
109
113
|
| --- | ---: | ---: | ---: | ---: |
|
|
110
114
|
| Claude Code 1M 모델 | 60% | 75% | 85% | 90% |
|
|
111
|
-
| Codex |
|
|
115
|
+
| Codex rollout 실행 창 | 60% | - | - | 85% |
|
|
116
|
+
| Codex 실행 창 없음(1.05M 분모) | 15% | 18% | 20% | 22% |
|
|
112
117
|
| agy | 12% | 없음 | 15% | 18% |
|
|
113
118
|
|
|
114
|
-
Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
|
|
119
|
+
Claude는 85% 도달 시 바로 handoff를 지시한다. Codex 실행 창 행의 compact와 handoff 비율은 아직 정하지 않았다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
|
|
115
120
|
|
|
116
|
-
결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다.
|
|
121
|
+
결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다. Codex는 rollout `token_count`의 `model_context_window`를 `executionContextLimitTokens`로 보고한다. 이 값이 있으면 모델 표에 없는 모델이라도 그 창으로 판정하고, 없으면 1,050,000 표 값을 분모로 쓴다. 한도를 모르면 비율은 `null`로 두고 경고만 한다. 사용량이 없는 `probe`는 `context: null`이다. 컨텍스트를 확인하려면 직접 `tfx-live probe`를 실행한다. `interrupt`, 자동 `ask`의 daemon probe, `stop --short`는 transcript를 읽지 않는다.
|
|
117
122
|
|
|
118
|
-
`ask`의 `--warn-context-pct`와 `--max-context-pct`는 경고·거부 비율을 덮어쓰며 0이면 해당 가드를 끈다. 기본값은 표의 경고·전송 거부 열이다. 거부 결과는 `status: "failed"`, `done: false`, `context-limit
|
|
123
|
+
`ask`의 `--warn-context-pct`와 `--max-context-pct`는 경고·거부 비율을 덮어쓰며 0이면 해당 가드를 끈다. 기본값은 표의 경고·전송 거부 열이다. Codex는 `executionContextLimitTokens`가 있으면 실행 창 행을, 없으면 1.05M 분모 행을 쓴다. 거부 결과는 `status: "failed"`, `done: false`, `context-limit`, 컨텍스트 필드와 적용한 거부 비율 `thresholdPct`를 돌려준다. 경고의 `thresholdPct`는 적용한 경고 비율이다. 자세한 근거와 실측은 [컨텍스트 정책 연구](../../docs/research/tfx-live-context-policy-2026-10.md)에 둔다.
|
|
119
124
|
|
|
120
125
|
### 수동 compact와 종료
|
|
121
126
|
|
|
122
127
|
```bash
|
|
123
|
-
tfx-live compact --cli claude --session cl1 --instructions "변경 파일과 검증 명령을 보존하라" --
|
|
128
|
+
tfx-live compact --cli claude --session cl1 --instructions "변경 파일과 검증 명령을 보존하라" --then "인계 파일을 읽고 검증을 이어가라" --no-wait
|
|
129
|
+
tfx-live compact --cli codex --session cx1 --then "src/main.mjs를 확인하고 지정된 단위 테스트를 실행하라" --no-wait
|
|
130
|
+
tfx-live wait --cli codex --session cx1 --request-id <then.requestId> --timeout 120
|
|
124
131
|
tfx-live stop --cli claude --short <8hex>
|
|
125
132
|
tfx-live stop --cli claude --session-id <id>
|
|
126
133
|
tfx-live stop --session cl1
|
|
127
134
|
```
|
|
128
135
|
|
|
129
|
-
`compact`는 로컬
|
|
136
|
+
`compact --session S [--cli claude|codex] [--instructions TEXT] [--then PROMPT] [--no-wait]`는 로컬 tmux에서 `/compact`를 중계 표식 없이 보낸다. CLI 기본값은 Claude다. `--instructions`는 Claude에서만 받으며 Codex는 인자를 받지 않아 거부한다. 기본 `--if-busy fail`이며 바쁜 세션에는 compact를 전송하지 않는다. UDS compact는 지원하지 않는다.
|
|
137
|
+
|
|
138
|
+
`--no-wait`는 압축 진행 표시 또는 새 완료 기록으로 제출을 확인한 뒤 반환한다. Codex에서 확인되지 않으면 Enter를 한 번 더 보내고, 그래도 확인되지 않으면 `--then`을 보내지 않고 오류로 끝낸다. `--then`은 일반 텍스트 지시만 받으며 슬래시 명령은 거부한다. 제출 확인 직후 Claude에는 표식을 붙여 tmux로 보내고 화면의 `queued messages` 또는 transcript의 `enqueue`로 확인한다. Claude 압축이 먼저 끝나 지시가 이미 처리에 들어갔으면 `then.queued: false, delivered: true`로 보고한다. Codex에는 기존 queue 경로로만 보내며 tmux로 전환하지 않는다. 결과의 `then.requestId`로 `wait --request-id`에서 압축 뒤 응답을 받는다.
|
|
139
|
+
|
|
140
|
+
`--no-wait`가 없으면 Claude의 새 `compact_boundary`, Codex rollout의 새 `compacted`까지 기다린다. 결과에 `compact: {submitted: true, done}`과 `then: {requestId, queued, transport}`를 추가하고 기존 `compacted`, `preTokens`, `postTokens`를 유지한다. 토큰 수는 기록에 있는 값만 보고하며 Codex의 압축 후 token_count가 아직 없으면 `postTokens`는 `null`이다. 시간 안에 완료 기록이 없으면 `compacted: false, timedOut: true`다.
|
|
130
141
|
|
|
131
142
|
`stop --short`와 `stop --session-id`는 `claude stop <id>`를 호출하고 `stopped: true, conversationKept: true`를 반환한다. `claude attach`로 대화를 재개할 수 있다. `/exit`, `←`, Ctrl+Z는 백그라운드 세션의 연결만 끊는다. macOS의 `sysctl -n kern.memorystatus_vm_pressure_level`이 2 이상이면 새 세션을 띄우기 전 정리한다. 원격 배정 전에는 `tfx-remote probe <host>`의 `ready`와 `warnings`를 확인한다.
|
|
132
143
|
|
package/bin/tfx-live.mjs
CHANGED
|
@@ -53,7 +53,6 @@ import {
|
|
|
53
53
|
validateHost as validateRemoteHost,
|
|
54
54
|
} from "../hub/team/remote-session.mjs";
|
|
55
55
|
import {
|
|
56
|
-
CONTEXT_THRESHOLDS,
|
|
57
56
|
contextGuard,
|
|
58
57
|
findCodexRollout,
|
|
59
58
|
modelContext,
|
|
@@ -107,8 +106,8 @@ function usage(command) {
|
|
|
107
106
|
" 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.",
|
|
108
107
|
" 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]",
|
|
109
108
|
" tfx-live ask --transport uds|auto (--short SHORT | --session-id ID) --prompt TEXT [--config-dir DIR] [--bridge ABS] [--session NAME (auto fallback)] [--timeout 60]",
|
|
110
|
-
" ask options: --no-wait --no-relay-tag --warn-context-pct N --max-context-pct N (0 disables; Claude 60/90, Codex 15/22).",
|
|
111
|
-
" tfx-live compact --
|
|
109
|
+
" ask options: --no-wait --no-relay-tag --warn-context-pct N --max-context-pct N (0 disables; Claude 60/90, Codex 60/85 of rollout window or 15/22 of 1.05M).",
|
|
110
|
+
" tfx-live compact --session NAME [--cli claude|codex] [--instructions TEXT] [--then PROMPT] [--no-wait] [--if-busy fail|wait] [--timeout 60]",
|
|
112
111
|
" tfx-live goal set --session NAME [--cli claude|codex] --condition TEXT [--budget N]",
|
|
113
112
|
" tfx-live goal status --session NAME [--cli claude|codex]",
|
|
114
113
|
" tfx-live goal clear --session NAME [--cli claude|codex]",
|
|
@@ -220,8 +219,10 @@ function integerFlag(flags, name, defaultValue) {
|
|
|
220
219
|
return value;
|
|
221
220
|
}
|
|
222
221
|
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
// 기본값은 contextGuard 가 읽은 실행 창에 맞춰 고른다.
|
|
223
|
+
function contextPctFlag(flags, name) {
|
|
224
|
+
if (flags[name] === undefined) return undefined;
|
|
225
|
+
const value = Number(flags[name]);
|
|
225
226
|
if (!Number.isFinite(value) || value < 0 || value > 100)
|
|
226
227
|
throw new Error(`--${name} must be between 0 and 100`);
|
|
227
228
|
return value;
|
|
@@ -230,16 +231,8 @@ function contextPctFlag(flags, name, defaultValue) {
|
|
|
230
231
|
function contextOpts(flags, cli) {
|
|
231
232
|
if (cli === "agy") return {};
|
|
232
233
|
return {
|
|
233
|
-
warnContextPct: contextPctFlag(
|
|
234
|
-
|
|
235
|
-
"warn-context-pct",
|
|
236
|
-
CONTEXT_THRESHOLDS[cli].warnContextPct,
|
|
237
|
-
),
|
|
238
|
-
maxContextPct: contextPctFlag(
|
|
239
|
-
flags,
|
|
240
|
-
"max-context-pct",
|
|
241
|
-
CONTEXT_THRESHOLDS[cli].maxContextPct,
|
|
242
|
-
),
|
|
234
|
+
warnContextPct: contextPctFlag(flags, "warn-context-pct"),
|
|
235
|
+
maxContextPct: contextPctFlag(flags, "max-context-pct"),
|
|
243
236
|
};
|
|
244
237
|
}
|
|
245
238
|
|
|
@@ -1770,6 +1763,49 @@ function isClaudeExternalImportsPrompt(text) {
|
|
|
1770
1763
|
);
|
|
1771
1764
|
}
|
|
1772
1765
|
|
|
1766
|
+
// 승인 화면에서는 마지막 선택 글리프 줄이 메뉴 항목이다. 문구를 인용한 대화라면
|
|
1767
|
+
// 그 아래 입력창이 마지막 글리프 줄이 되므로 걸러진다. 위쪽 문구는 테두리와
|
|
1768
|
+
// 줄바꿈이 끊어도 맞도록 공백 하나로 접는다.
|
|
1769
|
+
function splitAtLastSelector(text, glyph) {
|
|
1770
|
+
const lines = stripAnsi(text)
|
|
1771
|
+
.split("\n")
|
|
1772
|
+
.map((line) => line.replace(/^[\s│┃]+/, ""));
|
|
1773
|
+
const index = lines.findLastIndex((line) => line.startsWith(glyph));
|
|
1774
|
+
if (index < 0) return null;
|
|
1775
|
+
const isRule = (line = "") => /^─{3,}/.test(line);
|
|
1776
|
+
return {
|
|
1777
|
+
above: lines.slice(0, index).join(" ").replace(/\s+/g, " "),
|
|
1778
|
+
selected: lines[index],
|
|
1779
|
+
// Claude 입력창은 위아래 가로줄 상자다. 여러 줄 입력이면 아래 가로줄이 몇 줄 뒤에 온다.
|
|
1780
|
+
inComposerBox:
|
|
1781
|
+
isRule(lines[index - 1]) && lines.slice(index + 1).some(isRule),
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function isCodexTrustFolderPrompt(text) {
|
|
1786
|
+
const menu = splitAtLastSelector(text, "›");
|
|
1787
|
+
return (
|
|
1788
|
+
Boolean(menu) &&
|
|
1789
|
+
/trust this folder\?/i.test(menu.above) &&
|
|
1790
|
+
/^›\s*(?:\d+\.\s*)?(?:trust and continue|open restricted)/i.test(
|
|
1791
|
+
menu.selected,
|
|
1792
|
+
)
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// 다중 선택 항목은 한 글자 체크박스로 그린다. 바이너리 상수: [ ] [×] [✔] [√] ☐ ☒, 라디오 ( ) (*) ◯ ◉.
|
|
1797
|
+
function isClaudeMcpApprovalPrompt(text) {
|
|
1798
|
+
const menu = splitAtLastSelector(text, "❯");
|
|
1799
|
+
return (
|
|
1800
|
+
Boolean(menu) &&
|
|
1801
|
+
!menu.inComposerBox &&
|
|
1802
|
+
/new MCP servers? found in this project/i.test(menu.above) &&
|
|
1803
|
+
/^❯\s*(?:\d+\.\s*)?(?:use this|continue without using this MCP server|enable selected|\[.\]|\(.\)|[☐☒◯◉])/iu.test(
|
|
1804
|
+
menu.selected,
|
|
1805
|
+
)
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1773
1809
|
function isClaudeTourPrompt(text) {
|
|
1774
1810
|
// Claude's post-update welcome/tour screen ("Take the tour" / "Skip for now").
|
|
1775
1811
|
// Claude has no codex-style "Update available / Skip until next version" menu
|
|
@@ -2024,6 +2060,15 @@ const ADAPTERS = {
|
|
|
2024
2060
|
dismiss: dismissCodexDaemonSettingsPrompt,
|
|
2025
2061
|
},
|
|
2026
2062
|
],
|
|
2063
|
+
// 사람의 보안 판단이 필요한 화면이라 고르지 않고 start 를 ready:false 로 끝낸다.
|
|
2064
|
+
blockingScreens: [
|
|
2065
|
+
{
|
|
2066
|
+
name: "codex-trust-folder",
|
|
2067
|
+
isPresent: isCodexTrustFolderPrompt,
|
|
2068
|
+
warning:
|
|
2069
|
+
"Codex is asking whether to trust this folder; attach to the tmux session and answer it yourself",
|
|
2070
|
+
},
|
|
2071
|
+
],
|
|
2027
2072
|
},
|
|
2028
2073
|
claude: {
|
|
2029
2074
|
cli: "claude",
|
|
@@ -2081,6 +2126,14 @@ const ADAPTERS = {
|
|
|
2081
2126
|
dismiss: dismissClaudeTourPrompt,
|
|
2082
2127
|
},
|
|
2083
2128
|
],
|
|
2129
|
+
blockingScreens: [
|
|
2130
|
+
{
|
|
2131
|
+
name: "claude-mcp-approval",
|
|
2132
|
+
isPresent: isClaudeMcpApprovalPrompt,
|
|
2133
|
+
warning:
|
|
2134
|
+
"Claude is asking to approve project MCP servers; attach to the tmux session and answer it yourself",
|
|
2135
|
+
},
|
|
2136
|
+
],
|
|
2084
2137
|
},
|
|
2085
2138
|
agy: {
|
|
2086
2139
|
cli: "agy",
|
|
@@ -2204,6 +2257,17 @@ async function waitForReady(
|
|
|
2204
2257
|
|
|
2205
2258
|
while (Date.now() - startedAt < timeoutMs) {
|
|
2206
2259
|
raw = await captureVisible(remote, session);
|
|
2260
|
+
const blocking = adapter.blockingScreens?.find((screen) =>
|
|
2261
|
+
screen.isPresent(raw),
|
|
2262
|
+
);
|
|
2263
|
+
if (blocking) {
|
|
2264
|
+
return {
|
|
2265
|
+
ready: false,
|
|
2266
|
+
raw,
|
|
2267
|
+
blockedBy: blocking.name,
|
|
2268
|
+
warning: blocking.warning,
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2207
2271
|
if (isStartupScreen(adapter, raw)) {
|
|
2208
2272
|
await dismissStartupScreens(adapter, remote, session);
|
|
2209
2273
|
await sleep(pollIntervalMs);
|
|
@@ -2706,7 +2770,7 @@ async function doStart(adapter, opts) {
|
|
|
2706
2770
|
await runTmux(remote, ["send-keys", "-t", session, ...launchKeys]);
|
|
2707
2771
|
await dismissStartupScreens(adapter, remote, session);
|
|
2708
2772
|
|
|
2709
|
-
const { ready, raw } = await waitForReady(
|
|
2773
|
+
const { ready, raw, blockedBy, warning } = await waitForReady(
|
|
2710
2774
|
adapter,
|
|
2711
2775
|
remote,
|
|
2712
2776
|
session,
|
|
@@ -2886,6 +2950,7 @@ end tell`;
|
|
|
2886
2950
|
resumed,
|
|
2887
2951
|
resumeTarget,
|
|
2888
2952
|
ready,
|
|
2953
|
+
...(blockedBy ? { blockedBy, warning } : {}),
|
|
2889
2954
|
name,
|
|
2890
2955
|
nameGenerated: !resumed && opts.name === undefined,
|
|
2891
2956
|
nameApplied,
|
|
@@ -2941,7 +3006,10 @@ async function renameAgyConversation(session, name, timeoutMs, pollIntervalMs) {
|
|
|
2941
3006
|
return false;
|
|
2942
3007
|
}
|
|
2943
3008
|
|
|
2944
|
-
async function waitForTmuxIdle(adapter, opts) {
|
|
3009
|
+
async function waitForTmuxIdle(adapter, opts, deps = {}) {
|
|
3010
|
+
const capture = deps.captureVisible ?? captureVisible;
|
|
3011
|
+
const tmux = deps.runTmux ?? runTmux;
|
|
3012
|
+
const pause = deps.sleep ?? sleep;
|
|
2945
3013
|
const { session, remote, pollIntervalMs, busyTimeoutMs, ifBusy } = opts;
|
|
2946
3014
|
if (
|
|
2947
3015
|
![
|
|
@@ -2958,7 +3026,7 @@ async function waitForTmuxIdle(adapter, opts) {
|
|
|
2958
3026
|
const startedAt = Date.now();
|
|
2959
3027
|
let interrupted = false;
|
|
2960
3028
|
while (true) {
|
|
2961
|
-
const visible = await
|
|
3029
|
+
const visible = await capture(remote, session);
|
|
2962
3030
|
const elapsedMs = Date.now() - startedAt;
|
|
2963
3031
|
const busy =
|
|
2964
3032
|
adapter.isBusy(visible) ||
|
|
@@ -2971,16 +3039,20 @@ async function waitForTmuxIdle(adapter, opts) {
|
|
|
2971
3039
|
);
|
|
2972
3040
|
}
|
|
2973
3041
|
if (ifBusy === "interrupt" && !interrupted) {
|
|
2974
|
-
await
|
|
3042
|
+
await tmux(remote, ["send-keys", "-t", session, "Escape"]);
|
|
2975
3043
|
interrupted = true;
|
|
2976
3044
|
}
|
|
2977
|
-
await
|
|
3045
|
+
await pause(
|
|
2978
3046
|
Math.min(pollIntervalMs, Math.max(1, busyTimeoutMs - elapsedMs)),
|
|
2979
3047
|
);
|
|
2980
3048
|
}
|
|
2981
3049
|
}
|
|
2982
3050
|
|
|
2983
|
-
async function doAskViaTmux(adapter, opts) {
|
|
3051
|
+
async function doAskViaTmux(adapter, opts, deps = {}) {
|
|
3052
|
+
const tmux = deps.runTmux ?? runTmux;
|
|
3053
|
+
const capture = deps.captureVisible ?? captureVisible;
|
|
3054
|
+
const captureHistory = deps.capturePane ?? capturePane;
|
|
3055
|
+
const pause = deps.sleep ?? sleep;
|
|
2984
3056
|
const {
|
|
2985
3057
|
session,
|
|
2986
3058
|
prompt,
|
|
@@ -2993,17 +3065,22 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
2993
3065
|
busyTimeoutMs = timeoutMs,
|
|
2994
3066
|
} = opts;
|
|
2995
3067
|
const slashCommand = adapter.cli === "agy" && isSlashCommand(prompt);
|
|
2996
|
-
const busyWaitedMs =
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3068
|
+
const busyWaitedMs = opts.skipBusyCheck
|
|
3069
|
+
? 0
|
|
3070
|
+
: await waitForTmuxIdle(
|
|
3071
|
+
adapter,
|
|
3072
|
+
{
|
|
3073
|
+
session,
|
|
3074
|
+
remote,
|
|
3075
|
+
pollIntervalMs,
|
|
3076
|
+
busyTimeoutMs,
|
|
3077
|
+
ifBusy: slashCommand ? "queue" : ifBusy,
|
|
3078
|
+
},
|
|
3079
|
+
deps,
|
|
3080
|
+
);
|
|
3003
3081
|
const queued =
|
|
3004
|
-
adapter.cli === "agy" &&
|
|
3005
|
-
|
|
3006
|
-
const beforeRaw = await capturePane(remote, session);
|
|
3082
|
+
adapter.cli === "agy" && adapter.isBusy(await capture(remote, session));
|
|
3083
|
+
const beforeRaw = await captureHistory(remote, session);
|
|
3007
3084
|
const contextPctBefore = adapter.contextPct(beforeRaw);
|
|
3008
3085
|
const context =
|
|
3009
3086
|
adapter.cli === "agy"
|
|
@@ -3026,8 +3103,8 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3026
3103
|
};
|
|
3027
3104
|
|
|
3028
3105
|
const bufferName = tmuxBufferName(session);
|
|
3029
|
-
await
|
|
3030
|
-
await
|
|
3106
|
+
await tmux(remote, ["set-buffer", "-b", bufferName, "--", prompt]);
|
|
3107
|
+
await tmux(remote, [
|
|
3031
3108
|
"paste-buffer",
|
|
3032
3109
|
"-b",
|
|
3033
3110
|
bufferName,
|
|
@@ -3036,31 +3113,42 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3036
3113
|
"-t",
|
|
3037
3114
|
session,
|
|
3038
3115
|
]);
|
|
3039
|
-
await
|
|
3116
|
+
await pause(settleMs);
|
|
3040
3117
|
|
|
3041
|
-
let promptSubmitted =
|
|
3118
|
+
let promptSubmitted =
|
|
3119
|
+
adapter.cli === "agy" || opts.confirmSubmission ? false : !doneMarker;
|
|
3042
3120
|
if (doneMarker || opts.noWait || adapter.cli === "agy") {
|
|
3043
|
-
for (
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3121
|
+
for (
|
|
3122
|
+
let attempt = 1;
|
|
3123
|
+
attempt <= (opts.submitAttempts ?? PROMPT_SUBMIT_MAX_ATTEMPTS);
|
|
3124
|
+
attempt += 1
|
|
3125
|
+
) {
|
|
3126
|
+
await tmux(remote, ["send-keys", "-t", session, "Enter"]);
|
|
3127
|
+
await pause(PROMPT_SUBMIT_RETRY_DELAY_MS);
|
|
3128
|
+
const submissionVisible = await capture(remote, session);
|
|
3129
|
+
if (
|
|
3130
|
+
opts.confirmSubmission
|
|
3131
|
+
? await opts.confirmSubmission(submissionVisible)
|
|
3132
|
+
: !composerShowsPrompt(submissionVisible, prompt, adapter.cli)
|
|
3133
|
+
) {
|
|
3048
3134
|
promptSubmitted = true;
|
|
3049
3135
|
break;
|
|
3050
3136
|
}
|
|
3051
3137
|
}
|
|
3052
3138
|
if (!promptSubmitted) {
|
|
3053
3139
|
throw new Error(
|
|
3054
|
-
|
|
3140
|
+
opts.confirmSubmission
|
|
3141
|
+
? "compact submission could not be confirmed (it may still have started, or /compact may remain in the composer); --then was not sent"
|
|
3142
|
+
: `Prompt did not submit after ${PROMPT_SUBMIT_MAX_ATTEMPTS} attempts`,
|
|
3055
3143
|
);
|
|
3056
3144
|
}
|
|
3057
3145
|
} else {
|
|
3058
|
-
await
|
|
3146
|
+
await tmux(remote, ["send-keys", "-t", session, "Enter"]);
|
|
3059
3147
|
}
|
|
3060
3148
|
|
|
3061
3149
|
let nameApplied;
|
|
3062
3150
|
if (adapter.cli === "agy" && !slashCommand) {
|
|
3063
|
-
const { stdout } = await
|
|
3151
|
+
const { stdout } = await tmux(null, [
|
|
3064
3152
|
"show-options",
|
|
3065
3153
|
"-qv",
|
|
3066
3154
|
"-t",
|
|
@@ -3093,7 +3181,7 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3093
3181
|
pollIntervalMs,
|
|
3094
3182
|
);
|
|
3095
3183
|
if (nameApplied)
|
|
3096
|
-
await
|
|
3184
|
+
await tmux(null, [
|
|
3097
3185
|
"set-option",
|
|
3098
3186
|
"-u",
|
|
3099
3187
|
"-t",
|
|
@@ -3102,7 +3190,7 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3102
3190
|
]);
|
|
3103
3191
|
break;
|
|
3104
3192
|
}
|
|
3105
|
-
await
|
|
3193
|
+
await pause(Math.min(pollIntervalMs, 200));
|
|
3106
3194
|
}
|
|
3107
3195
|
} catch {
|
|
3108
3196
|
nameApplied = false;
|
|
@@ -3160,8 +3248,8 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3160
3248
|
|
|
3161
3249
|
while (Date.now() - startedAt < timeoutMs) {
|
|
3162
3250
|
visible = doneMarker
|
|
3163
|
-
? await
|
|
3164
|
-
: await
|
|
3251
|
+
? await captureHistory(remote, session)
|
|
3252
|
+
: await capture(remote, session);
|
|
3165
3253
|
contextPctAfter = adapter.contextPct(visible);
|
|
3166
3254
|
|
|
3167
3255
|
quietPolls = visible === previousVisible ? quietPolls + 1 : 0;
|
|
@@ -3184,7 +3272,7 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3184
3272
|
quietPolls >= FALLBACK_QUIET_POLLS &&
|
|
3185
3273
|
hasTmuxAssistantResponseAfterCurrentPrompt(
|
|
3186
3274
|
adapter,
|
|
3187
|
-
await
|
|
3275
|
+
await captureHistory(remote, session),
|
|
3188
3276
|
prompt,
|
|
3189
3277
|
beforeRaw,
|
|
3190
3278
|
);
|
|
@@ -3193,10 +3281,10 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3193
3281
|
break;
|
|
3194
3282
|
}
|
|
3195
3283
|
|
|
3196
|
-
await
|
|
3284
|
+
await pause(pollIntervalMs);
|
|
3197
3285
|
}
|
|
3198
3286
|
|
|
3199
|
-
raw = await
|
|
3287
|
+
raw = await captureHistory(remote, session);
|
|
3200
3288
|
const markerResponse = promptSubmitted
|
|
3201
3289
|
? extractResponseSinceMarker({
|
|
3202
3290
|
beforeRaw,
|
|
@@ -3412,11 +3500,15 @@ async function resolveCodexTmuxThread(session, deps = {}) {
|
|
|
3412
3500
|
|
|
3413
3501
|
async function doAskViaQueue(adapter, opts, deps = {}) {
|
|
3414
3502
|
// queue 를 못 쓰면 tmux 붙여넣기로 보내고 그 이유를 결과에 남긴다.
|
|
3415
|
-
const fallback = async (reason) =>
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3503
|
+
const fallback = async (reason) => {
|
|
3504
|
+
if (opts.queueOnly)
|
|
3505
|
+
throw new Error(`compact --then requires Codex queue: ${reason}`);
|
|
3506
|
+
return {
|
|
3507
|
+
...(await doAskViaTmux(adapter, { ...opts, transport: "tmux" })),
|
|
3508
|
+
transportRequested: "queue",
|
|
3509
|
+
fallbackReason: reason,
|
|
3510
|
+
};
|
|
3511
|
+
};
|
|
3420
3512
|
if (opts.remote) return fallback("remote-host");
|
|
3421
3513
|
const thread = opts.threadId
|
|
3422
3514
|
? { threadId: opts.threadId, threadSource: "flag" }
|
|
@@ -4374,70 +4466,208 @@ async function tmuxContext(adapter, opts) {
|
|
|
4374
4466
|
}
|
|
4375
4467
|
}
|
|
4376
4468
|
|
|
4377
|
-
async function
|
|
4378
|
-
|
|
4469
|
+
async function doCompact(flags, deps = {}) {
|
|
4470
|
+
const cli = flags.cli ?? "claude";
|
|
4471
|
+
if (cli === "agy")
|
|
4379
4472
|
throw new Error("agy has no compact; use /clear or a handoff");
|
|
4380
4473
|
if (
|
|
4381
|
-
|
|
4474
|
+
!["claude", "codex"].includes(cli) ||
|
|
4382
4475
|
flags.remote ||
|
|
4383
4476
|
flags.short ||
|
|
4384
4477
|
flags["session-id"] ||
|
|
4385
4478
|
(flags.transport && flags.transport !== "tmux")
|
|
4386
4479
|
)
|
|
4387
4480
|
throw new Error(
|
|
4388
|
-
"compact supports local --cli claude --session NAME only
|
|
4481
|
+
"compact supports local --cli claude|codex --session NAME only",
|
|
4389
4482
|
);
|
|
4483
|
+
if (cli === "codex" && flags.instructions)
|
|
4484
|
+
throw new Error(
|
|
4485
|
+
"Codex /compact does not accept --instructions; use --then for follow-up instructions",
|
|
4486
|
+
);
|
|
4487
|
+
const thenPrompt = flags.then?.trim();
|
|
4488
|
+
if (flags.then !== undefined && !thenPrompt)
|
|
4489
|
+
throw new Error("--then must not be blank");
|
|
4490
|
+
if (isSlashCommand(thenPrompt))
|
|
4491
|
+
throw new Error("--then accepts text instructions, not slash commands");
|
|
4390
4492
|
const ifBusy = flags["if-busy"] ?? "fail";
|
|
4391
4493
|
if (!["fail", "wait"].includes(ifBusy))
|
|
4392
4494
|
throw new Error("compact --if-busy must be fail or wait");
|
|
4495
|
+
const adapter = ADAPTERS[cli];
|
|
4393
4496
|
const opts = askOpts(
|
|
4394
4497
|
{
|
|
4395
4498
|
...flags,
|
|
4396
|
-
cli
|
|
4499
|
+
cli,
|
|
4397
4500
|
transport: "tmux",
|
|
4398
4501
|
"if-busy": ifBusy,
|
|
4399
4502
|
"no-wait": true,
|
|
4400
4503
|
prompt: `/compact${flags.instructions ? ` ${flags.instructions}` : ""}`,
|
|
4401
4504
|
},
|
|
4402
|
-
|
|
4403
|
-
);
|
|
4404
|
-
if (ifBusy === "wait") await waitForTmuxIdle(ADAPTERS.claude, opts);
|
|
4405
|
-
const transcriptPath = await claudeTmuxTranscript(
|
|
4406
|
-
opts.session,
|
|
4407
|
-
opts.configDir,
|
|
4505
|
+
adapter,
|
|
4408
4506
|
);
|
|
4409
|
-
const
|
|
4410
|
-
|
|
4507
|
+
const pause = deps.sleep ?? sleep;
|
|
4508
|
+
const capture = deps.captureVisible ?? captureVisible;
|
|
4509
|
+
await waitForTmuxIdle(adapter, opts, deps);
|
|
4510
|
+
let transcriptPath;
|
|
4511
|
+
let threadId;
|
|
4512
|
+
if (cli === "claude") {
|
|
4513
|
+
transcriptPath = await (deps.claudeTmuxTranscript ?? claudeTmuxTranscript)(
|
|
4514
|
+
opts.session,
|
|
4515
|
+
opts.configDir,
|
|
4516
|
+
);
|
|
4517
|
+
} else {
|
|
4518
|
+
const thread = await (
|
|
4519
|
+
deps.resolveCodexTmuxThread ?? resolveCodexTmuxThread
|
|
4520
|
+
)(opts.session, deps);
|
|
4521
|
+
threadId = thread.threadId;
|
|
4522
|
+
transcriptPath = await (deps.findCodexRollout ?? findCodexRollout)(
|
|
4523
|
+
threadId,
|
|
4524
|
+
);
|
|
4525
|
+
}
|
|
4526
|
+
const readSnapshot = () =>
|
|
4527
|
+
cli === "claude"
|
|
4528
|
+
? readClaudeTranscript(transcriptPath)
|
|
4529
|
+
: readCodexContext(transcriptPath, threadId);
|
|
4530
|
+
const before = transcriptPath && (await readSnapshot());
|
|
4531
|
+
if (!before || !Number.isInteger(before.compactCount))
|
|
4411
4532
|
throw new Error(
|
|
4412
|
-
|
|
4533
|
+
`compact requires a discoverable ${cli === "claude" ? "Claude transcript" : "Codex rollout"} for this tmux pane`,
|
|
4413
4534
|
);
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
ifBusy: "fail",
|
|
4417
|
-
skipContextGuard: true,
|
|
4418
|
-
});
|
|
4535
|
+
let snapshot = before;
|
|
4536
|
+
const done = () => snapshot?.compactCount > before.compactCount;
|
|
4419
4537
|
const deadline = Date.now() + opts.timeoutMs;
|
|
4420
|
-
|
|
4421
|
-
const
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
cli: "claude",
|
|
4425
|
-
session: opts.session,
|
|
4426
|
-
compacted: true,
|
|
4427
|
-
...snapshot.compact,
|
|
4428
|
-
});
|
|
4429
|
-
return;
|
|
4430
|
-
}
|
|
4431
|
-
await sleep(
|
|
4432
|
-
Math.min(opts.pollIntervalMs, Math.max(0, deadline - Date.now())),
|
|
4538
|
+
const confirmSubmission = async (visible) => {
|
|
4539
|
+
const submissionDeadline = Math.min(
|
|
4540
|
+
deadline,
|
|
4541
|
+
Date.now() + (cli === "codex" ? 1000 : 5000),
|
|
4433
4542
|
);
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4543
|
+
do {
|
|
4544
|
+
snapshot = await readSnapshot();
|
|
4545
|
+
if (
|
|
4546
|
+
done() ||
|
|
4547
|
+
(cli === "claude"
|
|
4548
|
+
? /Compacting conversation/i.test(stripAnsi(visible))
|
|
4549
|
+
: /Compacting context/i.test(stripAnsi(visible)))
|
|
4550
|
+
)
|
|
4551
|
+
return true;
|
|
4552
|
+
await pause(
|
|
4553
|
+
Math.min(
|
|
4554
|
+
opts.pollIntervalMs,
|
|
4555
|
+
Math.max(0, submissionDeadline - Date.now()),
|
|
4556
|
+
),
|
|
4557
|
+
);
|
|
4558
|
+
visible = await capture(null, opts.session);
|
|
4559
|
+
} while (Date.now() < submissionDeadline);
|
|
4560
|
+
return false;
|
|
4561
|
+
};
|
|
4562
|
+
await doAskViaTmux(
|
|
4563
|
+
adapter,
|
|
4564
|
+
{
|
|
4565
|
+
...opts,
|
|
4566
|
+
skipBusyCheck: true,
|
|
4567
|
+
skipContextGuard: true,
|
|
4568
|
+
submitAttempts: cli === "codex" ? 2 : 1,
|
|
4569
|
+
confirmSubmission,
|
|
4570
|
+
},
|
|
4571
|
+
deps,
|
|
4572
|
+
);
|
|
4573
|
+
const result = {
|
|
4574
|
+
cli,
|
|
4437
4575
|
session: opts.session,
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
}
|
|
4576
|
+
compact: { submitted: true, done: done() },
|
|
4577
|
+
compacted: done(),
|
|
4578
|
+
};
|
|
4579
|
+
if (thenPrompt) {
|
|
4580
|
+
const requestId = randomBytes(6).toString("hex");
|
|
4581
|
+
const thenOpts = {
|
|
4582
|
+
...opts,
|
|
4583
|
+
prompt: `[tfx-live req=${requestId}]\n${thenPrompt}`,
|
|
4584
|
+
requestId,
|
|
4585
|
+
threadId,
|
|
4586
|
+
noWait: true,
|
|
4587
|
+
settleMs: 0,
|
|
4588
|
+
transport: cli === "codex" ? "queue" : "tmux",
|
|
4589
|
+
skipBusyCheck: true,
|
|
4590
|
+
skipContextGuard: true,
|
|
4591
|
+
queueOnly: true,
|
|
4592
|
+
};
|
|
4593
|
+
try {
|
|
4594
|
+
if (cli === "codex") {
|
|
4595
|
+
const queued = await doAskViaQueue(adapter, thenOpts, deps);
|
|
4596
|
+
// biome-ignore lint/suspicious/noThenProperty: CLI 계약의 then은 함수가 아닌 큐 결과다.
|
|
4597
|
+
result.then = {
|
|
4598
|
+
...queued,
|
|
4599
|
+
requestId,
|
|
4600
|
+
queued: queued.inputSent === true,
|
|
4601
|
+
};
|
|
4602
|
+
if (!result.then.queued) {
|
|
4603
|
+
result.ok = false;
|
|
4604
|
+
result.error =
|
|
4605
|
+
queued.status === "unknown"
|
|
4606
|
+
? `${queued.error}; --then may already be queued, use wait --request-id and do not resend`
|
|
4607
|
+
: queued.error;
|
|
4608
|
+
}
|
|
4609
|
+
} else {
|
|
4610
|
+
await doAskViaTmux(adapter, thenOpts, deps);
|
|
4611
|
+
const queueDeadline = Date.now() + Math.min(opts.timeoutMs, 5000);
|
|
4612
|
+
let confirmedBy;
|
|
4613
|
+
do {
|
|
4614
|
+
const transcript = await readClaudeTranscript(transcriptPath, {
|
|
4615
|
+
requestId,
|
|
4616
|
+
});
|
|
4617
|
+
if (transcript?.enqueued) confirmedBy = "enqueue";
|
|
4618
|
+
else if (transcript?.userSeen) confirmedBy = "user";
|
|
4619
|
+
else if (
|
|
4620
|
+
/queued messages/i.test(
|
|
4621
|
+
stripAnsi(await capture(null, opts.session)),
|
|
4622
|
+
)
|
|
4623
|
+
)
|
|
4624
|
+
confirmedBy = "tui";
|
|
4625
|
+
if (confirmedBy) break;
|
|
4626
|
+
await pause(
|
|
4627
|
+
Math.min(
|
|
4628
|
+
opts.pollIntervalMs,
|
|
4629
|
+
Math.max(0, queueDeadline - Date.now()),
|
|
4630
|
+
),
|
|
4631
|
+
);
|
|
4632
|
+
} while (Date.now() < queueDeadline);
|
|
4633
|
+
// biome-ignore lint/suspicious/noThenProperty: CLI 계약의 then은 함수가 아닌 큐 결과다.
|
|
4634
|
+
result.then = {
|
|
4635
|
+
requestId,
|
|
4636
|
+
queued: Boolean(confirmedBy) && confirmedBy !== "user",
|
|
4637
|
+
transport: "tmux",
|
|
4638
|
+
...(confirmedBy === "user" ? { delivered: true } : {}),
|
|
4639
|
+
...(confirmedBy ? { confirmedBy } : {}),
|
|
4640
|
+
};
|
|
4641
|
+
if (!confirmedBy) {
|
|
4642
|
+
result.ok = false;
|
|
4643
|
+
result.error =
|
|
4644
|
+
"Claude --then was sent but enqueue could not be confirmed; use wait --request-id, do not resend";
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4647
|
+
} catch (error) {
|
|
4648
|
+
// compact 는 이미 제출됐으므로 그 정보를 지우지 않고 then 실패만 결과에 담는다.
|
|
4649
|
+
// biome-ignore lint/suspicious/noThenProperty: CLI 계약의 then은 함수가 아닌 큐 결과다.
|
|
4650
|
+
result.then = { requestId, queued: false, error: error.message };
|
|
4651
|
+
result.ok = false;
|
|
4652
|
+
result.error = `compact was submitted but --then failed: ${error.message}`;
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
if (!Object.hasOwn(flags, "no-wait")) {
|
|
4656
|
+
do {
|
|
4657
|
+
snapshot = await readSnapshot();
|
|
4658
|
+
if (done()) break;
|
|
4659
|
+
await pause(
|
|
4660
|
+
Math.min(opts.pollIntervalMs, Math.max(0, deadline - Date.now())),
|
|
4661
|
+
);
|
|
4662
|
+
} while (Date.now() < deadline);
|
|
4663
|
+
if (!done()) result.timedOut = true;
|
|
4664
|
+
}
|
|
4665
|
+
result.compacted = result.compact.done = done();
|
|
4666
|
+
if (done()) {
|
|
4667
|
+
Object.assign(result, snapshot.compact);
|
|
4668
|
+
Object.assign(result.compact, snapshot.compact);
|
|
4669
|
+
}
|
|
4670
|
+
return result;
|
|
4441
4671
|
}
|
|
4442
4672
|
|
|
4443
4673
|
function codexGoalStatus(goal) {
|
|
@@ -5695,7 +5925,9 @@ async function main() {
|
|
|
5695
5925
|
} else if (command === "queue") {
|
|
5696
5926
|
await queue(flags);
|
|
5697
5927
|
} else if (command === "compact") {
|
|
5698
|
-
await
|
|
5928
|
+
const result = await doCompact(flags);
|
|
5929
|
+
printJson(result);
|
|
5930
|
+
if (result.ok === false) process.exitCode = 1;
|
|
5699
5931
|
} else if (command === "goal") {
|
|
5700
5932
|
printJson(await goal(action, flags));
|
|
5701
5933
|
} else if (command === "rename") {
|
|
@@ -5737,6 +5969,7 @@ export {
|
|
|
5737
5969
|
discoverClaudeTmuxSessions,
|
|
5738
5970
|
discoverCodexTmuxSessions,
|
|
5739
5971
|
doAskViaTmux,
|
|
5972
|
+
doCompact,
|
|
5740
5973
|
extractAssistantResponse,
|
|
5741
5974
|
extractClaudeCompletedTaskListResponse,
|
|
5742
5975
|
extractResponseSinceMarker,
|
|
@@ -272,6 +272,7 @@ export async function readClaudeTranscript(transcriptPath, { requestId } = {}) {
|
|
|
272
272
|
model: state.model,
|
|
273
273
|
measuredAt: state.measuredAt,
|
|
274
274
|
},
|
|
275
|
+
enqueued: state.queuedContent !== null,
|
|
275
276
|
userSeen: state.userSeen,
|
|
276
277
|
response: state.response,
|
|
277
278
|
error: state.error,
|
|
@@ -23,14 +23,21 @@ const MODEL_CONTEXT = [
|
|
|
23
23
|
},
|
|
24
24
|
];
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
const CONTEXT_THRESHOLDS = {
|
|
27
27
|
claude: { warnContextPct: 60, maxContextPct: 90 },
|
|
28
|
-
codex: { warnContextPct:
|
|
28
|
+
codex: { warnContextPct: 60, maxContextPct: 85 },
|
|
29
29
|
};
|
|
30
|
+
// rollout 실행 창을 못 읽으면 1.05M 표 값이 분모라 실제 창보다 비율이 작게 나온다.
|
|
31
|
+
const CODEX_MODEL_WINDOW_THRESHOLDS = { warnContextPct: 15, maxContextPct: 22 };
|
|
30
32
|
|
|
31
33
|
const rolloutCache = new Map();
|
|
32
34
|
|
|
33
|
-
export function modelContext(
|
|
35
|
+
export function modelContext(
|
|
36
|
+
cli,
|
|
37
|
+
model,
|
|
38
|
+
estimatedContextTokens = null,
|
|
39
|
+
rolloutContextWindow = null,
|
|
40
|
+
) {
|
|
34
41
|
const entry = MODEL_CONTEXT.find(
|
|
35
42
|
(item) => item.cli === cli && item.model.test(model || ""),
|
|
36
43
|
);
|
|
@@ -38,18 +45,20 @@ export function modelContext(cli, model, estimatedContextTokens = null) {
|
|
|
38
45
|
const executionContextLimitTokens =
|
|
39
46
|
cli === "claude" && process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT === "1"
|
|
40
47
|
? 200_000
|
|
41
|
-
:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
: rolloutContextWindow;
|
|
49
|
+
// Codex 는 rollout 이 보고한 창이 실제 실행 한도라 표 값과 무관하게 그 창을 쓴다.
|
|
50
|
+
const effectiveLimit =
|
|
51
|
+
cli === "codex"
|
|
52
|
+
? (executionContextLimitTokens ?? contextLimitTokens)
|
|
53
|
+
: contextLimitTokens &&
|
|
54
|
+
Math.min(contextLimitTokens, executionContextLimitTokens ?? Infinity);
|
|
46
55
|
return {
|
|
47
56
|
estimatedContextTokens,
|
|
48
57
|
contextLimitTokens,
|
|
49
58
|
contextLimitSource: entry?.source ?? null,
|
|
50
59
|
...(executionContextLimitTokens ? { executionContextLimitTokens } : {}),
|
|
51
60
|
contextPct:
|
|
52
|
-
|
|
61
|
+
effectiveLimit && Number.isFinite(estimatedContextTokens)
|
|
53
62
|
? (estimatedContextTokens / effectiveLimit) * 100
|
|
54
63
|
: null,
|
|
55
64
|
};
|
|
@@ -60,7 +69,10 @@ export function contextGuard(
|
|
|
60
69
|
context,
|
|
61
70
|
{ warnContextPct, maxContextPct } = {},
|
|
62
71
|
) {
|
|
63
|
-
const defaults =
|
|
72
|
+
const defaults =
|
|
73
|
+
cli === "codex" && !context?.executionContextLimitTokens
|
|
74
|
+
? CODEX_MODEL_WINDOW_THRESHOLDS
|
|
75
|
+
: CONTEXT_THRESHOLDS[cli];
|
|
64
76
|
const warn = warnContextPct ?? defaults?.warnContextPct ?? 0;
|
|
65
77
|
const max = maxContextPct ?? defaults?.maxContextPct ?? 0;
|
|
66
78
|
const fields = {
|
|
@@ -90,6 +102,7 @@ export function contextGuard(
|
|
|
90
102
|
inputSent: false,
|
|
91
103
|
hint: "세션을 승계한 뒤 새 세션에 요청하라.",
|
|
92
104
|
...fields,
|
|
105
|
+
thresholdPct: max,
|
|
93
106
|
};
|
|
94
107
|
}
|
|
95
108
|
return warn > 0 && fields.contextPct >= warn
|
|
@@ -137,12 +150,19 @@ function acceptCodexLine(state, line) {
|
|
|
137
150
|
} catch {
|
|
138
151
|
return false;
|
|
139
152
|
}
|
|
153
|
+
if (entry?.type === "compacted") {
|
|
154
|
+
state.compactCount += 1;
|
|
155
|
+
state.compact = { preTokens: state.tokens, postTokens: null };
|
|
156
|
+
}
|
|
140
157
|
if (entry?.type === "turn_context")
|
|
141
158
|
state.model = entry.payload?.model || state.model;
|
|
142
159
|
if (entry?.type === "event_msg" && entry.payload?.type === "token_count") {
|
|
143
160
|
const info = entry.payload.info;
|
|
144
|
-
if (Number.isFinite(info?.last_token_usage?.total_tokens))
|
|
161
|
+
if (Number.isFinite(info?.last_token_usage?.total_tokens)) {
|
|
145
162
|
state.tokens = info.last_token_usage.total_tokens;
|
|
163
|
+
if (state.compact && state.compact.postTokens === null)
|
|
164
|
+
state.compact.postTokens = state.tokens;
|
|
165
|
+
}
|
|
146
166
|
if (Number.isFinite(info?.model_context_window))
|
|
147
167
|
state.executionContextLimitTokens = info.model_context_window;
|
|
148
168
|
}
|
|
@@ -172,6 +192,8 @@ export async function readCodexContext(rolloutPath, threadId) {
|
|
|
172
192
|
decoder: new StringDecoder("utf8"),
|
|
173
193
|
model: null,
|
|
174
194
|
tokens: null,
|
|
195
|
+
compactCount: 0,
|
|
196
|
+
compact: null,
|
|
175
197
|
executionContextLimitTokens: null,
|
|
176
198
|
};
|
|
177
199
|
}
|
|
@@ -197,8 +219,15 @@ export async function readCodexContext(rolloutPath, threadId) {
|
|
|
197
219
|
state.mtimeMs = stat.mtimeMs;
|
|
198
220
|
rolloutCache.set(rolloutPath, state);
|
|
199
221
|
return {
|
|
200
|
-
...modelContext(
|
|
222
|
+
...modelContext(
|
|
223
|
+
"codex",
|
|
224
|
+
state.model,
|
|
225
|
+
state.tokens,
|
|
226
|
+
state.executionContextLimitTokens,
|
|
227
|
+
),
|
|
201
228
|
model: state.model,
|
|
229
|
+
compactCount: state.compactCount,
|
|
230
|
+
compact: state.compact,
|
|
202
231
|
executionContextLimitTokens: state.executionContextLimitTokens,
|
|
203
232
|
};
|
|
204
233
|
}
|
package/package.json
CHANGED
package/scripts/pack.mjs
CHANGED
|
@@ -120,42 +120,54 @@ function buildFileSet(baseDir) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
|
-
* remote 패키지 내 .mjs
|
|
124
|
-
* @triflux/core 패키지 경로로 리라이팅한다.
|
|
123
|
+
* remote 패키지 내 .mjs 파일 하나에서 core 모듈을 참조하는 상대 import를
|
|
124
|
+
* @triflux/core 패키지 경로로 리라이팅한다. release:check-mirror 도 이 함수로 기대본을 만든다.
|
|
125
125
|
*/
|
|
126
|
+
export function rewriteRemoteSource(
|
|
127
|
+
content,
|
|
128
|
+
filePath,
|
|
129
|
+
remotePkgDir,
|
|
130
|
+
corePkgDir,
|
|
131
|
+
coreFiles = buildFileSet(corePkgDir),
|
|
132
|
+
) {
|
|
133
|
+
let rewrites = 0;
|
|
134
|
+
const rewriteRelativeSpecifier = (match, pre, importPath, post) => {
|
|
135
|
+
const resolved = normalize(join(dirname(filePath), importPath));
|
|
136
|
+
// import 대상이 remote 패키지 내에 있으면 그대로 유지
|
|
137
|
+
if (existsSync(resolved)) return match;
|
|
138
|
+
// core 패키지에 동일 상대경로로 존재하는지 확인
|
|
139
|
+
const relToRemote = relative(remotePkgDir, resolved).replace(/\\/g, "/");
|
|
140
|
+
const coreEquiv = normalize(join(corePkgDir, relToRemote));
|
|
141
|
+
if (coreFiles.has(coreEquiv)) {
|
|
142
|
+
rewrites++;
|
|
143
|
+
return `${pre}@triflux/core/${relToRemote}${post}`;
|
|
144
|
+
}
|
|
145
|
+
return match;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const rewritten = content
|
|
149
|
+
.replace(/(from\s+['"])(\.[^'"]+)(['"])/g, rewriteRelativeSpecifier)
|
|
150
|
+
.replace(
|
|
151
|
+
/(import\(\s*['"])(\.[^'"]+)(['"]\s*\))/g,
|
|
152
|
+
rewriteRelativeSpecifier,
|
|
153
|
+
);
|
|
154
|
+
return { content: rewritten, rewrites };
|
|
155
|
+
}
|
|
156
|
+
|
|
126
157
|
function rewriteRemoteImports(remotePkgDir, corePkgDir) {
|
|
127
158
|
const coreFiles = buildFileSet(corePkgDir);
|
|
128
159
|
let totalRewrites = 0;
|
|
129
160
|
|
|
130
161
|
for (const filePath of walkMjs(remotePkgDir)) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (existsSync(resolved)) return match;
|
|
138
|
-
// core 패키지에 동일 상대경로로 존재하는지 확인
|
|
139
|
-
const relToRemote = relative(remotePkgDir, resolved).replace(/\\/g, "/");
|
|
140
|
-
const coreEquiv = normalize(join(corePkgDir, relToRemote));
|
|
141
|
-
if (coreFiles.has(coreEquiv)) {
|
|
142
|
-
changed = true;
|
|
143
|
-
totalRewrites++;
|
|
144
|
-
return `${pre}@triflux/core/${relToRemote}${post}`;
|
|
145
|
-
}
|
|
146
|
-
return match;
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
content = content.replace(
|
|
150
|
-
/(from\s+['"])(\.[^'"]+)(['"])/g,
|
|
151
|
-
rewriteRelativeSpecifier,
|
|
162
|
+
const { content, rewrites } = rewriteRemoteSource(
|
|
163
|
+
readFileSync(filePath, "utf8"),
|
|
164
|
+
filePath,
|
|
165
|
+
remotePkgDir,
|
|
166
|
+
corePkgDir,
|
|
167
|
+
coreFiles,
|
|
152
168
|
);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
rewriteRelativeSpecifier,
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
if (changed) writeFileSync(filePath, content);
|
|
169
|
+
if (rewrites > 0) writeFileSync(filePath, content);
|
|
170
|
+
totalRewrites += rewrites;
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
return totalRewrites;
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "node:fs";
|
|
20
20
|
import { dirname, join } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
|
-
import { CORE_ENTRIES } from "../pack.mjs";
|
|
22
|
+
import { CORE_ENTRIES, rewriteRemoteSource } from "../pack.mjs";
|
|
23
23
|
|
|
24
24
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
25
25
|
const DEFAULT_REPO_ROOT = join(SCRIPT_DIR, "..", "..");
|
|
@@ -77,6 +77,7 @@ function walkRelFiles(root, skipRels = new Set()) {
|
|
|
77
77
|
// the two drift classes that shipped silently in v10.33.0: a broken remote
|
|
78
78
|
// import (relative specifier to a core-only module) and a stale non-JS asset
|
|
79
79
|
// (the tray UI), neither of which the triflux/core byte-mirror check can see.
|
|
80
|
+
// .mjs 본문도 pack:remote 리라이트를 거친 root 와 비교한다. 해석만 보면 옛 구현이 남아도 OK 였다.
|
|
80
81
|
const REMOTE_NON_MIRROR = new Set([
|
|
81
82
|
"package.json",
|
|
82
83
|
"package-lock.json",
|
|
@@ -99,16 +100,12 @@ function extractImportSpecifiers(content) {
|
|
|
99
100
|
return specs;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.replace(
|
|
109
|
-
/(import\(\s*["'])@triflux\/core\/hub\/([^"']+)(["']\s*\))/g,
|
|
110
|
-
"$1../../hub/$2$3",
|
|
111
|
-
);
|
|
103
|
+
function firstDiffLine(expected, actual) {
|
|
104
|
+
const a = expected.split("\n");
|
|
105
|
+
const b = actual.split("\n");
|
|
106
|
+
let i = 0;
|
|
107
|
+
while (a[i] === b[i]) i++;
|
|
108
|
+
return `line ${i + 1}: root ${JSON.stringify(a[i] ?? "<EOF>")} / remote ${JSON.stringify(b[i] ?? "<EOF>")}`;
|
|
112
109
|
}
|
|
113
110
|
|
|
114
111
|
function checkRemoteMirror(repoRoot) {
|
|
@@ -140,15 +137,21 @@ function checkRemoteMirror(repoRoot) {
|
|
|
140
137
|
});
|
|
141
138
|
}
|
|
142
139
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
140
|
+
const rootPath = join(repoRoot, rel);
|
|
141
|
+
if (existsSync(rootPath)) {
|
|
142
|
+
// pack:remote 와 같은 리라이트를 root 원본에 적용한 기대본과 비교한다.
|
|
143
|
+
const { content: expected } = rewriteRemoteSource(
|
|
144
|
+
readFileSync(rootPath, "utf8"),
|
|
145
|
+
remotePath,
|
|
146
|
+
remoteRoot,
|
|
147
|
+
coreRoot,
|
|
148
|
+
);
|
|
149
|
+
if (content !== expected) {
|
|
150
|
+
issues.push({
|
|
151
|
+
path: displayPath,
|
|
152
|
+
kind: "remote-content-diff",
|
|
153
|
+
detail: firstDiffLine(expected, content),
|
|
154
|
+
});
|
|
152
155
|
}
|
|
153
156
|
}
|
|
154
157
|
} else {
|
|
@@ -329,13 +332,14 @@ function main() {
|
|
|
329
332
|
}
|
|
330
333
|
} else {
|
|
331
334
|
console.log(
|
|
332
|
-
"Mirror OK — packages/triflux + packages/core (byte) and packages/remote (
|
|
335
|
+
"Mirror OK — packages/triflux + packages/core (byte) and packages/remote (import-normalized content) match root",
|
|
333
336
|
);
|
|
334
337
|
}
|
|
335
338
|
} else {
|
|
336
339
|
console.log(`Mirror mismatch (${result.issues.length} issues):`);
|
|
337
340
|
for (const i of result.issues) {
|
|
338
341
|
console.log(` ${i.kind.padEnd(18)} ${i.path}`);
|
|
342
|
+
if (i.detail) console.log(` ${i.detail}`);
|
|
339
343
|
}
|
|
340
344
|
if (result.fixed.length > 0) {
|
|
341
345
|
console.log(`Fixed during run (${result.fixed.length}):`);
|
package/skills/tfx-live/SKILL.md
CHANGED
|
@@ -29,6 +29,8 @@ macOS의 iTerm2에서 로컬 tmux 세션을 `start`하면 리드가 있는 창
|
|
|
29
29
|
|
|
30
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
31
|
|
|
32
|
+
Codex 폴더 신뢰 화면이나 Claude 프로젝트 MCP 승인 화면이 뜨면 `start`는 화면에 답하지 않고 `ready: false`, `blockedBy`(`codex-trust-folder` 또는 `claude-mcp-approval`), 할 일을 적은 `warning`을 반환한다. 세션은 남아 있으므로 tmux 세션에 붙어 직접 고른 뒤 지시를 보낸다.
|
|
33
|
+
|
|
32
34
|
## agy 세션
|
|
33
35
|
|
|
34
36
|
```bash
|
|
@@ -53,11 +55,13 @@ agy `ask`의 기본 `--if-busy queue`는 바쁜 턴에도 바로 전송한다. a
|
|
|
53
55
|
|
|
54
56
|
| 동작 | 적용 조건과 실행 |
|
|
55
57
|
| --- | --- |
|
|
56
|
-
| compact | 같은 목표와 담당이 이어지고 파일과 git에서 상태를 복구할 수 있을 때. Claude는 `tfx-live compact
|
|
58
|
+
| compact | 같은 목표와 담당이 이어지고 파일과 git에서 상태를 복구할 수 있을 때. Claude와 Codex는 `tfx-live compact --then "다음 지시" --no-wait`로 압축 제출을 확인하고 다음 지시를 미리 넣는다. 압축 뒤에 필요한 파일 경로와 검증 명령도 `--then`에 싣는다. agy는 수동 compact가 없어 handoff한다. |
|
|
57
59
|
| clear | 무관한 작업으로 전환하거나 같은 문제를 두 번 넘게 고쳐도 실패할 때, 스펙에서 구현으로 또는 작성자에서 리뷰어로 바뀔 때. 미완료 상태를 정리한 뒤 Claude는 `/rename`과 `/clear`, Codex는 작업 종료 뒤 `/new <이름>`, agy는 `/clear`를 쓴다. |
|
|
58
60
|
| handoff | 담당·CLI·모델·소유자가 바뀌거나 긴 작업이 창을 넘길 때. 인계 파일에 목표, 완료 조건, 체크아웃과 변경 상태, 확정 결정, 증거 경로, PASS/FAIL/SKIP, 미완료 작업과 프로세스, 다음 행동과 소유자, `predecessorSessionId`를 적는다. 승계 세션이 경로를 확인하면 이전 세션을 닫는다. |
|
|
59
61
|
| close | 결과와 인계를 확인한 즉시 닫는다. Claude 백그라운드는 `claude stop <id>`, tmux는 `tfx-live stop --session`, Claude 팀원은 shutdown, Codex 서브에이전트는 `close_agent`, agy 서브에이전트는 `/agents` 패널의 K를 쓴다. |
|
|
60
62
|
|
|
63
|
+
Claude TUI는 바쁠 때 들어온 입력을 대기열에 넣고 현재 턴(압축 포함)이 끝나면 순서대로 처리한다. transcript에는 `queue-operation`의 `enqueue`와 `dequeue`로 남는다. Codex app-server 큐도 바쁜 턴 뒤에 순서대로 처리한다. 리드는 compact를 보내고 `--then`으로 다음 지시를 넣는 것을 기본 절차로 쓴다.
|
|
64
|
+
|
|
61
65
|
두 번째 compact 전에는 handoff가 맞는지 점검한다. compact 횟수는 고정하지 않는다.
|
|
62
66
|
|
|
63
67
|
리드 역할 절차(역할별 모델, 지시서, 교차 리뷰, 릴리스 조율)는 `tfx-lead` 스킬을 따른다.
|
|
@@ -99,30 +103,37 @@ Claude와 Codex는 네이티브 goal을 우선 쓰고, goal-driven은 agy처럼
|
|
|
99
103
|
|
|
100
104
|
### 컨텍스트 판단
|
|
101
105
|
|
|
102
|
-
분모는 공식 모델
|
|
106
|
+
Claude의 분모는 공식 모델 창과 실행 한도 중 작은 값이다. Codex의 분모는 rollout이 보고한 실행 창이고, 없을 때만 공식 창이다. 아래 비율은 공식 권고가 아닌 **[추론] 운영값**이다. 모델 문서가 바뀌면 한도도 갱신한다.
|
|
103
107
|
|
|
104
108
|
| CLI | 준비와 경고 | compact | 신규 배정 중지·handoff | 전송 거부 |
|
|
105
109
|
| --- | ---: | ---: | ---: | ---: |
|
|
106
110
|
| Claude Code 1M 모델 | 60% | 75% | 85% | 90% |
|
|
107
|
-
| Codex |
|
|
111
|
+
| Codex rollout 실행 창 | 60% | - | - | 85% |
|
|
112
|
+
| Codex 실행 창 없음(1.05M 분모) | 15% | 18% | 20% | 22% |
|
|
108
113
|
| agy | 12% | 없음 | 15% | 18% |
|
|
109
114
|
|
|
110
|
-
Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
|
|
115
|
+
Claude는 85% 도달 시 바로 handoff를 지시한다. Codex 실행 창 행의 compact와 handoff 비율은 아직 정하지 않았다. agy 행은 운영 지침이며 agy도 `tfx-live` 전송 대상이지만 컨텍스트 비율은 자동으로 읽지 못해 `null`로 보고하고 가드를 건너뛴다. `model_context_window`와 `model_auto_compact_token_limit`은 올리지 않는다.
|
|
111
116
|
|
|
112
|
-
결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다.
|
|
117
|
+
결과에는 `estimatedContextTokens`, `contextLimitTokens`, `contextLimitSource`, `contextPct`가 포함된다. Claude 실행 한도는 `executionContextLimitTokens`로 별도 보고하며 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`이면 200,000이다. Codex는 rollout `token_count`의 `model_context_window`를 `executionContextLimitTokens`로 보고한다. 이 값이 있으면 모델 표에 없는 모델이라도 그 창으로 판정하고, 없으면 1,050,000 표 값을 분모로 쓴다. 한도를 모르면 비율은 `null`로 두고 경고만 한다. 사용량이 없는 `probe`는 `context: null`이다. 컨텍스트를 확인하려면 직접 `tfx-live probe`를 실행한다. `interrupt`, 자동 `ask`의 daemon probe, `stop --short`는 transcript를 읽지 않는다.
|
|
113
118
|
|
|
114
|
-
`ask`의 `--warn-context-pct`와 `--max-context-pct`는 경고·거부 비율을 덮어쓰며 0이면 해당 가드를 끈다. 기본값은 표의 경고·전송 거부 열이다. 거부 결과는 `status: "failed"`, `done: false`, `context-limit
|
|
119
|
+
`ask`의 `--warn-context-pct`와 `--max-context-pct`는 경고·거부 비율을 덮어쓰며 0이면 해당 가드를 끈다. 기본값은 표의 경고·전송 거부 열이다. Codex는 `executionContextLimitTokens`가 있으면 실행 창 행을, 없으면 1.05M 분모 행을 쓴다. 거부 결과는 `status: "failed"`, `done: false`, `context-limit`, 컨텍스트 필드와 적용한 거부 비율 `thresholdPct`를 돌려준다. 경고의 `thresholdPct`는 적용한 경고 비율이다. 자세한 근거와 실측은 [컨텍스트 정책 연구](../../docs/research/tfx-live-context-policy-2026-10.md)에 둔다.
|
|
115
120
|
|
|
116
121
|
### 수동 compact와 종료
|
|
117
122
|
|
|
118
123
|
```bash
|
|
119
|
-
tfx-live compact --cli claude --session cl1 --instructions "변경 파일과 검증 명령을 보존하라" --
|
|
124
|
+
tfx-live compact --cli claude --session cl1 --instructions "변경 파일과 검증 명령을 보존하라" --then "인계 파일을 읽고 검증을 이어가라" --no-wait
|
|
125
|
+
tfx-live compact --cli codex --session cx1 --then "src/main.mjs를 확인하고 지정된 단위 테스트를 실행하라" --no-wait
|
|
126
|
+
tfx-live wait --cli codex --session cx1 --request-id <then.requestId> --timeout 120
|
|
120
127
|
tfx-live stop --cli claude --short <8hex>
|
|
121
128
|
tfx-live stop --cli claude --session-id <id>
|
|
122
129
|
tfx-live stop --session cl1
|
|
123
130
|
```
|
|
124
131
|
|
|
125
|
-
`compact`는 로컬
|
|
132
|
+
`compact --session S [--cli claude|codex] [--instructions TEXT] [--then PROMPT] [--no-wait]`는 로컬 tmux에서 `/compact`를 중계 표식 없이 보낸다. CLI 기본값은 Claude다. `--instructions`는 Claude에서만 받으며 Codex는 인자를 받지 않아 거부한다. 기본 `--if-busy fail`이며 바쁜 세션에는 compact를 전송하지 않는다. UDS compact는 지원하지 않는다.
|
|
133
|
+
|
|
134
|
+
`--no-wait`는 압축 진행 표시 또는 새 완료 기록으로 제출을 확인한 뒤 반환한다. Codex에서 확인되지 않으면 Enter를 한 번 더 보내고, 그래도 확인되지 않으면 `--then`을 보내지 않고 오류로 끝낸다. `--then`은 일반 텍스트 지시만 받으며 슬래시 명령은 거부한다. 제출 확인 직후 Claude에는 표식을 붙여 tmux로 보내고 화면의 `queued messages` 또는 transcript의 `enqueue`로 확인한다. Claude 압축이 먼저 끝나 지시가 이미 처리에 들어갔으면 `then.queued: false, delivered: true`로 보고한다. Codex에는 기존 queue 경로로만 보내며 tmux로 전환하지 않는다. 결과의 `then.requestId`로 `wait --request-id`에서 압축 뒤 응답을 받는다.
|
|
135
|
+
|
|
136
|
+
`--no-wait`가 없으면 Claude의 새 `compact_boundary`, Codex rollout의 새 `compacted`까지 기다린다. 결과에 `compact: {submitted: true, done}`과 `then: {requestId, queued, transport}`를 추가하고 기존 `compacted`, `preTokens`, `postTokens`를 유지한다. 토큰 수는 기록에 있는 값만 보고하며 Codex의 압축 후 token_count가 아직 없으면 `postTokens`는 `null`이다. 시간 안에 완료 기록이 없으면 `compacted: false, timedOut: true`다.
|
|
126
137
|
|
|
127
138
|
`stop --short`와 `stop --session-id`는 `claude stop <id>`를 호출하고 `stopped: true, conversationKept: true`를 반환한다. `claude attach`로 대화를 재개할 수 있다. `/exit`, `←`, Ctrl+Z는 백그라운드 세션의 연결만 끊는다. macOS의 `sysctl -n kern.memorystatus_vm_pressure_level`이 2 이상이면 새 세션을 띄우기 전 정리한다. 원격 배정 전에는 `tfx-remote probe <host>`의 `ready`와 `warnings`를 확인한다.
|
|
128
139
|
|