@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.
- package/hub/pipe.mjs +10 -15
- package/hub/schema.sql +1 -0
- package/hub/server.mjs +172 -93
- package/hub/store.mjs +127 -61
- package/hub/team/backend.mjs +4 -2
- package/hub/team/claude-daemon-control.mjs +2 -0
- package/hub/team/cli/commands/start/start-headless.mjs +2 -0
- package/hub/team/cli/commands/start/start-mux.mjs +24 -3
- package/hub/team/cli/services/runtime-mode.mjs +23 -9
- package/hub/team/conductor.mjs +80 -24
- package/hub/team/cto-auto-collect.mjs +2 -6
- package/hub/team/cto-pull.mjs +4 -0
- package/hub/team/dashboard-open.mjs +1 -0
- package/hub/team/execution-mode.mjs +38 -2
- package/hub/team/headless.mjs +493 -82
- package/hub/team/interactive-tui-transport.mjs +15 -1
- package/hub/team/launcher-template.mjs +17 -1
- package/hub/team/orchestrator.mjs +36 -9
- package/hub/team/pane.mjs +176 -14
- package/hub/team/psmux.mjs +79 -70
- package/hub/team/retry-state-machine.mjs +47 -16
- package/hub/team/session.mjs +57 -0
- package/hub/team/synapse-http.mjs +149 -4
- package/hub/team/terminal-opener.mjs +68 -13
- package/hub/team/tui-viewer.mjs +65 -14
- package/hub/team/uds-orchestrator.mjs +384 -83
- package/hub/tools.mjs +10 -1
- package/hub/workers/codex-mcp.mjs +9 -4
- package/hub/workers/delegator-mcp.mjs +1 -1
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +99 -38
- package/scripts/lib/cli-agy.mjs +24 -4
- package/scripts/lib/cli-codex.mjs +2 -0
- package/scripts/lib/codex-profile-config.mjs +45 -13
- package/scripts/lib/codex-recovery.sh +19 -0
- package/scripts/lib/gemini-profiles.mjs +113 -29
- package/scripts/lib/keyword-rules.mjs +12 -0
- package/scripts/lib/logger.mjs +60 -43
- package/scripts/lib/machine-profile.mjs +293 -0
- 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([
|
|
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 = `
|
|
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
|
-
* @
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
+
confirmSubmit(target, prompt, cli, () =>
|
|
322
|
+
muxExec(`send-keys -t ${target} Enter`),
|
|
323
|
+
);
|
|
162
324
|
} finally {
|
|
163
325
|
try {
|
|
164
326
|
unlinkSync(tmpFile);
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
+
psmuxExec(["select-pane", "-t", leadPane]);
|
|
738
739
|
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
769
|
+
killPsmuxSession(sessionName);
|
|
743
770
|
} catch {
|
|
744
|
-
|
|
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-
|
|
44
|
-
// 1. codex gpt-
|
|
45
|
-
// 2. claude
|
|
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-
|
|
50
|
-
profile: "
|
|
55
|
+
model: "gpt-6-astra",
|
|
56
|
+
profile: "gpt6_astra_max",
|
|
51
57
|
}),
|
|
52
|
-
Object.freeze({ cli: "claude", model:
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|