@triflux/remote 10.44.0 → 10.46.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.
Files changed (40) hide show
  1. package/hub/pipe.mjs +10 -15
  2. package/hub/schema.sql +1 -0
  3. package/hub/server.mjs +172 -93
  4. package/hub/store.mjs +127 -61
  5. package/hub/team/backend.mjs +4 -2
  6. package/hub/team/claude-daemon-control.mjs +2 -0
  7. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  8. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  9. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  10. package/hub/team/conductor.mjs +80 -24
  11. package/hub/team/cto-auto-collect.mjs +2 -6
  12. package/hub/team/cto-pull.mjs +4 -0
  13. package/hub/team/dashboard-open.mjs +1 -0
  14. package/hub/team/execution-mode.mjs +38 -2
  15. package/hub/team/headless.mjs +493 -82
  16. package/hub/team/interactive-tui-transport.mjs +15 -1
  17. package/hub/team/launcher-template.mjs +17 -1
  18. package/hub/team/orchestrator.mjs +36 -9
  19. package/hub/team/pane.mjs +176 -14
  20. package/hub/team/psmux.mjs +79 -70
  21. package/hub/team/retry-state-machine.mjs +47 -16
  22. package/hub/team/session.mjs +57 -0
  23. package/hub/team/synapse-http.mjs +149 -4
  24. package/hub/team/terminal-opener.mjs +68 -13
  25. package/hub/team/tui-viewer.mjs +65 -14
  26. package/hub/team/uds-orchestrator.mjs +384 -83
  27. package/hub/tools.mjs +10 -1
  28. package/hub/workers/codex-mcp.mjs +9 -4
  29. package/hub/workers/delegator-mcp.mjs +1 -1
  30. package/package.json +1 -1
  31. package/scripts/lib/agent-route-policy.mjs +99 -38
  32. package/scripts/lib/cli-agy.mjs +24 -4
  33. package/scripts/lib/cli-codex.mjs +2 -0
  34. package/scripts/lib/codex-profile-config.mjs +45 -13
  35. package/scripts/lib/codex-recovery.sh +19 -0
  36. package/scripts/lib/gemini-profiles.mjs +113 -29
  37. package/scripts/lib/keyword-rules.mjs +12 -0
  38. package/scripts/lib/logger.mjs +60 -43
  39. package/scripts/lib/machine-profile.mjs +293 -0
  40. package/scripts/lib/mcp-health.mjs +44 -18
@@ -14,6 +14,7 @@ const STARTUP_PROMPT_MAX_ATTEMPTS = 30;
14
14
  const STARTUP_PROMPT_POLL_INTERVAL_MS = 300;
15
15
  const PIPE_TEMP_PREFIX = "triflux-tui-pipe-";
16
16
  const PIPE_OUTPUT_FILE = "pane.out";
17
+ const CODEX_NO_APPROVAL_FLAG = "--dangerously-bypass-approvals-and-sandbox";
17
18
 
18
19
  const CONTROL_SEQUENCES = [
19
20
  ["\x1b[A", "Up"],
@@ -41,6 +42,13 @@ export async function defaultRunTmux(args, options = {}) {
41
42
  });
42
43
  }
43
44
 
