triflux 10.61.2 → 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.
package/bin/tfx-live.mjs CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } from "node:child_process";
3
3
  import { randomBytes } from "node:crypto";
4
- import { mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ realpathSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
5
11
  import {
6
12
  chmod,
7
13
  mkdir,
@@ -53,6 +59,7 @@ import {
53
59
  modelContext,
54
60
  readCodexContext,
55
61
  } from "../hub/team/session-context.mjs";
62
+ import { JsonRpcWsUdsClient } from "../hub/workers/lib/jsonrpc-ws-uds.mjs";
56
63
 
57
64
  const execFileAsync = promisify(execFile);
58
65
 
@@ -101,7 +108,10 @@ function usage(command) {
101
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]",
102
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]",
103
110
  " ask options: --no-wait --no-relay-tag --warn-context-pct N --max-context-pct N (0 disables; Claude 60/90, Codex 15/22).",
104
- " tfx-live compact --cli claude --session NAME [--instructions TEXT] [--if-busy fail|wait] [--timeout 60]",
111
+ " tfx-live compact --session NAME [--cli claude|codex] [--instructions TEXT] [--then PROMPT] [--no-wait] [--if-busy fail|wait] [--timeout 60]",
112
+ " tfx-live goal set --session NAME [--cli claude|codex] --condition TEXT [--budget N]",
113
+ " tfx-live goal status --session NAME [--cli claude|codex]",
114
+ " tfx-live goal clear --session NAME [--cli claude|codex]",
105
115
  " 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]",
106
116
  " tfx-live wait --cli codex (--session NAME[:WINDOW.PANE] | --thread UUID) --request-id ID [--timeout 60] [--poll-interval 1500]",
107
117
  " tfx-live wait --cli agy --session NAME[:WINDOW.PANE] [--request-id ID] [--timeout 60] [--poll-interval 1500]",
@@ -141,6 +151,7 @@ function parseCli(argv) {
141
151
  }
142
152
 
143
153
  const flags = {};
154
+ const action = command === "goal" ? rest.shift() : undefined;
144
155
  for (let index = 0; index < rest.length; index += 1) {
145
156
  const arg = rest[index];
146
157
  if (!arg.startsWith("--")) {
@@ -165,7 +176,7 @@ function parseCli(argv) {
165
176
  index += 1;
166
177
  }
167
178
 
168
- return { command, flags };
179
+ return { command, flags, action };
169
180
  }
170
181
 
171
182
  function requireFlag(flags, name) {
@@ -2930,7 +2941,10 @@ async function renameAgyConversation(session, name, timeoutMs, pollIntervalMs) {
2930
2941
  return false;
2931
2942
  }
2932
2943
 
2933
- 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;
2934
2948
  const { session, remote, pollIntervalMs, busyTimeoutMs, ifBusy } = opts;
2935
2949
  if (
2936
2950
  ![
@@ -2947,7 +2961,7 @@ async function waitForTmuxIdle(adapter, opts) {
2947
2961
  const startedAt = Date.now();
2948
2962
  let interrupted = false;
2949
2963
  while (true) {
2950
- const visible = await captureVisible(remote, session);
2964
+ const visible = await capture(remote, session);
2951
2965
  const elapsedMs = Date.now() - startedAt;
2952
2966
  const busy =
2953
2967
  adapter.isBusy(visible) ||
@@ -2960,16 +2974,20 @@ async function waitForTmuxIdle(adapter, opts) {
2960
2974
  );
2961
2975
  }
2962
2976
  if (ifBusy === "interrupt" && !interrupted) {
2963
- await runTmux(remote, ["send-keys", "-t", session, "Escape"]);
2977
+ await tmux(remote, ["send-keys", "-t", session, "Escape"]);
2964
2978
  interrupted = true;
2965
2979
  }
2966
- await sleep(
2980
+ await pause(
2967
2981
  Math.min(pollIntervalMs, Math.max(1, busyTimeoutMs - elapsedMs)),
2968
2982
  );
2969
2983
  }
2970
2984
  }
2971
2985
 
2972
- 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;
2973
2991
  const {
2974
2992
  session,
2975
2993
  prompt,
@@ -2982,17 +3000,22 @@ async function doAskViaTmux(adapter, opts) {
2982
3000
  busyTimeoutMs = timeoutMs,
2983
3001
  } = opts;
2984
3002
  const slashCommand = adapter.cli === "agy" && isSlashCommand(prompt);
2985
- const busyWaitedMs = await waitForTmuxIdle(adapter, {
2986
- session,
2987
- remote,
2988
- pollIntervalMs,
2989
- busyTimeoutMs,
2990
- ifBusy: slashCommand ? "queue" : ifBusy,
2991
- });
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
+ );
2992
3016
  const queued =
2993
- adapter.cli === "agy" &&
2994
- adapter.isBusy(await captureVisible(remote, session));
2995
- const beforeRaw = await capturePane(remote, session);
3017
+ adapter.cli === "agy" && adapter.isBusy(await capture(remote, session));
3018
+ const beforeRaw = await captureHistory(remote, session);
2996
3019
  const contextPctBefore = adapter.contextPct(beforeRaw);
2997
3020
  const context =
2998
3021
  adapter.cli === "agy"
@@ -3015,8 +3038,8 @@ async function doAskViaTmux(adapter, opts) {
3015
3038
  };
3016
3039
 
3017
3040
  const bufferName = tmuxBufferName(session);
3018
- await runTmux(remote, ["set-buffer", "-b", bufferName, "--", prompt]);
3019
- await runTmux(remote, [
3041
+ await tmux(remote, ["set-buffer", "-b", bufferName, "--", prompt]);
3042
+ await tmux(remote, [
3020
3043
  "paste-buffer",
3021
3044
  "-b",
3022
3045
  bufferName,
@@ -3025,31 +3048,42 @@ async function doAskViaTmux(adapter, opts) {
3025
3048
  "-t",
3026
3049
  session,
3027
3050
  ]);
3028
- await sleep(settleMs);
3051
+ await pause(settleMs);
3029
3052
 
3030
- let promptSubmitted = adapter.cli === "agy" ? false : !doneMarker;
3053
+ let promptSubmitted =
3054
+ adapter.cli === "agy" || opts.confirmSubmission ? false : !doneMarker;
3031
3055
  if (doneMarker || opts.noWait || adapter.cli === "agy") {
3032
- for (let attempt = 1; attempt <= PROMPT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
3033
- await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
3034
- await sleep(PROMPT_SUBMIT_RETRY_DELAY_MS);
3035
- const submissionVisible = await captureVisible(remote, session);
3036
- if (!composerShowsPrompt(submissionVisible, prompt, adapter.cli)) {
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
+ ) {
3037
3069
  promptSubmitted = true;
3038
3070
  break;
3039
3071
  }
3040
3072
  }
3041
3073
  if (!promptSubmitted) {
3042
3074
  throw new Error(
3043
- `Prompt did not submit after ${PROMPT_SUBMIT_MAX_ATTEMPTS} attempts`,
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`,
3044
3078
  );
3045
3079
  }
3046
3080
  } else {
3047
- await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
3081
+ await tmux(remote, ["send-keys", "-t", session, "Enter"]);
3048
3082
  }
3049
3083
 
3050
3084
  let nameApplied;
3051
3085
  if (adapter.cli === "agy" && !slashCommand) {
3052
- const { stdout } = await runTmux(null, [
3086
+ const { stdout } = await tmux(null, [
3053
3087
  "show-options",
3054
3088
  "-qv",
3055
3089
  "-t",
@@ -3082,7 +3116,7 @@ async function doAskViaTmux(adapter, opts) {
3082
3116
  pollIntervalMs,
3083
3117
  );
3084
3118
  if (nameApplied)
3085
- await runTmux(null, [
3119
+ await tmux(null, [
3086
3120
  "set-option",
3087
3121
  "-u",
3088
3122
  "-t",
@@ -3091,7 +3125,7 @@ async function doAskViaTmux(adapter, opts) {
3091
3125
  ]);
3092
3126
  break;
3093
3127
  }
3094
- await sleep(Math.min(pollIntervalMs, 200));
3128
+ await pause(Math.min(pollIntervalMs, 200));
3095
3129
  }
3096
3130
  } catch {
3097
3131
  nameApplied = false;
@@ -3149,8 +3183,8 @@ async function doAskViaTmux(adapter, opts) {
3149
3183
 
3150
3184
  while (Date.now() - startedAt < timeoutMs) {
3151
3185
  visible = doneMarker
3152
- ? await capturePane(remote, session)
3153
- : await captureVisible(remote, session);
3186
+ ? await captureHistory(remote, session)
3187
+ : await capture(remote, session);
3154
3188
  contextPctAfter = adapter.contextPct(visible);
3155
3189
 
3156
3190
  quietPolls = visible === previousVisible ? quietPolls + 1 : 0;
@@ -3173,7 +3207,7 @@ async function doAskViaTmux(adapter, opts) {
3173
3207
  quietPolls >= FALLBACK_QUIET_POLLS &&
3174
3208
  hasTmuxAssistantResponseAfterCurrentPrompt(
3175
3209
  adapter,
3176
- await capturePane(remote, session),
3210
+ await captureHistory(remote, session),
3177
3211
  prompt,
3178
3212
  beforeRaw,
3179
3213
  );
@@ -3182,10 +3216,10 @@ async function doAskViaTmux(adapter, opts) {
3182
3216
  break;
3183
3217
  }
3184
3218
 
3185
- await sleep(pollIntervalMs);
3219
+ await pause(pollIntervalMs);
3186
3220
  }
3187
3221
 
3188
- raw = await capturePane(remote, session);
3222
+ raw = await captureHistory(remote, session);
3189
3223
  const markerResponse = promptSubmitted
3190
3224
  ? extractResponseSinceMarker({
3191
3225
  beforeRaw,
@@ -3233,10 +3267,8 @@ async function doAsk(adapter, opts) {
3233
3267
  const requestId = opts.requestId ?? randomBytes(6).toString("hex");
3234
3268
  const tag = `[tfx-live req=${requestId}]`;
3235
3269
  // Codex 는 접힌 발신자 표시가 없어 첫 줄 머리말로 보낸 세션을 드러낸다.
3236
- // 슬래시 명령은 첫 글자가 / 여야 실행되므로 표식을 붙이지 않는다.
3237
- const bare =
3238
- opts.noRelayTag ||
3239
- (["codex", "agy"].includes(adapter.cli) && isSlashCommand(opts.prompt));
3270
+ // 슬래시 명령은 CLI 와 상관없이 첫 글자가 / 여야 실행되므로 표식을 붙이지 않는다.
3271
+ const bare = opts.noRelayTag || isSlashCommand(opts.prompt);
3240
3272
  const from =
3241
3273
  adapter.cli === "codex" && !bare
3242
3274
  ? (opts.from ?? (await resolveSenderName()))
@@ -3248,7 +3280,15 @@ async function doAsk(adapter, opts) {
3248
3280
  : `${header}\n${opts.prompt}`;
3249
3281
  try {
3250
3282
  return {
3251
- ...(await dispatchAsk(adapter, { ...opts, prompt, requestId })),
3283
+ ...(await dispatchAsk(adapter, {
3284
+ ...opts,
3285
+ prompt,
3286
+ requestId,
3287
+ noRelayTag: bare,
3288
+ noWait:
3289
+ opts.noWait ||
3290
+ (adapter.cli === "claude" && CLAUDE_SILENT_SLASH.test(opts.prompt)),
3291
+ })),
3252
3292
  requestId,
3253
3293
  ...(opts.transportReason
3254
3294
  ? { transportReason: opts.transportReason }
@@ -3395,11 +3435,15 @@ async function resolveCodexTmuxThread(session, deps = {}) {
3395
3435
 
3396
3436
  async function doAskViaQueue(adapter, opts, deps = {}) {
3397
3437
  // queue 를 못 쓰면 tmux 붙여넣기로 보내고 그 이유를 결과에 남긴다.
3398
- const fallback = async (reason) => ({
3399
- ...(await doAskViaTmux(adapter, { ...opts, transport: "tmux" })),
3400
- transportRequested: "queue",
3401
- fallbackReason: reason,
3402
- });
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
+ };
3403
3447
  if (opts.remote) return fallback("remote-host");
3404
3448
  const thread = opts.threadId
3405
3449
  ? { threadId: opts.threadId, threadSource: "flag" }
@@ -3980,9 +4024,15 @@ function transportFlag(flags, adapter, short, sessionId) {
3980
4024
 
3981
4025
  // 절대 경로로 시작하는 프롬프트는 슬래시 명령이 아니다.
3982
4026
  function isSlashCommand(prompt) {
3983
- return /^\/[a-z][\w-]*(?:\s|$)/i.test(String(prompt ?? ""));
4027
+ const word = String(prompt ?? "").match(/^\/[a-z][\w-]*(?=\s|$)/i)?.[0];
4028
+ // `/tmp 를 확인해` 처럼 이 기기에 실제로 있는 경로로 시작하는 문장은 명령이 아니다.
4029
+ return Boolean(word) && !existsSync(word);
3984
4030
  }
3985
4031
 
4032
+ // 응답 턴을 만들지 않는 Claude 내장 명령. 스킬 명령은 응답을 만드므로 기다린다.
4033
+ const CLAUDE_SILENT_SLASH =
4034
+ /^\/(?:rename|clear|compact|model|effort|color)(?:\s|$)/i;
4035
+
3986
4036
  function askOpts(flags, adapter) {
3987
4037
  const short = flags.short;
3988
4038
  const sessionId = flags["session-id"];
@@ -4351,70 +4401,521 @@ async function tmuxContext(adapter, opts) {
4351
4401
  }
4352
4402
  }
4353
4403
 
4354
- async function compact(flags) {
4355
- if (flags.cli === "agy")
4404
+ async function doCompact(flags, deps = {}) {
4405
+ const cli = flags.cli ?? "claude";
4406
+ if (cli === "agy")
4356
4407
  throw new Error("agy has no compact; use /clear or a handoff");
4357
4408
  if (
4358
- (flags.cli ?? "claude") !== "claude" ||
4409
+ !["claude", "codex"].includes(cli) ||
4359
4410
  flags.remote ||
4360
4411
  flags.short ||
4361
4412
  flags["session-id"] ||
4362
4413
  (flags.transport && flags.transport !== "tmux")
4363
4414
  )
4364
4415
  throw new Error(
4365
- "compact supports local --cli claude --session NAME only; UDS slash execution is unverified",
4416
+ "compact supports local --cli claude|codex --session NAME only",
4366
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");
4367
4427
  const ifBusy = flags["if-busy"] ?? "fail";
4368
4428
  if (!["fail", "wait"].includes(ifBusy))
4369
4429
  throw new Error("compact --if-busy must be fail or wait");
4430
+ const adapter = ADAPTERS[cli];
4370
4431
  const opts = askOpts(
4371
4432
  {
4372
4433
  ...flags,
4373
- cli: "claude",
4434
+ cli,
4374
4435
  transport: "tmux",
4375
4436
  "if-busy": ifBusy,
4376
4437
  "no-wait": true,
4377
4438
  prompt: `/compact${flags.instructions ? ` ${flags.instructions}` : ""}`,
4378
4439
  },
4379
- ADAPTERS.claude,
4440
+ adapter,
4380
4441
  );
4381
- if (ifBusy === "wait") await waitForTmuxIdle(ADAPTERS.claude, opts);
4382
- const transcriptPath = await claudeTmuxTranscript(
4383
- opts.session,
4384
- opts.configDir,
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))
4467
+ throw new Error(
4468
+ `compact requires a discoverable ${cli === "claude" ? "Claude transcript" : "Codex rollout"} for this tmux pane`,
4469
+ );
4470
+ let snapshot = before;
4471
+ const done = () => snapshot?.compactCount > before.compactCount;
4472
+ const deadline = Date.now() + opts.timeoutMs;
4473
+ const confirmSubmission = async (visible) => {
4474
+ const submissionDeadline = Math.min(
4475
+ deadline,
4476
+ Date.now() + (cli === "codex" ? 1000 : 5000),
4477
+ );
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,
4385
4507
  );
4386
- const before = await readClaudeTranscript(transcriptPath);
4387
- if (!before)
4508
+ const result = {
4509
+ cli,
4510
+ session: opts.session,
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;
4606
+ }
4607
+
4608
+ function codexGoalStatus(goal) {
4609
+ return {
4610
+ state: goal
4611
+ ? ({ usageLimited: "usage_limited", budgetLimited: "budget_limited" }[
4612
+ goal.status
4613
+ ] ?? goal.status)
4614
+ : "none",
4615
+ objective: goal?.objective ?? null,
4616
+ judgedBy: "model",
4617
+ ...(goal
4618
+ ? {
4619
+ tokensUsed: goal.tokensUsed,
4620
+ tokenBudget: goal.tokenBudget ?? null,
4621
+ updatedAt: new Date(goal.updatedAt * 1000).toISOString(),
4622
+ }
4623
+ : {}),
4624
+ };
4625
+ }
4626
+
4627
+ async function readCodexGoal(threadId) {
4628
+ let db;
4629
+ try {
4630
+ const [major, minor] = process.versions.node.split(".").map(Number);
4631
+ if (
4632
+ major < 22 ||
4633
+ (major === 22 && minor < 13) ||
4634
+ (major === 23 && minor < 4)
4635
+ )
4636
+ throw new Error("Node 22.13+ with read-only node:sqlite is required");
4637
+ const { DatabaseSync } = await import("node:sqlite");
4638
+ db = new DatabaseSync(
4639
+ pathJoin(
4640
+ process.env.CODEX_HOME ?? pathJoin(homedir(), ".codex"),
4641
+ "goals_1.sqlite",
4642
+ ),
4643
+ { readOnly: true },
4644
+ );
4645
+ const row = db
4646
+ .prepare(
4647
+ "SELECT objective, status, token_budget AS tokenBudget, tokens_used AS tokensUsed, updated_at_ms / 1000.0 AS updatedAt FROM thread_goals WHERE thread_id = ?",
4648
+ )
4649
+ .get(threadId);
4650
+ return codexGoalStatus(row);
4651
+ } catch (error) {
4652
+ // Node >=22 전체에서 sqlite 를 지원하지는 않으므로 미확인을 none 으로 보고하지 않는다.
4653
+ return {
4654
+ state: null,
4655
+ objective: null,
4656
+ judgedBy: "model",
4657
+ warning: `Goal status unavailable (read-only node:sqlite): ${error.message}`,
4658
+ };
4659
+ } finally {
4660
+ db?.close();
4661
+ }
4662
+ }
4663
+
4664
+ async function codexGoal(action, opts, deps = {}) {
4665
+ const { threadId, objective, tokenBudget } = opts;
4666
+ let fallbackReason;
4667
+ let client;
4668
+ try {
4669
+ const socketPath = (deps.resolveSocket ?? resolveCodexDaemonSocket)(
4670
+ opts.codexSocket,
4671
+ );
4672
+ client = (
4673
+ deps.clientFactory ?? ((options) => new JsonRpcWsUdsClient(options))
4674
+ )({ socketPath, connectTimeoutMs: 5000 });
4675
+ await client.connect();
4676
+ await client.request(
4677
+ "initialize",
4678
+ {
4679
+ clientInfo: { name: "triflux-tfx-live", version: "1.0.0" },
4680
+ capabilities: { experimentalApi: true },
4681
+ },
4682
+ 5000,
4683
+ );
4684
+ client.notify("initialized", {});
4685
+ let cursor;
4686
+ let loaded = false;
4687
+ do {
4688
+ const page = await client.request(
4689
+ "thread/loaded/list",
4690
+ cursor ? { cursor } : {},
4691
+ 5000,
4692
+ );
4693
+ loaded = page.data.includes(threadId);
4694
+ cursor = page.nextCursor;
4695
+ } while (!loaded && cursor);
4696
+ if (!loaded) fallbackReason = "thread-not-loaded";
4697
+ } catch (error) {
4698
+ fallbackReason = `daemon-unavailable: ${error.message}`;
4699
+ }
4700
+ try {
4701
+ if (fallbackReason) {
4702
+ if (tokenBudget !== undefined)
4703
+ throw new Error(
4704
+ `--budget requires a loaded Codex daemon thread; tmux fallback cannot set a budget (${fallbackReason})`,
4705
+ );
4706
+ return {
4707
+ ...(action === "status"
4708
+ ? await (deps.readGoal ?? readCodexGoal)(threadId)
4709
+ : {}),
4710
+ transport: action === "status" ? "sqlite" : "tmux",
4711
+ fallbackReason,
4712
+ };
4713
+ }
4714
+ const params = { threadId, origin: "user" };
4715
+ // 쓰기를 시도한 뒤의 오류는 폴백하지 않는다. 성공 여부가 불명확한 명령을 중복 전송할 수 있다.
4716
+ if (action !== "status") {
4717
+ const cleared = await client.request("thread/goal/clear", params, 10000);
4718
+ if (action === "clear")
4719
+ return {
4720
+ ...codexGoalStatus(null),
4721
+ ...cleared,
4722
+ confirmed: true,
4723
+ transport: "daemon",
4724
+ };
4725
+ }
4726
+ const result =
4727
+ action === "set"
4728
+ ? await client
4729
+ .request(
4730
+ "thread/goal/set",
4731
+ {
4732
+ ...params,
4733
+ objective,
4734
+ status: "active",
4735
+ ...(tokenBudget !== undefined ? { tokenBudget } : {}),
4736
+ },
4737
+ 10000,
4738
+ )
4739
+ .catch((error) => {
4740
+ throw new Error(
4741
+ `${error.message}; clear was applied and there is currently no goal`,
4742
+ );
4743
+ })
4744
+ : await client.request("thread/goal/get", { threadId }, 10000);
4745
+ return {
4746
+ ...codexGoalStatus(result.goal),
4747
+ ...(action === "set" ? { confirmed: true } : {}),
4748
+ transport: "daemon",
4749
+ };
4750
+ } finally {
4751
+ client?.close();
4752
+ }
4753
+ }
4754
+
4755
+ async function goal(action, flags) {
4756
+ if (!["set", "status", "clear"].includes(action))
4757
+ throw new Error("goal requires set, status, or clear");
4758
+ if (flags.remote !== undefined)
4759
+ throw new Error("goal does not support --remote");
4760
+ const session = splitTmuxTarget(requireFlag(flags, "session")).target;
4761
+ let cli = flags.cli;
4762
+ if (!cli) {
4763
+ for (const candidate of ["claude", "codex", "agy"]) {
4764
+ if (await paneCliProcess(session, candidate)) {
4765
+ cli = candidate;
4766
+ break;
4767
+ }
4768
+ }
4769
+ if (!cli)
4770
+ throw new Error("Cannot infer session CLI; specify --cli claude|codex");
4771
+ }
4772
+ if (cli === "agy") throw new Error("agy has no native goal");
4773
+ const adapter = selectAdapter({ ...flags, cli });
4774
+ if (flags.budget !== undefined && (cli !== "codex" || action !== "set"))
4775
+ throw new Error("--budget is supported only by goal set --cli codex");
4776
+ const tokenBudget = integerFlag(flags, "budget", undefined);
4777
+ if (tokenBudget !== undefined && !Number.isSafeInteger(tokenBudget))
4778
+ throw new Error("--budget must be a safe positive integer");
4779
+ const objective =
4780
+ action === "set"
4781
+ ? requireFlag(flags, "condition").replace(/\s+/gu, " ").trim()
4782
+ : null;
4783
+ if (
4784
+ action === "set" &&
4785
+ (!objective || /^(clear|pause|resume|edit)(?: |$)/i.test(objective))
4786
+ )
4388
4787
  throw new Error(
4389
- "compact requires a discoverable Claude transcript for this tmux pane",
4788
+ "--condition must not be blank or start with a reserved command (clear, pause, resume, edit)",
4390
4789
  );
4391
- await doAskViaTmux(ADAPTERS.claude, {
4790
+ const base = { cli, session };
4791
+ let codexResult;
4792
+ let threadId;
4793
+ if (cli === "codex") {
4794
+ ({ threadId } = await codexThreadFromFlags(flags));
4795
+ codexResult = await codexGoal(action, {
4796
+ threadId,
4797
+ objective,
4798
+ tokenBudget,
4799
+ codexSocket: flags["codex-socket"],
4800
+ });
4801
+ if (codexResult.transport !== "tmux") return { ...base, ...codexResult };
4802
+ }
4803
+ const transcriptPath =
4804
+ cli === "claude"
4805
+ ? await claudeTmuxTranscript(session, flags["config-dir"])
4806
+ : null;
4807
+ let snapshot =
4808
+ cli === "claude" ? await readClaudeTranscript(transcriptPath) : null;
4809
+ const unavailable = {
4810
+ state: null,
4811
+ objective: null,
4812
+ judgedBy: "evaluator",
4813
+ warning: "Claude transcript unavailable for this tmux pane",
4814
+ };
4815
+ if (action === "status")
4816
+ return {
4817
+ ...base,
4818
+ ...(snapshot?.goal ?? unavailable),
4819
+ transport: "tmux",
4820
+ };
4821
+ // 달성 뒤 자동 해제됐거나 없던 goal 은 /goal clear 가 기록을 남기지 않아 확인할 수 없다.
4822
+ if (action === "clear" && snapshot?.goal && snapshot.goal.state !== "active")
4823
+ return {
4824
+ ...base,
4825
+ ...snapshot.goal,
4826
+ transport: "tmux",
4827
+ confirmed: true,
4828
+ alreadyCleared: true,
4829
+ };
4830
+ const opts = askOpts(
4831
+ {
4832
+ ...flags,
4833
+ cli,
4834
+ "if-busy":
4835
+ flags["if-busy"] ?? (action === "clear" ? "interrupt" : "wait"),
4836
+ transport: "tmux",
4837
+ "no-wait": true,
4838
+ "no-relay-tag": true,
4839
+ prompt: `/goal ${action === "set" ? objective : "clear"}`,
4840
+ },
4841
+ adapter,
4842
+ );
4843
+ let interrupted = false;
4844
+ if (action === "clear" && opts.ifBusy === "interrupt") {
4845
+ const visible = await captureVisible(null, session);
4846
+ // Claude 반복 턴은 화면에 esc 안내가 없어도 active goal 을 계속 실행할 수 있다.
4847
+ if (
4848
+ snapshot?.goal?.state === "active" ||
4849
+ adapter.isBusy(visible) ||
4850
+ (cli === "codex" && isCodexLoadingCapture(visible))
4851
+ ) {
4852
+ await doInterruptViaTmux(adapter, opts);
4853
+ await sleep(opts.pollIntervalMs);
4854
+ interrupted = true;
4855
+ }
4856
+ }
4857
+ await waitForTmuxIdle(adapter, opts);
4858
+ // Esc 가 직전 입력을 복원해도 clear 명령 앞에 붙지 않게 비운다.
4859
+ if (interrupted) await runTmux(null, ["send-keys", "-t", session, "C-u"]);
4860
+ snapshot =
4861
+ cli === "claude" ? await readClaudeTranscript(transcriptPath) : null;
4862
+ const beforeCount = snapshot?.goalSentinel?.count ?? 0;
4863
+ const inputStartedAt = Date.now();
4864
+ await doAskViaTmux(adapter, {
4392
4865
  ...opts,
4393
4866
  ifBusy: "fail",
4394
4867
  skipContextGuard: true,
4395
4868
  });
4396
- const deadline = Date.now() + opts.timeoutMs;
4869
+ const deadline = Date.now() + Math.min(opts.timeoutMs, 15_000);
4870
+ let confirmed = false;
4871
+ if (cli === "codex") {
4872
+ do {
4873
+ snapshot = await readCodexGoal(threadId);
4874
+ confirmed =
4875
+ action === "set"
4876
+ ? snapshot.state === "active" &&
4877
+ snapshot.objective === objective &&
4878
+ Date.parse(snapshot.updatedAt) >= inputStartedAt
4879
+ : snapshot.state === "none";
4880
+ if (confirmed || snapshot.state === null || Date.now() >= deadline) break;
4881
+ await sleep(Math.min(opts.pollIntervalMs, deadline - Date.now()));
4882
+ } while (Date.now() < deadline);
4883
+ // 입력은 이미 들어갔으므로 오류로 끝내면 재전송을 부른다. 미확인으로만 알린다.
4884
+ return {
4885
+ ...base,
4886
+ ...codexResult,
4887
+ ...snapshot,
4888
+ inputSent: true,
4889
+ confirmed,
4890
+ ...(!confirmed && snapshot.state !== null
4891
+ ? { warning: "No matching Codex goal observed after input" }
4892
+ : {}),
4893
+ };
4894
+ }
4895
+ // 갓 띄운 세션은 첫 입력 뒤에야 자기 transcript 가 생기므로 매번 경로를 다시 찾는다.
4397
4896
  do {
4398
- const snapshot = await readClaudeTranscript(transcriptPath);
4399
- if (snapshot?.compactCount > before.compactCount) {
4400
- printJson({
4401
- cli: "claude",
4402
- session: opts.session,
4403
- compacted: true,
4404
- ...snapshot.compact,
4405
- });
4406
- return;
4407
- }
4408
- await sleep(
4409
- Math.min(opts.pollIntervalMs, Math.max(0, deadline - Date.now())),
4897
+ snapshot = await readClaudeTranscript(
4898
+ await claudeTmuxTranscript(session, flags["config-dir"]),
4410
4899
  );
4900
+ const sentinel = snapshot?.goalSentinel;
4901
+ confirmed =
4902
+ sentinel?.count > beforeCount &&
4903
+ Date.parse(sentinel.updatedAt) >= inputStartedAt &&
4904
+ (action === "set"
4905
+ ? sentinel.met === false && sentinel.condition === objective
4906
+ : sentinel.met === true);
4907
+ if (confirmed || Date.now() >= deadline) break;
4908
+ await sleep(Math.min(opts.pollIntervalMs, deadline - Date.now()));
4411
4909
  } while (Date.now() < deadline);
4412
- printJson({
4413
- cli: "claude",
4414
- session: opts.session,
4415
- compacted: false,
4416
- timedOut: true,
4417
- });
4910
+ return {
4911
+ ...base,
4912
+ ...(snapshot?.goal ?? unavailable),
4913
+ transport: "tmux",
4914
+ confirmed,
4915
+ ...(!confirmed
4916
+ ? { warning: "No new matching goal_status sentinel observed after input" }
4917
+ : {}),
4918
+ };
4418
4919
  }
4419
4920
 
4420
4921
  async function interrupt(flags) {
@@ -5343,7 +5844,7 @@ function printJson(value) {
5343
5844
  }
5344
5845
 
5345
5846
  async function main() {
5346
- const { command, flags } = parseCli(process.argv.slice(2));
5847
+ const { command, flags, action } = parseCli(process.argv.slice(2));
5347
5848
 
5348
5849
  if (command === "help") {
5349
5850
  process.stdout.write(`${usage(flags.subcommand)}\n`);
@@ -5359,7 +5860,11 @@ async function main() {
5359
5860
  } else if (command === "queue") {
5360
5861
  await queue(flags);
5361
5862
  } else if (command === "compact") {
5362
- await compact(flags);
5863
+ const result = await doCompact(flags);
5864
+ printJson(result);
5865
+ if (result.ok === false) process.exitCode = 1;
5866
+ } else if (command === "goal") {
5867
+ printJson(await goal(action, flags));
5363
5868
  } else if (command === "rename") {
5364
5869
  await rename(flags);
5365
5870
  } else if (command === "stop") {
@@ -5392,12 +5897,14 @@ export {
5392
5897
  buildTmuxCommand,
5393
5898
  callRemoteLive,
5394
5899
  classifyPeerExitReason,
5900
+ codexGoal,
5395
5901
  composerShowsPrompt,
5396
5902
  createPeerSignalController,
5397
5903
  derivePeerStatus,
5398
5904
  discoverClaudeTmuxSessions,
5399
5905
  discoverCodexTmuxSessions,
5400
5906
  doAskViaTmux,
5907
+ doCompact,
5401
5908
  extractAssistantResponse,
5402
5909
  extractClaudeCompletedTaskListResponse,
5403
5910
  extractResponseSinceMarker,
@@ -5405,6 +5912,7 @@ export {
5405
5912
  inspectCodexTmuxPane,
5406
5913
  inspectUnsetEnv,
5407
5914
  isCodexLoadingCapture,
5915
+ isSlashCommand,
5408
5916
  parseCodexTmuxSessions,
5409
5917
  parsePeerClosure,
5410
5918
  parseRemoteLiveJson,