triflux 10.62.0 → 10.63.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.
|
@@ -57,11 +57,13 @@ agy `ask`의 기본 `--if-busy queue`는 바쁜 턴에도 바로 전송한다. a
|
|
|
57
57
|
|
|
58
58
|
| 동작 | 적용 조건과 실행 |
|
|
59
59
|
| --- | --- |
|
|
60
|
-
| compact | 같은 목표와 담당이 이어지고 파일과 git에서 상태를 복구할 수 있을 때. Claude는 `tfx-live compact
|
|
60
|
+
| compact | 같은 목표와 담당이 이어지고 파일과 git에서 상태를 복구할 수 있을 때. Claude와 Codex는 `tfx-live compact --then "다음 지시" --no-wait`로 압축 제출을 확인하고 다음 지시를 미리 넣는다. 압축 뒤에 필요한 파일 경로와 검증 명령도 `--then`에 싣는다. agy는 수동 compact가 없어 handoff한다. |
|
|
61
61
|
| clear | 무관한 작업으로 전환하거나 같은 문제를 두 번 넘게 고쳐도 실패할 때, 스펙에서 구현으로 또는 작성자에서 리뷰어로 바뀔 때. 미완료 상태를 정리한 뒤 Claude는 `/rename`과 `/clear`, Codex는 작업 종료 뒤 `/new <이름>`, agy는 `/clear`를 쓴다. |
|
|
62
62
|
| handoff | 담당·CLI·모델·소유자가 바뀌거나 긴 작업이 창을 넘길 때. 인계 파일에 목표, 완료 조건, 체크아웃과 변경 상태, 확정 결정, 증거 경로, PASS/FAIL/SKIP, 미완료 작업과 프로세스, 다음 행동과 소유자, `predecessorSessionId`를 적는다. 승계 세션이 경로를 확인하면 이전 세션을 닫는다. |
|
|
63
63
|
| close | 결과와 인계를 확인한 즉시 닫는다. Claude 백그라운드는 `claude stop <id>`, tmux는 `tfx-live stop --session`, Claude 팀원은 shutdown, Codex 서브에이전트는 `close_agent`, agy 서브에이전트는 `/agents` 패널의 K를 쓴다. |
|
|
64
64
|
|
|
65
|
+
Claude TUI는 바쁠 때 들어온 입력을 대기열에 넣고 현재 턴(압축 포함)이 끝나면 순서대로 처리한다. transcript에는 `queue-operation`의 `enqueue`와 `dequeue`로 남는다. Codex app-server 큐도 바쁜 턴 뒤에 순서대로 처리한다. 리드는 compact를 보내고 `--then`으로 다음 지시를 넣는 것을 기본 절차로 쓴다.
|
|
66
|
+
|
|
65
67
|
두 번째 compact 전에는 handoff가 맞는지 점검한다. compact 횟수는 고정하지 않는다.
|
|
66
68
|
|
|
67
69
|
리드 역할 절차(역할별 모델, 지시서, 교차 리뷰, 릴리스 조율)는 `tfx-lead` 스킬을 따른다.
|
|
@@ -120,13 +122,19 @@ Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지
|
|
|
120
122
|
### 수동 compact와 종료
|
|
121
123
|
|
|
122
124
|
```bash
|
|
123
|
-
tfx-live compact --cli claude --session cl1 --instructions "변경 파일과 검증 명령을 보존하라" --
|
|
125
|
+
tfx-live compact --cli claude --session cl1 --instructions "변경 파일과 검증 명령을 보존하라" --then "인계 파일을 읽고 검증을 이어가라" --no-wait
|
|
126
|
+
tfx-live compact --cli codex --session cx1 --then "src/main.mjs를 확인하고 지정된 단위 테스트를 실행하라" --no-wait
|
|
127
|
+
tfx-live wait --cli codex --session cx1 --request-id <then.requestId> --timeout 120
|
|
124
128
|
tfx-live stop --cli claude --short <8hex>
|
|
125
129
|
tfx-live stop --cli claude --session-id <id>
|
|
126
130
|
tfx-live stop --session cl1
|
|
127
131
|
```
|
|
128
132
|
|
|
129
|
-
`compact`는 로컬
|
|
133
|
+
`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는 지원하지 않는다.
|
|
134
|
+
|
|
135
|
+
`--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`에서 압축 뒤 응답을 받는다.
|
|
136
|
+
|
|
137
|
+
`--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
138
|
|
|
131
139
|
`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
140
|
|
package/bin/tfx-live.mjs
CHANGED
|
@@ -108,7 +108,7 @@ function usage(command) {
|
|
|
108
108
|
" 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
109
|
" 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
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 --
|
|
111
|
+
" tfx-live compact --session NAME [--cli claude|codex] [--instructions TEXT] [--then PROMPT] [--no-wait] [--if-busy fail|wait] [--timeout 60]",
|
|
112
112
|
" tfx-live goal set --session NAME [--cli claude|codex] --condition TEXT [--budget N]",
|
|
113
113
|
" tfx-live goal status --session NAME [--cli claude|codex]",
|
|
114
114
|
" tfx-live goal clear --session NAME [--cli claude|codex]",
|
|
@@ -2941,7 +2941,10 @@ async function renameAgyConversation(session, name, timeoutMs, pollIntervalMs) {
|
|
|
2941
2941
|
return false;
|
|
2942
2942
|
}
|
|
2943
2943
|
|
|
2944
|
-
async function waitForTmuxIdle(adapter, opts) {
|
|
2944
|
+
async function waitForTmuxIdle(adapter, opts, deps = {}) {
|
|
2945
|
+
const capture = deps.captureVisible ?? captureVisible;
|
|
2946
|
+
const tmux = deps.runTmux ?? runTmux;
|
|
2947
|
+
const pause = deps.sleep ?? sleep;
|
|
2945
2948
|
const { session, remote, pollIntervalMs, busyTimeoutMs, ifBusy } = opts;
|
|
2946
2949
|
if (
|
|
2947
2950
|
![
|
|
@@ -2958,7 +2961,7 @@ async function waitForTmuxIdle(adapter, opts) {
|
|
|
2958
2961
|
const startedAt = Date.now();
|
|
2959
2962
|
let interrupted = false;
|
|
2960
2963
|
while (true) {
|
|
2961
|
-
const visible = await
|
|
2964
|
+
const visible = await capture(remote, session);
|
|
2962
2965
|
const elapsedMs = Date.now() - startedAt;
|
|
2963
2966
|
const busy =
|
|
2964
2967
|
adapter.isBusy(visible) ||
|
|
@@ -2971,16 +2974,20 @@ async function waitForTmuxIdle(adapter, opts) {
|
|
|
2971
2974
|
);
|
|
2972
2975
|
}
|
|
2973
2976
|
if (ifBusy === "interrupt" && !interrupted) {
|
|
2974
|
-
await
|
|
2977
|
+
await tmux(remote, ["send-keys", "-t", session, "Escape"]);
|
|
2975
2978
|
interrupted = true;
|
|
2976
2979
|
}
|
|
2977
|
-
await
|
|
2980
|
+
await pause(
|
|
2978
2981
|
Math.min(pollIntervalMs, Math.max(1, busyTimeoutMs - elapsedMs)),
|
|
2979
2982
|
);
|
|
2980
2983
|
}
|
|
2981
2984
|
}
|
|
2982
2985
|
|
|
2983
|
-
async function doAskViaTmux(adapter, opts) {
|
|
2986
|
+
async function doAskViaTmux(adapter, opts, deps = {}) {
|
|
2987
|
+
const tmux = deps.runTmux ?? runTmux;
|
|
2988
|
+
const capture = deps.captureVisible ?? captureVisible;
|
|
2989
|
+
const captureHistory = deps.capturePane ?? capturePane;
|
|
2990
|
+
const pause = deps.sleep ?? sleep;
|
|
2984
2991
|
const {
|
|
2985
2992
|
session,
|
|
2986
2993
|
prompt,
|
|
@@ -2993,17 +3000,22 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
2993
3000
|
busyTimeoutMs = timeoutMs,
|
|
2994
3001
|
} = opts;
|
|
2995
3002
|
const slashCommand = adapter.cli === "agy" && isSlashCommand(prompt);
|
|
2996
|
-
const busyWaitedMs =
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
+
const busyWaitedMs = opts.skipBusyCheck
|
|
3004
|
+
? 0
|
|
3005
|
+
: await waitForTmuxIdle(
|
|
3006
|
+
adapter,
|
|
3007
|
+
{
|
|
3008
|
+
session,
|
|
3009
|
+
remote,
|
|
3010
|
+
pollIntervalMs,
|
|
3011
|
+
busyTimeoutMs,
|
|
3012
|
+
ifBusy: slashCommand ? "queue" : ifBusy,
|
|
3013
|
+
},
|
|
3014
|
+
deps,
|
|
3015
|
+
);
|
|
3003
3016
|
const queued =
|
|
3004
|
-
adapter.cli === "agy" &&
|
|
3005
|
-
|
|
3006
|
-
const beforeRaw = await capturePane(remote, session);
|
|
3017
|
+
adapter.cli === "agy" && adapter.isBusy(await capture(remote, session));
|
|
3018
|
+
const beforeRaw = await captureHistory(remote, session);
|
|
3007
3019
|
const contextPctBefore = adapter.contextPct(beforeRaw);
|
|
3008
3020
|
const context =
|
|
3009
3021
|
adapter.cli === "agy"
|
|
@@ -3026,8 +3038,8 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3026
3038
|
};
|
|
3027
3039
|
|
|
3028
3040
|
const bufferName = tmuxBufferName(session);
|
|
3029
|
-
await
|
|
3030
|
-
await
|
|
3041
|
+
await tmux(remote, ["set-buffer", "-b", bufferName, "--", prompt]);
|
|
3042
|
+
await tmux(remote, [
|
|
3031
3043
|
"paste-buffer",
|
|
3032
3044
|
"-b",
|
|
3033
3045
|
bufferName,
|
|
@@ -3036,31 +3048,42 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3036
3048
|
"-t",
|
|
3037
3049
|
session,
|
|
3038
3050
|
]);
|
|
3039
|
-
await
|
|
3051
|
+
await pause(settleMs);
|
|
3040
3052
|
|
|
3041
|
-
let promptSubmitted =
|
|
3053
|
+
let promptSubmitted =
|
|
3054
|
+
adapter.cli === "agy" || opts.confirmSubmission ? false : !doneMarker;
|
|
3042
3055
|
if (doneMarker || opts.noWait || adapter.cli === "agy") {
|
|
3043
|
-
for (
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3056
|
+
for (
|
|
3057
|
+
let attempt = 1;
|
|
3058
|
+
attempt <= (opts.submitAttempts ?? PROMPT_SUBMIT_MAX_ATTEMPTS);
|
|
3059
|
+
attempt += 1
|
|
3060
|
+
) {
|
|
3061
|
+
await tmux(remote, ["send-keys", "-t", session, "Enter"]);
|
|
3062
|
+
await pause(PROMPT_SUBMIT_RETRY_DELAY_MS);
|
|
3063
|
+
const submissionVisible = await capture(remote, session);
|
|
3064
|
+
if (
|
|
3065
|
+
opts.confirmSubmission
|
|
3066
|
+
? await opts.confirmSubmission(submissionVisible)
|
|
3067
|
+
: !composerShowsPrompt(submissionVisible, prompt, adapter.cli)
|
|
3068
|
+
) {
|
|
3048
3069
|
promptSubmitted = true;
|
|
3049
3070
|
break;
|
|
3050
3071
|
}
|
|
3051
3072
|
}
|
|
3052
3073
|
if (!promptSubmitted) {
|
|
3053
3074
|
throw new Error(
|
|
3054
|
-
|
|
3075
|
+
opts.confirmSubmission
|
|
3076
|
+
? "compact submission could not be confirmed (it may still have started, or /compact may remain in the composer); --then was not sent"
|
|
3077
|
+
: `Prompt did not submit after ${PROMPT_SUBMIT_MAX_ATTEMPTS} attempts`,
|
|
3055
3078
|
);
|
|
3056
3079
|
}
|
|
3057
3080
|
} else {
|
|
3058
|
-
await
|
|
3081
|
+
await tmux(remote, ["send-keys", "-t", session, "Enter"]);
|
|
3059
3082
|
}
|
|
3060
3083
|
|
|
3061
3084
|
let nameApplied;
|
|
3062
3085
|
if (adapter.cli === "agy" && !slashCommand) {
|
|
3063
|
-
const { stdout } = await
|
|
3086
|
+
const { stdout } = await tmux(null, [
|
|
3064
3087
|
"show-options",
|
|
3065
3088
|
"-qv",
|
|
3066
3089
|
"-t",
|
|
@@ -3093,7 +3116,7 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3093
3116
|
pollIntervalMs,
|
|
3094
3117
|
);
|
|
3095
3118
|
if (nameApplied)
|
|
3096
|
-
await
|
|
3119
|
+
await tmux(null, [
|
|
3097
3120
|
"set-option",
|
|
3098
3121
|
"-u",
|
|
3099
3122
|
"-t",
|
|
@@ -3102,7 +3125,7 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3102
3125
|
]);
|
|
3103
3126
|
break;
|
|
3104
3127
|
}
|
|
3105
|
-
await
|
|
3128
|
+
await pause(Math.min(pollIntervalMs, 200));
|
|
3106
3129
|
}
|
|
3107
3130
|
} catch {
|
|
3108
3131
|
nameApplied = false;
|
|
@@ -3160,8 +3183,8 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3160
3183
|
|
|
3161
3184
|
while (Date.now() - startedAt < timeoutMs) {
|
|
3162
3185
|
visible = doneMarker
|
|
3163
|
-
? await
|
|
3164
|
-
: await
|
|
3186
|
+
? await captureHistory(remote, session)
|
|
3187
|
+
: await capture(remote, session);
|
|
3165
3188
|
contextPctAfter = adapter.contextPct(visible);
|
|
3166
3189
|
|
|
3167
3190
|
quietPolls = visible === previousVisible ? quietPolls + 1 : 0;
|
|
@@ -3184,7 +3207,7 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3184
3207
|
quietPolls >= FALLBACK_QUIET_POLLS &&
|
|
3185
3208
|
hasTmuxAssistantResponseAfterCurrentPrompt(
|
|
3186
3209
|
adapter,
|
|
3187
|
-
await
|
|
3210
|
+
await captureHistory(remote, session),
|
|
3188
3211
|
prompt,
|
|
3189
3212
|
beforeRaw,
|
|
3190
3213
|
);
|
|
@@ -3193,10 +3216,10 @@ async function doAskViaTmux(adapter, opts) {
|
|
|
3193
3216
|
break;
|
|
3194
3217
|
}
|
|
3195
3218
|
|
|
3196
|
-
await
|
|
3219
|
+
await pause(pollIntervalMs);
|
|
3197
3220
|
}
|
|
3198
3221
|
|
|
3199
|
-
raw = await
|
|
3222
|
+
raw = await captureHistory(remote, session);
|
|
3200
3223
|
const markerResponse = promptSubmitted
|
|
3201
3224
|
? extractResponseSinceMarker({
|
|
3202
3225
|
beforeRaw,
|
|
@@ -3412,11 +3435,15 @@ async function resolveCodexTmuxThread(session, deps = {}) {
|
|
|
3412
3435
|
|
|
3413
3436
|
async function doAskViaQueue(adapter, opts, deps = {}) {
|
|
3414
3437
|
// queue 를 못 쓰면 tmux 붙여넣기로 보내고 그 이유를 결과에 남긴다.
|
|
3415
|
-
const fallback = async (reason) =>
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3438
|
+
const fallback = async (reason) => {
|
|
3439
|
+
if (opts.queueOnly)
|
|
3440
|
+
throw new Error(`compact --then requires Codex queue: ${reason}`);
|
|
3441
|
+
return {
|
|
3442
|
+
...(await doAskViaTmux(adapter, { ...opts, transport: "tmux" })),
|
|
3443
|
+
transportRequested: "queue",
|
|
3444
|
+
fallbackReason: reason,
|
|
3445
|
+
};
|
|
3446
|
+
};
|
|
3420
3447
|
if (opts.remote) return fallback("remote-host");
|
|
3421
3448
|
const thread = opts.threadId
|
|
3422
3449
|
? { threadId: opts.threadId, threadSource: "flag" }
|
|
@@ -4374,70 +4401,208 @@ async function tmuxContext(adapter, opts) {
|
|
|
4374
4401
|
}
|
|
4375
4402
|
}
|
|
4376
4403
|
|
|
4377
|
-
async function
|
|
4378
|
-
|
|
4404
|
+
async function doCompact(flags, deps = {}) {
|
|
4405
|
+
const cli = flags.cli ?? "claude";
|
|
4406
|
+
if (cli === "agy")
|
|
4379
4407
|
throw new Error("agy has no compact; use /clear or a handoff");
|
|
4380
4408
|
if (
|
|
4381
|
-
|
|
4409
|
+
!["claude", "codex"].includes(cli) ||
|
|
4382
4410
|
flags.remote ||
|
|
4383
4411
|
flags.short ||
|
|
4384
4412
|
flags["session-id"] ||
|
|
4385
4413
|
(flags.transport && flags.transport !== "tmux")
|
|
4386
4414
|
)
|
|
4387
4415
|
throw new Error(
|
|
4388
|
-
"compact supports local --cli claude --session NAME only
|
|
4416
|
+
"compact supports local --cli claude|codex --session NAME only",
|
|
4389
4417
|
);
|
|
4418
|
+
if (cli === "codex" && flags.instructions)
|
|
4419
|
+
throw new Error(
|
|
4420
|
+
"Codex /compact does not accept --instructions; use --then for follow-up instructions",
|
|
4421
|
+
);
|
|
4422
|
+
const thenPrompt = flags.then?.trim();
|
|
4423
|
+
if (flags.then !== undefined && !thenPrompt)
|
|
4424
|
+
throw new Error("--then must not be blank");
|
|
4425
|
+
if (isSlashCommand(thenPrompt))
|
|
4426
|
+
throw new Error("--then accepts text instructions, not slash commands");
|
|
4390
4427
|
const ifBusy = flags["if-busy"] ?? "fail";
|
|
4391
4428
|
if (!["fail", "wait"].includes(ifBusy))
|
|
4392
4429
|
throw new Error("compact --if-busy must be fail or wait");
|
|
4430
|
+
const adapter = ADAPTERS[cli];
|
|
4393
4431
|
const opts = askOpts(
|
|
4394
4432
|
{
|
|
4395
4433
|
...flags,
|
|
4396
|
-
cli
|
|
4434
|
+
cli,
|
|
4397
4435
|
transport: "tmux",
|
|
4398
4436
|
"if-busy": ifBusy,
|
|
4399
4437
|
"no-wait": true,
|
|
4400
4438
|
prompt: `/compact${flags.instructions ? ` ${flags.instructions}` : ""}`,
|
|
4401
4439
|
},
|
|
4402
|
-
|
|
4403
|
-
);
|
|
4404
|
-
if (ifBusy === "wait") await waitForTmuxIdle(ADAPTERS.claude, opts);
|
|
4405
|
-
const transcriptPath = await claudeTmuxTranscript(
|
|
4406
|
-
opts.session,
|
|
4407
|
-
opts.configDir,
|
|
4440
|
+
adapter,
|
|
4408
4441
|
);
|
|
4409
|
-
const
|
|
4410
|
-
|
|
4442
|
+
const pause = deps.sleep ?? sleep;
|
|
4443
|
+
const capture = deps.captureVisible ?? captureVisible;
|
|
4444
|
+
await waitForTmuxIdle(adapter, opts, deps);
|
|
4445
|
+
let transcriptPath;
|
|
4446
|
+
let threadId;
|
|
4447
|
+
if (cli === "claude") {
|
|
4448
|
+
transcriptPath = await (deps.claudeTmuxTranscript ?? claudeTmuxTranscript)(
|
|
4449
|
+
opts.session,
|
|
4450
|
+
opts.configDir,
|
|
4451
|
+
);
|
|
4452
|
+
} else {
|
|
4453
|
+
const thread = await (
|
|
4454
|
+
deps.resolveCodexTmuxThread ?? resolveCodexTmuxThread
|
|
4455
|
+
)(opts.session, deps);
|
|
4456
|
+
threadId = thread.threadId;
|
|
4457
|
+
transcriptPath = await (deps.findCodexRollout ?? findCodexRollout)(
|
|
4458
|
+
threadId,
|
|
4459
|
+
);
|
|
4460
|
+
}
|
|
4461
|
+
const readSnapshot = () =>
|
|
4462
|
+
cli === "claude"
|
|
4463
|
+
? readClaudeTranscript(transcriptPath)
|
|
4464
|
+
: readCodexContext(transcriptPath, threadId);
|
|
4465
|
+
const before = transcriptPath && (await readSnapshot());
|
|
4466
|
+
if (!before || !Number.isInteger(before.compactCount))
|
|
4411
4467
|
throw new Error(
|
|
4412
|
-
|
|
4468
|
+
`compact requires a discoverable ${cli === "claude" ? "Claude transcript" : "Codex rollout"} for this tmux pane`,
|
|
4413
4469
|
);
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
ifBusy: "fail",
|
|
4417
|
-
skipContextGuard: true,
|
|
4418
|
-
});
|
|
4470
|
+
let snapshot = before;
|
|
4471
|
+
const done = () => snapshot?.compactCount > before.compactCount;
|
|
4419
4472
|
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())),
|
|
4473
|
+
const confirmSubmission = async (visible) => {
|
|
4474
|
+
const submissionDeadline = Math.min(
|
|
4475
|
+
deadline,
|
|
4476
|
+
Date.now() + (cli === "codex" ? 1000 : 5000),
|
|
4433
4477
|
);
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4478
|
+
do {
|
|
4479
|
+
snapshot = await readSnapshot();
|
|
4480
|
+
if (
|
|
4481
|
+
done() ||
|
|
4482
|
+
(cli === "claude"
|
|
4483
|
+
? /Compacting conversation/i.test(stripAnsi(visible))
|
|
4484
|
+
: /Compacting context/i.test(stripAnsi(visible)))
|
|
4485
|
+
)
|
|
4486
|
+
return true;
|
|
4487
|
+
await pause(
|
|
4488
|
+
Math.min(
|
|
4489
|
+
opts.pollIntervalMs,
|
|
4490
|
+
Math.max(0, submissionDeadline - Date.now()),
|
|
4491
|
+
),
|
|
4492
|
+
);
|
|
4493
|
+
visible = await capture(null, opts.session);
|
|
4494
|
+
} while (Date.now() < submissionDeadline);
|
|
4495
|
+
return false;
|
|
4496
|
+
};
|
|
4497
|
+
await doAskViaTmux(
|
|
4498
|
+
adapter,
|
|
4499
|
+
{
|
|
4500
|
+
...opts,
|
|
4501
|
+
skipBusyCheck: true,
|
|
4502
|
+
skipContextGuard: true,
|
|
4503
|
+
submitAttempts: cli === "codex" ? 2 : 1,
|
|
4504
|
+
confirmSubmission,
|
|
4505
|
+
},
|
|
4506
|
+
deps,
|
|
4507
|
+
);
|
|
4508
|
+
const result = {
|
|
4509
|
+
cli,
|
|
4437
4510
|
session: opts.session,
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
}
|
|
4511
|
+
compact: { submitted: true, done: done() },
|
|
4512
|
+
compacted: done(),
|
|
4513
|
+
};
|
|
4514
|
+
if (thenPrompt) {
|
|
4515
|
+
const requestId = randomBytes(6).toString("hex");
|
|
4516
|
+
const thenOpts = {
|
|
4517
|
+
...opts,
|
|
4518
|
+
prompt: `[tfx-live req=${requestId}]\n${thenPrompt}`,
|
|
4519
|
+
requestId,
|
|
4520
|
+
threadId,
|
|
4521
|
+
noWait: true,
|
|
4522
|
+
settleMs: 0,
|
|
4523
|
+
transport: cli === "codex" ? "queue" : "tmux",
|
|
4524
|
+
skipBusyCheck: true,
|
|
4525
|
+
skipContextGuard: true,
|
|
4526
|
+
queueOnly: true,
|
|
4527
|
+
};
|
|
4528
|
+
try {
|
|
4529
|
+
if (cli === "codex") {
|
|
4530
|
+
const queued = await doAskViaQueue(adapter, thenOpts, deps);
|
|
4531
|
+
// biome-ignore lint/suspicious/noThenProperty: CLI 계약의 then은 함수가 아닌 큐 결과다.
|
|
4532
|
+
result.then = {
|
|
4533
|
+
...queued,
|
|
4534
|
+
requestId,
|
|
4535
|
+
queued: queued.inputSent === true,
|
|
4536
|
+
};
|
|
4537
|
+
if (!result.then.queued) {
|
|
4538
|
+
result.ok = false;
|
|
4539
|
+
result.error =
|
|
4540
|
+
queued.status === "unknown"
|
|
4541
|
+
? `${queued.error}; --then may already be queued, use wait --request-id and do not resend`
|
|
4542
|
+
: queued.error;
|
|
4543
|
+
}
|
|
4544
|
+
} else {
|
|
4545
|
+
await doAskViaTmux(adapter, thenOpts, deps);
|
|
4546
|
+
const queueDeadline = Date.now() + Math.min(opts.timeoutMs, 5000);
|
|
4547
|
+
let confirmedBy;
|
|
4548
|
+
do {
|
|
4549
|
+
const transcript = await readClaudeTranscript(transcriptPath, {
|
|
4550
|
+
requestId,
|
|
4551
|
+
});
|
|
4552
|
+
if (transcript?.enqueued) confirmedBy = "enqueue";
|
|
4553
|
+
else if (transcript?.userSeen) confirmedBy = "user";
|
|
4554
|
+
else if (
|
|
4555
|
+
/queued messages/i.test(
|
|
4556
|
+
stripAnsi(await capture(null, opts.session)),
|
|
4557
|
+
)
|
|
4558
|
+
)
|
|
4559
|
+
confirmedBy = "tui";
|
|
4560
|
+
if (confirmedBy) break;
|
|
4561
|
+
await pause(
|
|
4562
|
+
Math.min(
|
|
4563
|
+
opts.pollIntervalMs,
|
|
4564
|
+
Math.max(0, queueDeadline - Date.now()),
|
|
4565
|
+
),
|
|
4566
|
+
);
|
|
4567
|
+
} while (Date.now() < queueDeadline);
|
|
4568
|
+
// biome-ignore lint/suspicious/noThenProperty: CLI 계약의 then은 함수가 아닌 큐 결과다.
|
|
4569
|
+
result.then = {
|
|
4570
|
+
requestId,
|
|
4571
|
+
queued: Boolean(confirmedBy) && confirmedBy !== "user",
|
|
4572
|
+
transport: "tmux",
|
|
4573
|
+
...(confirmedBy === "user" ? { delivered: true } : {}),
|
|
4574
|
+
...(confirmedBy ? { confirmedBy } : {}),
|
|
4575
|
+
};
|
|
4576
|
+
if (!confirmedBy) {
|
|
4577
|
+
result.ok = false;
|
|
4578
|
+
result.error =
|
|
4579
|
+
"Claude --then was sent but enqueue could not be confirmed; use wait --request-id, do not resend";
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
} catch (error) {
|
|
4583
|
+
// compact 는 이미 제출됐으므로 그 정보를 지우지 않고 then 실패만 결과에 담는다.
|
|
4584
|
+
// biome-ignore lint/suspicious/noThenProperty: CLI 계약의 then은 함수가 아닌 큐 결과다.
|
|
4585
|
+
result.then = { requestId, queued: false, error: error.message };
|
|
4586
|
+
result.ok = false;
|
|
4587
|
+
result.error = `compact was submitted but --then failed: ${error.message}`;
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
if (!Object.hasOwn(flags, "no-wait")) {
|
|
4591
|
+
do {
|
|
4592
|
+
snapshot = await readSnapshot();
|
|
4593
|
+
if (done()) break;
|
|
4594
|
+
await pause(
|
|
4595
|
+
Math.min(opts.pollIntervalMs, Math.max(0, deadline - Date.now())),
|
|
4596
|
+
);
|
|
4597
|
+
} while (Date.now() < deadline);
|
|
4598
|
+
if (!done()) result.timedOut = true;
|
|
4599
|
+
}
|
|
4600
|
+
result.compacted = result.compact.done = done();
|
|
4601
|
+
if (done()) {
|
|
4602
|
+
Object.assign(result, snapshot.compact);
|
|
4603
|
+
Object.assign(result.compact, snapshot.compact);
|
|
4604
|
+
}
|
|
4605
|
+
return result;
|
|
4441
4606
|
}
|
|
4442
4607
|
|
|
4443
4608
|
function codexGoalStatus(goal) {
|
|
@@ -5695,7 +5860,9 @@ async function main() {
|
|
|
5695
5860
|
} else if (command === "queue") {
|
|
5696
5861
|
await queue(flags);
|
|
5697
5862
|
} else if (command === "compact") {
|
|
5698
|
-
await
|
|
5863
|
+
const result = await doCompact(flags);
|
|
5864
|
+
printJson(result);
|
|
5865
|
+
if (result.ok === false) process.exitCode = 1;
|
|
5699
5866
|
} else if (command === "goal") {
|
|
5700
5867
|
printJson(await goal(action, flags));
|
|
5701
5868
|
} else if (command === "rename") {
|
|
@@ -5737,6 +5904,7 @@ export {
|
|
|
5737
5904
|
discoverClaudeTmuxSessions,
|
|
5738
5905
|
discoverCodexTmuxSessions,
|
|
5739
5906
|
doAskViaTmux,
|
|
5907
|
+
doCompact,
|
|
5740
5908
|
extractAssistantResponse,
|
|
5741
5909
|
extractClaudeCompletedTaskListResponse,
|
|
5742
5910
|
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,
|
|
@@ -137,12 +137,19 @@ function acceptCodexLine(state, line) {
|
|
|
137
137
|
} catch {
|
|
138
138
|
return false;
|
|
139
139
|
}
|
|
140
|
+
if (entry?.type === "compacted") {
|
|
141
|
+
state.compactCount += 1;
|
|
142
|
+
state.compact = { preTokens: state.tokens, postTokens: null };
|
|
143
|
+
}
|
|
140
144
|
if (entry?.type === "turn_context")
|
|
141
145
|
state.model = entry.payload?.model || state.model;
|
|
142
146
|
if (entry?.type === "event_msg" && entry.payload?.type === "token_count") {
|
|
143
147
|
const info = entry.payload.info;
|
|
144
|
-
if (Number.isFinite(info?.last_token_usage?.total_tokens))
|
|
148
|
+
if (Number.isFinite(info?.last_token_usage?.total_tokens)) {
|
|
145
149
|
state.tokens = info.last_token_usage.total_tokens;
|
|
150
|
+
if (state.compact && state.compact.postTokens === null)
|
|
151
|
+
state.compact.postTokens = state.tokens;
|
|
152
|
+
}
|
|
146
153
|
if (Number.isFinite(info?.model_context_window))
|
|
147
154
|
state.executionContextLimitTokens = info.model_context_window;
|
|
148
155
|
}
|
|
@@ -172,6 +179,8 @@ export async function readCodexContext(rolloutPath, threadId) {
|
|
|
172
179
|
decoder: new StringDecoder("utf8"),
|
|
173
180
|
model: null,
|
|
174
181
|
tokens: null,
|
|
182
|
+
compactCount: 0,
|
|
183
|
+
compact: null,
|
|
175
184
|
executionContextLimitTokens: null,
|
|
176
185
|
};
|
|
177
186
|
}
|
|
@@ -199,6 +208,8 @@ export async function readCodexContext(rolloutPath, threadId) {
|
|
|
199
208
|
return {
|
|
200
209
|
...modelContext("codex", state.model, state.tokens),
|
|
201
210
|
model: state.model,
|
|
211
|
+
compactCount: state.compactCount,
|
|
212
|
+
compact: state.compact,
|
|
202
213
|
executionContextLimitTokens: state.executionContextLimitTokens,
|
|
203
214
|
};
|
|
204
215
|
}
|
package/package.json
CHANGED
package/skills/tfx-live/SKILL.md
CHANGED
|
@@ -53,11 +53,13 @@ agy `ask`의 기본 `--if-busy queue`는 바쁜 턴에도 바로 전송한다. a
|
|
|
53
53
|
|
|
54
54
|
| 동작 | 적용 조건과 실행 |
|
|
55
55
|
| --- | --- |
|
|
56
|
-
| compact | 같은 목표와 담당이 이어지고 파일과 git에서 상태를 복구할 수 있을 때. Claude는 `tfx-live compact
|
|
56
|
+
| compact | 같은 목표와 담당이 이어지고 파일과 git에서 상태를 복구할 수 있을 때. Claude와 Codex는 `tfx-live compact --then "다음 지시" --no-wait`로 압축 제출을 확인하고 다음 지시를 미리 넣는다. 압축 뒤에 필요한 파일 경로와 검증 명령도 `--then`에 싣는다. agy는 수동 compact가 없어 handoff한다. |
|
|
57
57
|
| clear | 무관한 작업으로 전환하거나 같은 문제를 두 번 넘게 고쳐도 실패할 때, 스펙에서 구현으로 또는 작성자에서 리뷰어로 바뀔 때. 미완료 상태를 정리한 뒤 Claude는 `/rename`과 `/clear`, Codex는 작업 종료 뒤 `/new <이름>`, agy는 `/clear`를 쓴다. |
|
|
58
58
|
| handoff | 담당·CLI·모델·소유자가 바뀌거나 긴 작업이 창을 넘길 때. 인계 파일에 목표, 완료 조건, 체크아웃과 변경 상태, 확정 결정, 증거 경로, PASS/FAIL/SKIP, 미완료 작업과 프로세스, 다음 행동과 소유자, `predecessorSessionId`를 적는다. 승계 세션이 경로를 확인하면 이전 세션을 닫는다. |
|
|
59
59
|
| close | 결과와 인계를 확인한 즉시 닫는다. Claude 백그라운드는 `claude stop <id>`, tmux는 `tfx-live stop --session`, Claude 팀원은 shutdown, Codex 서브에이전트는 `close_agent`, agy 서브에이전트는 `/agents` 패널의 K를 쓴다. |
|
|
60
60
|
|
|
61
|
+
Claude TUI는 바쁠 때 들어온 입력을 대기열에 넣고 현재 턴(압축 포함)이 끝나면 순서대로 처리한다. transcript에는 `queue-operation`의 `enqueue`와 `dequeue`로 남는다. Codex app-server 큐도 바쁜 턴 뒤에 순서대로 처리한다. 리드는 compact를 보내고 `--then`으로 다음 지시를 넣는 것을 기본 절차로 쓴다.
|
|
62
|
+
|
|
61
63
|
두 번째 compact 전에는 handoff가 맞는지 점검한다. compact 횟수는 고정하지 않는다.
|
|
62
64
|
|
|
63
65
|
리드 역할 절차(역할별 모델, 지시서, 교차 리뷰, 릴리스 조율)는 `tfx-lead` 스킬을 따른다.
|
|
@@ -116,13 +118,19 @@ Claude는 85% 도달 시 바로 handoff를 지시한다. agy 행은 운영 지
|
|
|
116
118
|
### 수동 compact와 종료
|
|
117
119
|
|
|
118
120
|
```bash
|
|
119
|
-
tfx-live compact --cli claude --session cl1 --instructions "변경 파일과 검증 명령을 보존하라" --
|
|
121
|
+
tfx-live compact --cli claude --session cl1 --instructions "변경 파일과 검증 명령을 보존하라" --then "인계 파일을 읽고 검증을 이어가라" --no-wait
|
|
122
|
+
tfx-live compact --cli codex --session cx1 --then "src/main.mjs를 확인하고 지정된 단위 테스트를 실행하라" --no-wait
|
|
123
|
+
tfx-live wait --cli codex --session cx1 --request-id <then.requestId> --timeout 120
|
|
120
124
|
tfx-live stop --cli claude --short <8hex>
|
|
121
125
|
tfx-live stop --cli claude --session-id <id>
|
|
122
126
|
tfx-live stop --session cl1
|
|
123
127
|
```
|
|
124
128
|
|
|
125
|
-
`compact`는 로컬
|
|
129
|
+
`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는 지원하지 않는다.
|
|
130
|
+
|
|
131
|
+
`--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`에서 압축 뒤 응답을 받는다.
|
|
132
|
+
|
|
133
|
+
`--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
134
|
|
|
127
135
|
`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
136
|
|