@triflux/remote 10.43.0 → 10.45.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 (48) hide show
  1. package/cto/hygiene.mjs +1 -1
  2. package/hub/lib/cto-env.mjs +1 -0
  3. package/hub/pipe.mjs +10 -9
  4. package/hub/schema.sql +62 -2
  5. package/hub/server.mjs +276 -132
  6. package/hub/store.mjs +699 -41
  7. package/hub/team/backend.mjs +4 -2
  8. package/hub/team/claude-daemon-control.mjs +2 -0
  9. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  10. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  11. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  12. package/hub/team/conductor.mjs +101 -24
  13. package/hub/team/cto-auto-collect.mjs +2 -6
  14. package/hub/team/cto-pull.mjs +4 -0
  15. package/hub/team/dashboard-open.mjs +1 -0
  16. package/hub/team/execution-mode.mjs +50 -9
  17. package/hub/team/headless.mjs +539 -92
  18. package/hub/team/interactive-tui-transport.mjs +15 -1
  19. package/hub/team/intervention.mjs +1 -1
  20. package/hub/team/launcher-template.mjs +17 -1
  21. package/hub/team/notify.mjs +31 -11
  22. package/hub/team/orchestrator.mjs +36 -9
  23. package/hub/team/pane.mjs +176 -14
  24. package/hub/team/psmux.mjs +79 -70
  25. package/hub/team/retry-state-machine.mjs +41 -13
  26. package/hub/team/session.mjs +57 -0
  27. package/hub/team/swarm-hypervisor.mjs +4 -2
  28. package/hub/team/swarm-preflight.mjs +47 -0
  29. package/hub/team/synapse-http.mjs +149 -4
  30. package/hub/team/terminal-opener.mjs +68 -13
  31. package/hub/team/tui-viewer.mjs +65 -14
  32. package/hub/team/worktree-lifecycle.mjs +56 -59
  33. package/hub/tools.mjs +22 -1
  34. package/hub/workers/codex-mcp.mjs +26 -5
  35. package/hub/workers/delegator-mcp.mjs +3 -49
  36. package/package.json +1 -1
  37. package/scripts/lib/agent-route-policy.mjs +367 -0
  38. package/scripts/lib/cli-agy.mjs +24 -4
  39. package/scripts/lib/cli-codex.mjs +22 -160
  40. package/scripts/lib/codex-profile-config.mjs +48 -4
  41. package/scripts/lib/codex-recovery.sh +19 -0
  42. package/scripts/lib/gemini-profiles.mjs +113 -29
  43. package/scripts/lib/keyword-rules.mjs +24 -0
  44. package/scripts/lib/logger.mjs +60 -43
  45. package/scripts/lib/machine-profile.mjs +293 -0
  46. package/scripts/lib/mcp-health.mjs +44 -18
  47. package/cto/steward.mjs +0 -362
  48. package/hub/log-retention.mjs +0 -726
@@ -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,11 +5,11 @@ import { statSync } from "node:fs";
5
5
  import { readdir, readlink } from "node:fs/promises";
6
6
  import { promisify } from "node:util";
7
7
  import { isPidAlive } from "@triflux/core/hub/lib/process-utils.mjs";
8
- import { killProcess } from "@triflux/core/hub/platform.mjs";
9
8
  import {
10
9
  resolveHardCeilingMs,
11
10
  resolveStallInterventionMs as resolveStallThresholdMs,
12
11
  } from "@triflux/core/hub/lib/timeout-defaults.mjs";
12
+ import { killProcess } from "@triflux/core/hub/platform.mjs";
13
13
 
14
14
  export { resolveHardCeilingMs, resolveStallThresholdMs };
15
15
 
@@ -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({
@@ -54,12 +54,17 @@ function normalizeEvent(event, defaults = {}) {
54
54
  });
55
55
  }
56
56
 