45
+ export function ensureInteractiveCodexPosture(launchCmd) {
46
+ const command = String(launchCmd || "").trim();
47
+ if (!/^codex(?:\s|$)/u.test(command)) return command;
48
+ if (command.includes(CODEX_NO_APPROVAL_FLAG)) return command;
49
+ return command.replace(/^codex/u, `codex ${CODEX_NO_APPROVAL_FLAG}`);
50
+ }
51
+
44
52
  export function createInteractiveTuiTransport({
45
53
  runTmux = defaultRunTmux,
46
54
  sessionName,
@@ -113,7 +121,13 @@ export function createInteractiveTuiTransport({
113
121
  ...(cwd ? ["-c", cwd] : []),
114
122
  ...buildTmuxEnvArgs(env),
115
123
  ]);
116
- await tmux(["send-keys", "-t", sessionName, String(launchCmd), "Enter"]);
124
+ await tmux([
125
+ "send-keys",
126
+ "-t",
127
+ sessionName,
128
+ ensureInteractiveCodexPosture(launchCmd),
129
+ "Enter",
130
+ ]);
117
131
  await dismissStartupPrompts({ tmux, captureVisible, sessionName });
118
132
  started = true;
119
133
  await startOutputStream();
@@ -5,6 +5,11 @@
5
5
  // F4 해결: codex exec "prompt" 인라인 (파이프/리다이렉트 아님)
6
6
  // F5 해결: 동일 입력 → 동일 args 배열 (런타임 분기 없음)
7
7
 
8
+ import {
9
+ buildDisabledCliError,
10
+ normalizeCliName,
11
+ resolveCliPolicy,
12
+ } from "../../scripts/lib/machine-profile.mjs";
8
13
  import { buildExecArgs as buildCodexArgs } from "@triflux/core/hub/codex-adapter.mjs";
9
14
 
10
15
  function shellSingleQuote(value) {
@@ -13,7 +18,7 @@ function shellSingleQuote(value) {
13
18
 
14
19
  function buildAntigravityArgs(opts = {}) {
15
20
  const prompt = typeof opts.prompt === "string" ? opts.prompt : "";
16
- const command = `printf %s ${shellSingleQuote(prompt)} | agy --print --dangerously-skip-permissions`;
21
+ const command = `agy --dangerously-skip-permissions --print ${shellSingleQuote(prompt)}`;
17
22
  if (opts.resultFile) {
18
23
  return `${command} > ${shellSingleQuote(opts.resultFile)} 2>${shellSingleQuote(`${opts.resultFile}.err`)}`;
19
24
  }
@@ -90,6 +95,17 @@ export function buildLauncher(opts) {
90
95
  if (!agent) throw new Error("agent is required");
91
96
  if (!prompt && prompt !== "") throw new Error("prompt is required");
92
97
 
98
+ // TFX_DISABLE_* SSOT 집행. 이 런처는 tfx-route.sh 를 거치지 않고 codex/agy
99
+ // 바이너리를 직접 실행하는 경로라서 같은 정책을 여기서 다시 확인한다. 정책
100
+ // 판독 시임은 opts.cliPolicy(계산된 정책)와 opts.policyEnv(env 객체)뿐이다.
101
+ // claude 처럼 정책 대상이 아닌 CLI 는 프로파일 파일을 읽지 않는다.
102
+ if (normalizeCliName(agent)) {
103
+ const cliPolicy =
104
+ opts.cliPolicy ?? resolveCliPolicy(opts.policyEnv ?? process.env);
105
+ const blockedCli = buildDisabledCliError(agent, cliPolicy);
106
+ if (blockedCli) throw blockedCli;
107
+ }
108
+
93
109
  const adapter = getAdapter(agent);
94
110
 
95
111
  const command = adapter.buildArgs({
@@ -126,7 +126,8 @@ export function buildPrompt(subtask, config) {
126
126
  * @param {string} opts.hubUrl — Hub URL
127
127
  * @param {{target:string, cli:string, task:string}|null} opts.lead
128
128
  * @param {string} opts.teammateMode
129
- * @returns {Promise<void>}
129
+ * @param {(failure: {target:string, cli:string, role:string, message:string}) => void} [opts.onInjectionFailure]
130
+ * @returns {Promise<Array<{target:string, cli:string, role:string, message:string}>>}
130
131
  */
131
132
  export async function orchestrate(sessionName, assignments, opts = {}) {
132
133
  const {
@@ -134,7 +135,24 @@ export async function orchestrate(sessionName, assignments, opts = {}) {
134
135
  lead = null,
135
136
  teammateMode = "tmux",
136
137
  injectPrompt = defaultInjectPrompt,
138
+ onInjectionFailure,
137
139
  } = opts;
140
+ const failures = [];
141
+
142
+ function recordInjectionFailure(target, cli, role, error) {
143
+ const failure = {
144
+ target,
145
+ cli: cli || "unknown",
146
+ role,
147
+ message: String(error?.message || error || "prompt submission failed"),
148
+ };
149
+ failures.push(failure);
150
+ try {
151
+ onInjectionFailure?.(failure);
152
+ } catch {
153
+ // reporting failure must not stop the remaining pane injections
154
+ }
155
+ }
138
156
 
139
157
  const workers = assignments.map(({ target, cli, subtask }) => ({
140
158
  target,
@@ -155,10 +173,14 @@ export async function orchestrate(sessionName, assignments, opts = {}) {
155
173
  subtask: w.subtask,
156
174
  })),
157
175
  });
158
- injectPrompt(lead.target, leadPrompt, {
159
- useFileRef: true,
160
- cli: lead.cli,
161
- });
176
+ try {
177
+ injectPrompt(lead.target, leadPrompt, {
178
+ useFileRef: true,
179
+ cli: lead.cli,
180
+ });
181
+ } catch (error) {
182
+ recordInjectionFailure(lead.target, lead.cli, "lead", error);
183
+ }
162
184
  await new Promise((r) => setTimeout(r, 100));
163
185
  }
164
186
 
@@ -169,10 +191,15 @@ export async function orchestrate(sessionName, assignments, opts = {}) {
169
191
  hubUrl,
170
192
  sessionName,
171
193
  });
172
- injectPrompt(worker.target, prompt, {
173
- useFileRef: true,
174
- cli: worker.cli,
175
- });
194
+ try {
195
+ injectPrompt(worker.target, prompt, {
196
+ useFileRef: true,
197
+ cli: worker.cli,
198
+ });
199
+ } catch (error) {
200
+ recordInjectionFailure(worker.target, worker.cli, "worker", error);
201
+ }
176
202
  await new Promise((r) => setTimeout(r, 100));
177
203
  }
204
+ return failures;
178
205
  }
package/hub/team/pane.mjs CHANGED
@@ -62,7 +62,7 @@ export function buildCliCommand(cli, options = {}) {
62
62
  if (trustMode) {
63
63
  return buildExecArgs({});
64
64
  }
65
- return "codex";
65
+ return "codex --dangerously-bypass-approvals-and-sandbox";
66
66
  case "gemini":
67
67
  // interactive 모드 — MCP는 ~/.gemini/settings.json에 사전 등록
68
68
  return "gemini";
@@ -105,6 +105,149 @@ export function shouldUseFileRef({ multiplexer, useFileRef, cli }) {
105
105
  return multiplexer === "psmux" && useFileRef && cli !== "codex";
106
106
  }
107
107
 
108
+ /** 동기 sleep — injectPrompt는 sync 경로라 setTimeout을 쓸 수 없다 */
109
+ function sleepMs(ms) {
110
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
111
+ }
112
+
113
+ // paste 직후 Enter가 TUI paste-burst 감지에 개행으로 흡수되는 것을 피하는 정착 지연
114
+ const PASTE_SETTLE_MS = 350;
115
+ // 제출 확인 재시도 상한
116
+ const SUBMIT_RETRY_LIMIT = 3;
117
+ // TUI 준비 대기 상한 — 배너/composer 렌더 완료 전 주입은 키 입력이 통째로 유실될 수 있음
118
+ const COMPOSER_READY_TIMEOUT_MS = 8000;
119
+
120
+ function capturePaneText(target) {
121
+ try {
122
+ if (detectMultiplexer() === "psmux") {
123
+ return String(
124
+ psmuxExec(["capture-pane", "-t", target, "-p"], { encoding: "utf8" }) ??
125
+ "",
126
+ );
127
+ }
128
+ return String(muxExec(`capture-pane -t ${target} -p`) ?? "");
129
+ } catch {
130
+ return "";
131
+ }
132
+ }
133
+
134
+ /**
135
+ * TUI가 그리기를 마칠 때까지 대기: pane 내용이 비어 있지 않고 연속 두 번의
136
+ * capture가 동일(정적)해질 때까지 폴링한다. CLI 종류와 무관한 준비 신호이며
137
+ * 시간 초과 시 그냥 진행한다 (기존 고정-대기 동작으로 degrade).
138
+ */
139
+ function waitForComposerReady(target) {
140
+ const deadline = Date.now() + COMPOSER_READY_TIMEOUT_MS;
141
+ let prev = null;
142
+ while (Date.now() < deadline) {
143
+ const text = capturePaneText(target);
144
+ if (text.trim() && prev !== null && text === prev) return;
145
+ prev = text;
146
+ sleepMs(400);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 주입한 프롬프트가 실제 제출됐는지 확인하고, 안 됐으면 Enter를 재전송한다.
152
+ *
153
+ * TUI는 연속 키 입력을 paste burst로 묶으므로 paste 직후의 Enter가 제출 대신
154
+ * composer 개행으로 흡수될 수 있다. transcript의 과거 prompt가 아니라 마지막
155
+ * composer line에서 현재 prompt가 사라졌을 때만 제출로 판정한다. Antigravity는
156
+ * capture 불가 또는 재시도 소진을 성공으로 숨기지 않고 오류로 반환한다.
157
+ */
158
+ function isAntigravityCli(cli) {
159
+ return ["agy", "antigravity", "gemini"].includes(
160
+ String(cli || "").toLowerCase(),
161
+ );
162
+ }
163
+
164
+ function promptComposerNeedle(prompt) {
165
+ const firstContentLine = String(prompt || "")
166
+ .split(/\r?\n/u)
167
+ .map((line) => line.trim())
168
+ .find(Boolean);
169
+ return (firstContentLine || "").slice(0, 16);
170
+ }
171
+
172
+ function isComposerPromptLine(line) {
173
+ return /^\s*(?:[│┃]\s*)?[›❯▶▸>]\s*/u.test(String(line || ""));
174
+ }
175
+
176
+ function composerPromptState(text, needle) {
177
+ const composerLines = String(text || "")
178
+ .split("\n")
179
+ .filter(isComposerPromptLine);
180
+ if (composerLines.length === 0) return "unknown";
181
+ return composerLines.at(-1).includes(needle) ? "present" : "absent";
182
+ }
183
+
184
+ function hasBusyMarkerOutsideComposer(text) {
185
+ return String(text || "")
186
+ .split("\n")
187
+ .some(
188
+ (line) =>
189
+ !isComposerPromptLine(line) &&
190
+ /esc to (?:interrupt|cancel)/iu.test(line),
191
+ );
192
+ }
193
+
194
+ /**
195
+ * paste된 prompt가 실제 제출될 때까지 Enter를 bounded retry한다.
196
+ * Antigravity는 capture로 성공을 확인하지 못하면 조용히 idle 상태로 남기지 않고
197
+ * caller에 명확한 오류를 반환한다.
198
+ */
199
+ export function submitPromptWithVerification({
200
+ cli,
201
+ prompt,
202
+ capture,
203
+ sendEnter,
204
+ sleep = sleepMs,
205
+ settleMs = PASTE_SETTLE_MS,
206
+ retryLimit = SUBMIT_RETRY_LIMIT,
207
+ } = {}) {
208
+ if (typeof capture !== "function" || typeof sendEnter !== "function") {
209
+ throw new TypeError("capture and sendEnter are required");
210
+ }
211
+
212
+ const strictVerification = isAntigravityCli(cli);
213
+ const needle = promptComposerNeedle(prompt);
214
+ sleep(settleMs);
215
+
216
+ if (!needle) {
217
+ sendEnter();
218
+ return;
219
+ }
220
+
221
+ for (let attempt = 1; attempt <= retryLimit; attempt++) {
222
+ sendEnter();
223
+ sleep(400 * attempt);
224
+ const text = String(capture() ?? "");
225
+ if (!text) {
226
+ if (strictVerification) continue;
227
+ return;
228
+ }
229
+ if (hasBusyMarkerOutsideComposer(text)) return;
230
+ const composerState = composerPromptState(text, needle);
231
+ if (composerState === "absent") return;
232
+ if (composerState === "unknown" && !strictVerification) return;
233
+ }
234
+
235
+ if (strictVerification) {
236
+ throw new Error(
237
+ `Antigravity prompt submission failed after ${retryLimit} attempts: prompt remains in composer or submission could not be verified`,
238
+ );
239
+ }
240
+ }
241
+
242
+ function confirmSubmit(target, prompt, cli, sendEnter) {
243
+ submitPromptWithVerification({
244
+ cli,
245
+ prompt,
246
+ capture: () => capturePaneText(target),
247
+ sendEnter,
248
+ });
249
+ }
250
+
108
251
  /**
109
252
  * pane에 프롬프트 주입
110
253
  * @param {string} target — 예: tfx-multi-abc:0.1
@@ -131,16 +274,29 @@ export function injectPrompt(
131
274
  if (shouldUseFileRef({ multiplexer, useFileRef, cli })) {
132
275
  writeFileSync(tmpFile, prompt, "utf8");
133
276
  const filePath = tmpFile.replace(/\\/g, "/");
134
- psmuxExec(["select-pane", "-t", target]);
135
- psmuxExec(["send-keys", "-t", target, "-l", `@${filePath}`]);
136
- psmuxExec(["send-keys", "-t", target, "Enter"]);
137
- // TUI가 파일을 읽을 시간을 주고 정리
138
- setTimeout(() => {
139
- try {
140
- unlinkSync(tmpFile);
141
- } catch {}
142
- }, 10000);
143
- return;
277
+ let cleanupScheduled = false;
278
+ try {
279
+ waitForComposerReady(target);
280
+ psmuxExec(["select-pane", "-t", target]);
281
+ psmuxExec(["send-keys", "-t", target, "-l", `@${filePath}`]);
282
+ confirmSubmit(target, `@${filePath}`, cli, () =>
283
+ psmuxExec(["send-keys", "-t", target, "Enter"]),
284
+ );
285
+ // TUI가 파일을 읽을 시간을 주고 정리
286
+ setTimeout(() => {
287
+ try {
288
+ unlinkSync(tmpFile);
289
+ } catch {}
290
+ }, 10000);
291
+ cleanupScheduled = true;
292
+ return;
293
+ } finally {
294
+ if (!cleanupScheduled) {
295
+ try {
296
+ unlinkSync(tmpFile);
297
+ } catch {}
298
+ }
299
+ }
144
300
  }
145
301
 
146
302
  try {
@@ -148,17 +304,23 @@ export function injectPrompt(
148
304
 
149
305
  if (detectMultiplexer() === "psmux") {
150
306
  const sessionName = getPsmuxSessionName(target);
307
+ waitForComposerReady(target);
151
308
  psmuxExec(["load-buffer", "-t", sessionName, toMuxPath(tmpFile)]);
152
309
  psmuxExec(["select-pane", "-t", target]);
153
310
  psmuxExec(["paste-buffer", "-t", target]);
154
- psmuxExec(["send-keys", "-t", target, "Enter"]);
311
+ confirmSubmit(target, prompt, cli, () =>
312
+ psmuxExec(["send-keys", "-t", target, "Enter"]),
313
+ );
155
314
  return;
156
315
  }
157
316
 
158
- // tmux load-buffer → paste-buffer → Enter
317
+ // tmux load-buffer → paste-buffer → (정착 지연 + 제출 확인) Enter
318
+ waitForComposerReady(target);
159
319
  muxExec(`load-buffer ${quoteArg(toMuxPath(tmpFile))}`);
160
320
  muxExec(`paste-buffer -t ${target}`);
161
- muxExec(`send-keys -t ${target} Enter`);
321
+ confirmSubmit(target, prompt, cli, () =>
322
+ muxExec(`send-keys -t ${target} Enter`),
323
+ );
162
324
  } finally {
163
325
  try {
164
326
  unlinkSync(tmpFile);
@@ -676,41 +676,32 @@ export function createPsmuxSession(sessionName, opts = {}) {
676
676
  newSessionArgs.push(PWSH_BIN, "-NoLogo", "-NoProfile");
677
677
  const leadPane = psmuxExec(newSessionArgs);
678
678
 
679
- // split-window로 생성되는 pane도 동일 셸 사용 (Windows: PowerShell 강제)
680
- if (PWSH_BIN && IS_WINDOWS) {
681
- try {
682
- psmuxExec([
683
- "set-option",
684
- "-t",
685
- sessionName,
686
- "default-command",
687
- `${PWSH_BIN} -NoLogo -NoProfile`,
688
- ]);
689
- } catch {
690
- /* 미지원 시 무시 */
679
+ try {
680
+ // split-window로 생성되는 pane도 동일 셸 사용 (Windows: PowerShell 강제)
681
+ if (PWSH_BIN && IS_WINDOWS) {
682
+ try {
683
+ psmuxExec([
684
+ "set-option",
685
+ "-t",
686
+ sessionName,
687
+ "default-command",
688
+ `${PWSH_BIN} -NoLogo -NoProfile`,
689
+ ]);
690
+ } catch {
691
+ /* 미지원 시 무시 */
692
+ }
691
693
  }
692
- }
693
694
 
694
- if (layout === "2x2" && limitedPaneCount >= 3) {
695
- const rightPane = psmuxExec([
696
- "split-window",
697
- "-h",
698
- "-P",
699
- "-F",
700
- "#{session_name}:#{window_index}.#{pane_index}",
701
- "-t",
702
- leadPane,
703
- ]);
704
- psmuxExec([
705
- "split-window",
706
- "-v",
707
- "-P",
708
- "-F",
709
- "#{session_name}:#{window_index}.#{pane_index}",
710
- "-t",
711
- rightPane,
712
- ]);
713
- if (limitedPaneCount >= 4) {
695
+ if (layout === "2x2" && limitedPaneCount >= 3) {
696
+ const rightPane = psmuxExec([
697
+ "split-window",
698
+ "-h",
699
+ "-P",
700
+ "-F",
701
+ "#{session_name}:#{window_index}.#{pane_index}",
702
+ "-t",
703
+ leadPane,
704
+ ]);
714
705
  psmuxExec([
715
706
  "split-window",
716
707
  "-v",
@@ -718,51 +709,69 @@ export function createPsmuxSession(sessionName, opts = {}) {
718
709
  "-F",
719
710
  "#{session_name}:#{window_index}.#{pane_index}",
720
711
  "-t",
721
- leadPane,
712
+ rightPane,
722
713
  ]);
714
+ if (limitedPaneCount >= 4) {
715
+ psmuxExec([
716
+ "split-window",
717
+ "-v",
718
+ "-P",
719
+ "-F",
720
+ "#{session_name}:#{window_index}.#{pane_index}",
721
+ "-t",
722
+ leadPane,
723
+ ]);
724
+ }
725
+ psmuxExec(["select-layout", "-t", sessionTarget, "tiled"]);
726
+ } else if (layout === "1xN") {
727
+ for (let index = 1; index < limitedPaneCount; index += 1) {
728
+ psmuxExec(["split-window", "-h", "-t", sessionTarget]);
729
+ }
730
+ psmuxExec(["select-layout", "-t", sessionTarget, "even-horizontal"]);
731
+ } else {
732
+ for (let index = 1; index < limitedPaneCount; index += 1) {
733
+ psmuxExec(["split-window", "-v", "-t", sessionTarget]);
734
+ }
735
+ psmuxExec(["select-layout", "-t", sessionTarget, "even-vertical"]);
723
736
  }
724
- psmuxExec(["select-layout", "-t", sessionTarget, "tiled"]);
725
- } else if (layout === "1xN") {
726
- for (let index = 1; index < limitedPaneCount; index += 1) {
727
- psmuxExec(["split-window", "-h", "-t", sessionTarget]);
728
- }
729
- psmuxExec(["select-layout", "-t", sessionTarget, "even-horizontal"]);
730
- } else {
731
- for (let index = 1; index < limitedPaneCount; index += 1) {
732
- psmuxExec(["split-window", "-v", "-t", sessionTarget]);
733
- }
734
- psmuxExec(["select-layout", "-t", sessionTarget, "even-vertical"]);
735
- }
736
737
 
737
- psmuxExec(["select-pane", "-t", leadPane]);
738
+ psmuxExec(["select-pane", "-t", leadPane]);
738
739
 
739
- const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
740
- panes.forEach((pane, index) => {
740
+ const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
741
+ panes.forEach((pane, index) => {
742
+ try {
743
+ psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
744
+ } catch {
745
+ // tmux 2.6 미만: select-pane -T 미지원 — pane title 없이 계속 진행
746
+ }
747
+ });
748
+
749
+ // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout 깨짐 방지:
750
+ // pane 생성 직후 UTF-8 인코딩을 강제 설정한다.
751
+ // chcp 65001: 프로세스 코드 페이지를 UTF-8로 전환
752
+ // OutputEncoding: 네이티브 명령(gemini/codex) stdout 디코딩
753
+ // $OutputEncoding: PowerShell 파이프라인 기본 인코딩
754
+ if (IS_WINDOWS) {
755
+ const encodingInit = [
756
+ "chcp 65001 > $null",
757
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
758
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
759
+ "$OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
760
+ ].join("; ");
761
+ panes.forEach((paneId) => {
762
+ sendLiteralToPane(paneId, encodingInit, true);
763
+ });
764
+ }
765
+
766
+ return { sessionName, panes };
767
+ } catch (error) {
741
768
  try {
742
- psmuxExec(["select-pane", "-t", pane, "-T", toPaneTitle(index)]);
769
+ killPsmuxSession(sessionName);
743
770
  } catch {
744
- // tmux 2.6 미만: select-pane -T 미지원 — pane title 없이 계속 진행
771
+ /* rollback is best-effort; preserve the initialization error */
745
772
  }
746
- });
747
-
748
- // CP949 등 non-UTF-8 codepage 환경에서 CLI stdout 깨짐 방지:
749
- // pane 생성 직후 UTF-8 인코딩을 강제 설정한다.
750
- // chcp 65001: 프로세스 코드 페이지를 UTF-8로 전환
751
- // OutputEncoding: 네이티브 명령(gemini/codex) stdout 디코딩
752
- // $OutputEncoding: PowerShell 파이프라인 기본 인코딩
753
- if (IS_WINDOWS) {
754
- const encodingInit = [
755
- "chcp 65001 > $null",
756
- "[Console]::InputEncoding = [System.Text.Encoding]::UTF8",
757
- "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
758
- "$OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
759
- ].join("; ");
760
- panes.forEach((paneId) => {
761
- sendLiteralToPane(paneId, encodingInit, true);
762
- });
773
+ throw error;
763
774
  }
764
-
765
- return { sessionName, panes };
766
775
  }
767
776
 
768
777
  /**
@@ -23,6 +23,11 @@ import {
23
23
  } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
 
26
+ import {
27
+ canonicalCodexProfileModel,
28
+ normalizeCodexProfileName,
29
+ } from "../../scripts/lib/codex-profile-config.mjs";
30
+
26
31
  export const STATES = Object.freeze({
27
32
  PLANNING: "PLANNING",
28
33
  EXECUTING: "EXECUTING",
@@ -39,21 +44,29 @@ export const MODES = Object.freeze({
39
44
  });
40
45
 
41
46
  const ESCALATION_CHAIN_CONFIG_PATH = ".triflux/config/escalation-chain.json";
47
+ const DEFAULT_CLAUDE_MODEL = "fable";
42
48
 
43
- // Escalation chain (2026-05-27 정책):
44
- // 1. codex gpt-5.6-sol max — 최난도 단일 작업용 Codex escalation 단계
45
- // 2. claude opus-4-8 — 최종 수단
49
+ // Escalation chain (2026-09-22 정책):
50
+ // 1. codex gpt-6-astra max: 최난도 단일 작업용 Codex escalation 단계
51
+ // 2. claude fable alias: Claude CLI가 최신 Fable tier로 해석하는 최종 수단
46
52
  const DEFAULT_ESCALATION_CHAIN = Object.freeze([
47
53
  Object.freeze({
48
54
  cli: "codex",
49
- model: "gpt-5.6-sol",
50
- profile: "gpt56_sol_max",
55
+ model: "gpt-6-astra",
56
+ profile: "gpt6_astra_max",
51
57
  }),
52
- Object.freeze({ cli: "claude", model: "opus-4-8" }),
58
+ Object.freeze({ cli: "claude", model: DEFAULT_CLAUDE_MODEL }),
53
59
  ]);
54
60
 
55
61
  const STUCK_THRESHOLD = 3;
56
62
 
63
+ function normalizeEscalationProfile(profile) {
64
+ const normalized = normalizeCodexProfileName(profile);
65
+ return normalized === "ultra" || normalized === "gpt6_astra_ultra"
66
+ ? "gpt6_astra_max"
67
+ : normalized;
68
+ }
69
+
57
70
  export function createRetryStateMachine(options = {}) {
58
71
  const mode = options.mode || MODES.BOUNDED;
59
72
  const cliChain = resolveEscalationChain(options);
@@ -234,15 +247,25 @@ function resolveEscalationChain(options) {
234
247
  }
235
248
 
236
249
  const projectRoot = options.projectRoot || process.cwd();
237
- return (
238
- loadEscalationChainOverride(projectRoot) ||
239
- normalizeEscalationChain(
240
- DEFAULT_ESCALATION_CHAIN,
241
- "DEFAULT_ESCALATION_CHAIN",
242
- )
250
+ const projectOverride = loadEscalationChainOverride(projectRoot);
251
+ if (projectOverride) return projectOverride;
252
+
253
+ return normalizeEscalationChain(
254
+ resolveDefaultEscalationChain(options.env || process.env),
255
+ "DEFAULT_ESCALATION_CHAIN",
243
256
  );
244
257
  }
245
258
 
259
+ function resolveDefaultEscalationChain(env) {
260
+ const configuredModel = env?.TFX_ESCALATION_CLAUDE_MODEL?.trim();
261
+ if (!configuredModel) return DEFAULT_ESCALATION_CHAIN;
262
+
263
+ return [
264
+ DEFAULT_ESCALATION_CHAIN[0],
265
+ { cli: "claude", model: configuredModel },
266
+ ];
267
+ }
268
+
246
269
  export function loadEscalationChainOverride(projectRoot = process.cwd()) {
247
270
  const configPath = join(projectRoot, ESCALATION_CHAIN_CONFIG_PATH);
248
271
  if (!existsSync(configPath)) return null;
@@ -297,10 +320,12 @@ function normalizeEscalationChainEntry(entry, index, source) {
297
320
  if (typeof entry.profile === "string" && entry.profile.trim() !== "") {
298
321
  const profile = entry.profile.trim();
299
322
  normalized.profile =
300
- entry.cli === "codex" &&
301
- (profile === "ultra" || profile === "gpt56_sol_ultra")
302
- ? "gpt56_sol_max"
303
- : profile;
323
+ entry.cli === "codex" ? normalizeEscalationProfile(profile) : profile;
324
+ // A remapped legacy profile must not keep reporting its old model.
325
+ if (normalized.profile !== profile) {
326
+ normalized.model =
327
+ canonicalCodexProfileModel(normalized.profile) ?? normalized.model;
328
+ }
304
329
  }
305
330
  return normalized;
306
331
  }
@@ -333,6 +358,12 @@ export function loadSnapshot(snapshotFile) {
333
358
  `unsupported snapshot version: ${parsed.version} (expected 1)`,
334
359
  );
335
360
  }
361
+ if (Array.isArray(parsed.cliChain) && parsed.cliChain.length > 0) {
362
+ return {
363
+ ...parsed,
364
+ cliChain: normalizeEscalationChain(parsed.cliChain, "snapshot.cliChain"),
365
+ };
366
+ }
336
367
  return parsed;
337
368
  }
338
369