57
- function defaultChannelConfig(name, env) {
57
+ function defaultChannelConfig(name, env, platform = process.platform) {
58
58
  switch (name) {
59
59
  case "bell":
60
- return { enabled: true };
60
+ return { enabled: envFlag(env?.TRIFLUX_NOTIFY_BELL, true) };
61
61
  case "toast":
62
- return { enabled: true };
62
+ return {
63
+ // macOS `osascript display notification` can surface Script Editor
64
+ // authorization/UI prompts. Keep it opt-in there; Windows keeps the
65
+ // historical toast default, and other platforms skip as unsupported.
66
+ enabled: envFlag(env?.TRIFLUX_NOTIFY_TOAST, platform !== "darwin"),
67
+ };
63
68
  case "webhook": {
64
69
  const url = String(env?.TRIFLUX_NOTIFY_WEBHOOK || "");
65
70
  return { enabled: Boolean(url), url };
@@ -69,8 +74,13 @@ function defaultChannelConfig(name, env) {
69
74
  }
70
75
  }
71
76
 
72
- function normalizeChannelConfig(name, value, env) {
73
- const base = defaultChannelConfig(name, env);
77
+ function envFlag(value, fallback) {
78
+ if (value === undefined || value === null || value === "") return fallback;
79
+ return !["0", "false", "no", "off"].includes(String(value).toLowerCase());
80
+ }
81
+
82
+ function normalizeChannelConfig(name, value, env, platform) {
83
+ const base = defaultChannelConfig(name, env, platform);
74
84
  const patch =
75
85
  typeof value === "boolean"
76
86
  ? { enabled: value }
@@ -103,16 +113,21 @@ function normalizeChannelConfig(name, value, env) {
103
113
  return freezeRecord(next);
104
114
  }
105
115
 
106
- function normalizeChannels(channels, env) {
116
+ function normalizeChannels(channels, env, platform) {
107
117
  const source = channels && typeof channels === "object" ? channels : {};
108
118
  const normalized = {};
109
119
  for (const name of NOTIFY_CHANNELS) {
110
- normalized[name] = normalizeChannelConfig(name, source[name], env);
120
+ normalized[name] = normalizeChannelConfig(
121
+ name,
122
+ source[name],
123
+ env,
124
+ platform,
125
+ );
111
126
  }
112
127
  return Object.freeze(normalized);
113
128
  }
114
129
 
115
- function updateChannelConfig(channels, channel, config, env) {
130
+ function updateChannelConfig(channels, channel, config, env, platform) {
116
131
  if (!NOTIFY_CHANNELS.includes(channel)) {
117
132
  throw new TypeError(`Unknown notify channel: ${channel}`);
118
133
  }
@@ -126,6 +141,7 @@ function updateChannelConfig(channels, channel, config, env) {
126
141
  ...(typeof config === "boolean" ? { enabled: config } : config || {}),
127
142
  },
128
143
  env,
144
+ platform,
129
145
  ),
130
146
  });
131
147
  }
@@ -322,7 +338,7 @@ function createNotifierInstance(channels, deps) {
322
338
 
323
339
  function setChannel(channel, config) {
324
340
  return createNotifierInstance(
325
- updateChannelConfig(channels, channel, config, deps.env),
341
+ updateChannelConfig(channels, channel, config, deps.env, deps.platform),
326
342
  deps,
327
343
  );
328
344
  }
@@ -349,17 +365,21 @@ function createNotifierInstance(channels, deps) {
349
365
  */
350
366
  export function createNotifier(opts = {}) {
351
367
  const env = opts.env || process.env;
368
+ const platform = opts.platform || process.platform;
352
369
  const deps = Object.freeze({
353
370
  env,
354
371
  stdout: opts.stdout || process.stdout,
355
372
  execFile: opts.deps?.execFile || execFile,
356
373
  fetch: opts.deps?.fetch || globalThis.fetch?.bind(globalThis),
357
- platform: opts.platform || process.platform,
374
+ platform,
358
375
  hostname: opts.hostname || os.hostname(),
359
376
  powerShellCandidates: Object.freeze(
360
377
  opts.deps?.powerShellCandidates || ["pwsh", "powershell.exe"],
361
378
  ),
362
379
  });
363
380
 
364
- return createNotifierInstance(normalizeChannels(opts.channels, env), deps);
381
+ return createNotifierInstance(
382
+ normalizeChannels(opts.channels, env, platform),
383
+ deps,
384
+ );
365
385
  }
@@ -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
  /**