@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
@@ -23,6 +23,8 @@ import {
23
23
  } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
 
26
+ import { normalizeCodexProfileName } from "../../scripts/lib/codex-profile-config.mjs";
27
+
26
28
  export const STATES = Object.freeze({
27
29
  PLANNING: "PLANNING",
28
30
  EXECUTING: "EXECUTING",
@@ -39,21 +41,29 @@ export const MODES = Object.freeze({
39
41
  });
40
42
 
41
43
  const ESCALATION_CHAIN_CONFIG_PATH = ".triflux/config/escalation-chain.json";
44
+ const DEFAULT_CLAUDE_MODEL = "fable";
42
45
 
43
- // Escalation chain (2026-05-27 정책):
44
- // 1. codex gpt-5.6-sol — 기존 최상위 Codex escalation 단계
45
- // 2. claude opus-4-8 — 최종 수단
46
+ // Escalation chain (2026-09-22 정책):
47
+ // 1. codex gpt-6-astra max: 최난도 단일 작업용 Codex escalation 단계
48
+ // 2. claude fable alias: Claude CLI가 최신 Fable tier로 해석하는 최종 수단
46
49
  const DEFAULT_ESCALATION_CHAIN = Object.freeze([
47
50
  Object.freeze({
48
51
  cli: "codex",
49
- model: "gpt-5.6-sol",
50
- profile: "gpt56_sol_xhigh",
52
+ model: "gpt-6-astra",
53
+ profile: "gpt6_astra_max",
51
54
  }),
52
- Object.freeze({ cli: "claude", model: "opus-4-8" }),
55
+ Object.freeze({ cli: "claude", model: DEFAULT_CLAUDE_MODEL }),
53
56
  ]);
54
57
 
55
58
  const STUCK_THRESHOLD = 3;
56
59
 
60
+ function normalizeEscalationProfile(profile) {
61
+ const normalized = normalizeCodexProfileName(profile);
62
+ return normalized === "ultra" || normalized === "gpt6_astra_ultra"
63
+ ? "gpt6_astra_max"
64
+ : normalized;
65
+ }
66
+
57
67
  export function createRetryStateMachine(options = {}) {
58
68
  const mode = options.mode || MODES.BOUNDED;
59
69
  const cliChain = resolveEscalationChain(options);
@@ -234,15 +244,25 @@ function resolveEscalationChain(options) {
234
244
  }
235
245
 
236
246
  const projectRoot = options.projectRoot || process.cwd();
237
- return (
238
- loadEscalationChainOverride(projectRoot) ||
239
- normalizeEscalationChain(
240
- DEFAULT_ESCALATION_CHAIN,
241
- "DEFAULT_ESCALATION_CHAIN",
242
- )
247
+ const projectOverride = loadEscalationChainOverride(projectRoot);
248
+ if (projectOverride) return projectOverride;
249
+
250
+ return normalizeEscalationChain(
251
+ resolveDefaultEscalationChain(options.env || process.env),
252
+ "DEFAULT_ESCALATION_CHAIN",
243
253
  );
244
254
  }
245
255
 
256
+ function resolveDefaultEscalationChain(env) {
257
+ const configuredModel = env?.TFX_ESCALATION_CLAUDE_MODEL?.trim();
258
+ if (!configuredModel) return DEFAULT_ESCALATION_CHAIN;
259
+
260
+ return [
261
+ DEFAULT_ESCALATION_CHAIN[0],
262
+ { cli: "claude", model: configuredModel },
263
+ ];
264
+ }
265
+
246
266
  export function loadEscalationChainOverride(projectRoot = process.cwd()) {
247
267
  const configPath = join(projectRoot, ESCALATION_CHAIN_CONFIG_PATH);
248
268
  if (!existsSync(configPath)) return null;
@@ -295,7 +315,9 @@ function normalizeEscalationChainEntry(entry, index, source) {
295
315
  model: entry.model,
296
316
  };
297
317
  if (typeof entry.profile === "string" && entry.profile.trim() !== "") {
298
- normalized.profile = entry.profile;
318
+ const profile = entry.profile.trim();
319
+ normalized.profile =
320
+ entry.cli === "codex" ? normalizeEscalationProfile(profile) : profile;
299
321
  }
300
322
  return normalized;
301
323
  }
@@ -328,6 +350,12 @@ export function loadSnapshot(snapshotFile) {
328
350
  `unsupported snapshot version: ${parsed.version} (expected 1)`,
329
351
  );
330
352
  }
353
+ if (Array.isArray(parsed.cliChain) && parsed.cliChain.length > 0) {
354
+ return {
355
+ ...parsed,
356
+ cliChain: normalizeEscalationChain(parsed.cliChain, "snapshot.cliChain"),
357
+ };
358
+ }
331
359
  return parsed;
332
360
  }
333
361
 
@@ -475,6 +475,63 @@ export function attachSession(sessionName) {
475
475
  }
476
476
  }
477
477
 
478
+ /**
479
+ * non-TTY 환경용 attach fallback: 요청 프로세스가 명시적으로 넘긴 lead pane을
480
+ * 분할해 그 pane 안에서 대상 세션에 attach한다.
481
+ *
482
+ * 호출 프로세스가 pipe(non-TTY)로 실행돼 attachSession()이 불가능해도,
483
+ * tmux 서버에 보내는 split-window는 호출자 TTY가 필요 없다. 새 pane에는
484
+ * tmux가 실제 pty를 붙여주므로 그 안의 attach는 정상 동작한다.
485
+ * env -u TMUX는 nested-attach 보호 해제용이다.
486
+ *
487
+ * @param {string} sessionName attach할 대상 세션
488
+ * @param {string} targetPane 요청 lead의 `$TMUX_PANE` (`%N`)
489
+ * @param {string} tmuxEnv 요청 lead의 `$TMUX` (`socket,pid,index`)
490
+ * @returns {boolean} 분할 attach 성공 여부 (false면 호출자가 기존 생략 경로 유지)
491
+ */
492
+ export function buildSplitAttachArgs(sessionName, targetPane, tmuxEnv) {
493
+ const normalizedSessionName = String(sessionName || "");
494
+ if (!normalizedSessionName.trim()) {
495
+ throw new Error("session name is required");
496
+ }
497
+ if (!/^%\d+$/u.test(String(targetPane || ""))) {
498
+ throw new Error("requesting lead TMUX_PANE is required");
499
+ }
500
+ const socketPath = String(tmuxEnv || "").split(",", 1)[0];
501
+ if (!socketPath.trim()) {
502
+ throw new Error("requesting lead TMUX socket is required");
503
+ }
504
+ const quotedSocket = `'${socketPath.replace(/'/gu, "'\\''")}'`;
505
+ const quotedSessionName = `'${normalizedSessionName.replace(/'/gu, "'\\''")}'`;
506
+ return [
507
+ "split-window",
508
+ "-t",
509
+ targetPane,
510
+ "-v",
511
+ "-l",
512
+ "50%",
513
+ `env -u TMUX tmux -S ${quotedSocket} attach -t ${quotedSessionName}`,
514
+ ];
515
+ }
516
+
517
+ export function splitAttachToClient(sessionName, opts = {}) {
518
+ const deps = opts._deps || {};
519
+ const detectMux = deps.detectMultiplexer || detectMultiplexer;
520
+ if (detectMux() !== "tmux") return false;
521
+
522
+ let args;
523
+ try {
524
+ args = buildSplitAttachArgs(sessionName, opts.targetPane, opts.tmuxEnv);
525
+ } catch {
526
+ return false;
527
+ }
528
+
529
+ // 상하 분할: 좌우 분할은 절반 폭이 워커 pane 가독 하한(100cols) 미만인 경우가 흔함
530
+ const spawn = deps.spawnSync || spawnSync;
531
+ const split = spawn("tmux", args);
532
+ return (split.status ?? 1) === 0;
533
+ }
534
+
478
535
  /**
479
536
  * tmux 세션 존재 확인
480
537
  * @param {string} sessionName
@@ -20,8 +20,8 @@ import {
20
20
  isDynamicRoutingEnabled,
21
21
  } from "@triflux/core/hub/dynamic-routing-engine.mjs";
22
22
  import { cleanupShardProcesses } from "@triflux/core/hub/lib/process-utils.mjs";
23
- import { DEFAULT_SWARM_LOCK_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
24
23
  import { getHostConfig } from "@triflux/core/hub/lib/ssh-command.mjs";
24
+ import { DEFAULT_SWARM_LOCK_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
25
25
  import { buildWorkerPrompt } from "./build-worker-prompt.mjs";
26
26
  import { registerSwarmShard } from "./claude-native-bridge.mjs";
27
27
  import { createConductor, STATES } from "./conductor.mjs";
@@ -678,6 +678,7 @@ export function createSwarmHypervisor(opts) {
678
678
  const config = {
679
679
  id: buildSwarmSessionId(shard),
680
680
  agent: shard.agent,
681
+ role: shard.role,
681
682
  // #125: append Completion Protocol appendix so workers emit a
682
683
  // sentinel-framed JSON payload that conductor can reliably capture.
683
684
  prompt: buildWorkerPrompt(shard.prompt, {
@@ -1964,7 +1965,8 @@ export function createSwarmHypervisor(opts) {
1964
1965
  lockRenewTimer = setInterval(() => {
1965
1966
  if (!lockManager) return;
1966
1967
  for (const shardName of workers.keys()) {
1967
- if (!completedShards.has(shardName) && !failures.has(shardName)) lockManager.renew(shardName);
1968
+ if (!completedShards.has(shardName) && !failures.has(shardName))
1969
+ lockManager.renew(shardName);
1968
1970
  }
1969
1971
  }, DEFAULT_SWARM_LOCK_TTL_MS / 2);
1970
1972
  lockRenewTimer.unref?.();
@@ -1,6 +1,7 @@
1
1
  // hub/team/swarm-preflight.mjs — PRD swarm preflight report/gate (#188)
2
2
  // Predicts launch-time blockers before any worker session or worktree spawn.
3
3
 
4
+ import { execFileSync } from "node:child_process";
4
5
  import { resolve } from "node:path";
5
6
  import { readHosts, resolveHost } from "@triflux/core/hub/lib/hosts-compat.mjs";
6
7
  import { whichCommand } from "@triflux/core/hub/platform.mjs";
@@ -83,6 +84,44 @@ function checkSensitive(plan, sensitiveDeny) {
83
84
  return makeCheck("sensitiveDeny", true, { matches });
84
85
  }
85
86
 
87
+ function parseTrackedStatus(rawStatus) {
88
+ const files = [];
89
+ const records = String(rawStatus || "").split("\0");
90
+ for (let index = 0; index < records.length; index++) {
91
+ const record = records[index];
92
+ if (record.length < 4) continue;
93
+ const status = record.slice(0, 2);
94
+ files.push(normalizePath(record.slice(3)));
95
+ if (/[RC]/u.test(status) && records[index + 1]) {
96
+ files.push(normalizePath(records[++index]));
97
+ }
98
+ }
99
+ return unique(files);
100
+ }
101
+
102
+ function checkRootDirtyLease(plan, repoRoot, deps = {}) {
103
+ const gitStatus =
104
+ deps.gitStatus ||
105
+ ((cwd) =>
106
+ execFileSync(
107
+ "git",
108
+ ["status", "--porcelain=v1", "-z", "--untracked-files=no"],
109
+ { cwd, encoding: "utf8", windowsHide: true },
110
+ ));
111
+ const dirtyFiles = parseTrackedStatus(gitStatus(repoRoot));
112
+ const leasedFiles = unique(
113
+ [...plan.leaseMap.values()].flat().map(normalizePath),
114
+ );
115
+ const leaseSet = new Set(leasedFiles);
116
+ const overlappingFiles = dirtyFiles.filter((file) => leaseSet.has(file));
117
+
118
+ return makeCheck("rootDirtyLease", overlappingFiles.length === 0, {
119
+ dirtyFiles,
120
+ leasedFiles,
121
+ overlappingFiles,
122
+ });
123
+ }
124
+
86
125
  function checkHosts(plan, repoRoot) {
87
126
  const registry = readHosts(repoRoot);
88
127
  const missing = [];
@@ -216,6 +255,13 @@ function collectMessages(checks) {
216
255
  );
217
256
  }
218
257
 
258
+ const rootDirtyLease = checks.rootDirtyLease;
259
+ if (rootDirtyLease && !rootDirtyLease.ok) {
260
+ errors.push(
261
+ `rootDir tracked changes overlap shard leases: ${rootDirtyLease.overlappingFiles.join(", ")}`,
262
+ );
263
+ }
264
+
219
265
  return { errors: unique(errors), warnings: unique(warnings) };
220
266
  }
221
267
 
@@ -258,6 +304,7 @@ export function runSwarmPreflight(prdPath, opts = {}) {
258
304
  leases: makeCheck("leases", plan.conflicts.length === 0, {
259
305
  conflicts: [...plan.conflicts],
260
306
  }),
307
+ rootDirtyLease: checkRootDirtyLease(plan, repoRoot, opts.deps),
261
308
  sensitiveDeny: checkSensitive(plan, sensitiveDeny),
262
309
  hosts: checkHosts(plan, repoRoot),
263
310
  workerCli: checkLocalWorkerClis(plan, opts.deps),
@@ -1,6 +1,9 @@
1
- import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir, hostname } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { normalizeRoleKey } from "@triflux/core/hub/role-contract.mjs";
4
7
 
5
8
  const HUB_DEFAULT_PORT = 27888;
6
9
  // Same token file as hub/bridge.mjs (HUB_TOKEN_FILE). Kept inline rather than
@@ -21,6 +24,144 @@ function normalizeToken(raw) {
21
24
  return token || null;
22
25
  }
23
26
 
27
+ function opaqueId(prefix, value) {
28
+ return `${prefix}_${createHash("sha256")
29
+ .update(String(value || ""))
30
+ .digest("base64url")
31
+ .slice(0, 22)}`;
32
+ }
33
+
34
+ function normalizedProjectId(value) {
35
+ try {
36
+ return normalizeRoleKey({ project_id: value, role_kind: "cto" }).project_id;
37
+ } catch {
38
+ return "";
39
+ }
40
+ }
41
+
42
+ function resolveProjectId(cwd, meta, opts = {}) {
43
+ const explicit = meta?.project_id || process.env.TFX_PROJECT_ID;
44
+ if (explicit) return normalizedProjectId(explicit);
45
+ if (typeof opts.resolveProjectId === "function") {
46
+ return normalizedProjectId(opts.resolveProjectId(cwd));
47
+ }
48
+ let current = cwd;
49
+ while (current) {
50
+ const manifest = join(current, ".triflux", "project.json");
51
+ if (existsSync(manifest)) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
54
+ if (parsed?.schema_version !== "tfx.project.v1") return "";
55
+ return normalizedProjectId(parsed.project_id);
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ const parent = dirname(current);
61
+ if (parent === current) break;
62
+ current = parent;
63
+ }
64
+ return "";
65
+ }
66
+
67
+ function inferSessionCli(meta, opts = {}) {
68
+ const explicit = String(meta?.cli || meta?.actor_cli || "")
69
+ .trim()
70
+ .toLowerCase();
71
+ if (explicit === "codex" || explicit === "claude") return explicit;
72
+ const entrypoint = String(opts.entrypoint ?? process.argv[1] ?? "");
73
+ if (entrypoint.includes("codex-session-hook")) return "codex";
74
+ if (entrypoint.includes("agy-session-hook")) return "other";
75
+ return "claude";
76
+ }
77
+
78
+ function resolveTmuxSession(meta, opts = {}) {
79
+ const explicit =
80
+ meta?.tmux_session || meta?.session_name || process.env.TFX_TMUX_SESSION;
81
+ if (explicit) return String(explicit).trim();
82
+ if (!process.env.TMUX) return "";
83
+ try {
84
+ const run =
85
+ opts.tmuxSessionName ||
86
+ (() =>
87
+ execFileSync("tmux", ["display-message", "-p", "#{session_name}"], {
88
+ encoding: "utf8",
89
+ timeout: 200,
90
+ windowsHide: true,
91
+ }));
92
+ return String(run() || "").trim();
93
+ } catch {
94
+ return "";
95
+ }
96
+ }
97
+
98
+ export function buildSynapseRegistrationMeta(meta, opts = {}) {
99
+ if (!meta || meta.sessionKind !== "interactive") return meta;
100
+ const sessionId = String(meta.sessionId || "").trim();
101
+ if (!sessionId) return meta;
102
+ const cwd = typeof meta.cwd === "string" ? meta.cwd : process.cwd();
103
+ const cli = inferSessionCli(meta, opts);
104
+ const hostId = String(
105
+ meta.host_id ||
106
+ process.env.TFX_HOST_ID ||
107
+ opaqueId("hst", opts.hostname?.() || hostname()),
108
+ ).trim();
109
+ const expiresAtMs = Number(opts.nowMs?.() ?? Date.now()) + 299000;
110
+ const transportLocators = [];
111
+ if (cli === "claude") {
112
+ transportLocators.push({
113
+ schema_version: "tfx.transport-locator.v1",
114
+ transport_id: opaqueId("trn", `claude-uds:${hostId}:${sessionId}`),
115
+ kind: "claude-uds",
116
+ host_id: hostId,
117
+ capabilities: ["probe", "wake", "activate", "interrupt"],
118
+ priority: 100,
119
+ expires_at_ms: expiresAtMs,
120
+ locator: {
121
+ session_id: sessionId,
122
+ bridge_id: String(
123
+ meta.bridge_id || process.env.TFX_BRIDGE_ID || "local-hub",
124
+ ),
125
+ },
126
+ });
127
+ }
128
+ const tmuxSession = resolveTmuxSession(meta, opts);
129
+ if (tmuxSession) {
130
+ const muxServerSource =
131
+ meta.mux_server_id ||
132
+ process.env.TFX_MUX_SERVER_ID ||
133
+ String(process.env.TMUX || "default").split(",", 1)[0];
134
+ transportLocators.push({
135
+ schema_version: "tfx.transport-locator.v1",
136
+ transport_id: opaqueId("trn", `tmux:${hostId}:${tmuxSession}`),
137
+ kind: "tmux",
138
+ host_id: hostId,
139
+ capabilities: ["probe", "wake", "activate", "interrupt"],
140
+ priority: cli === "codex" ? 100 : 50,
141
+ expires_at_ms: expiresAtMs,
142
+ locator: {
143
+ session_name: tmuxSession,
144
+ mux_server_id: opaqueId("mux", muxServerSource),
145
+ },
146
+ });
147
+ }
148
+ const directAgentId = `session:${sessionId}`;
149
+ return {
150
+ ...meta,
151
+ agent_id:
152
+ meta.agent_id ||
153
+ (directAgentId.length <= 64 && /^[a-zA-Z0-9._:-]+$/u.test(directAgentId)
154
+ ? directAgentId
155
+ : opaqueId("session", sessionId)),
156
+ cli,
157
+ project_id: resolveProjectId(cwd, meta, opts) || undefined,
158
+ session_id: meta.session_id || sessionId,
159
+ host_id: hostId,
160
+ transport_locators_json:
161
+ meta.transport_locators_json ?? JSON.stringify(transportLocators),
162
+ };
163
+ }
164
+
24
165
  // Mirrors hub/bridge.mjs:readHubToken — env override first, then the token file.
25
166
  // Missing token → null (backward-compatible with token-less hubs).
26
167
  function readHubToken() {
@@ -139,7 +280,11 @@ export function drainPendingSynapse(timeoutMs = 1000) {
139
280
  }
140
281
 
141
282
  export function registerSynapseSession(meta, opts = {}) {
142
- return fireAndForgetSynapse("/synapse/register", meta, opts);
283
+ return fireAndForgetSynapse(
284
+ "/synapse/register",
285
+ buildSynapseRegistrationMeta(meta, opts),
286
+ opts,
287
+ );
143
288
  }
144
289
 
145
290
  export function heartbeatSynapseSession(
@@ -111,6 +111,35 @@ function wtResultSucceeded(result) {
111
111
  return result?.success !== false;
112
112
  }
113
113
 
114
+ function resolveLeadPane(value) {
115
+ const pane = String(value || "");
116
+ return /^%\d+$/u.test(pane) ? pane : null;
117
+ }
118
+
119
+ function resolveLeadWindow(tmuxExec, targetPane) {
120
+ try {
121
+ const targetWindow = String(
122
+ tmuxExec(
123
+ `display-message -p -t ${targetPane} '#{session_name}:#{window_index}'`,
124
+ ) ?? "",
125
+ ).trim();
126
+ return targetWindow || null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function resolveLeadSocket(tmuxExec, targetPane) {
133
+ try {
134
+ const socketPath = String(
135
+ tmuxExec(`display-message -p -t ${targetPane} '#{socket_path}'`) ?? "",
136
+ ).trim();
137
+ return socketPath || null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
114
143
  export function createTerminalOpener(deps = {}) {
115
144
  const platform = resolvePlatform(deps);
116
145
  const tmuxExec = deps.tmuxExec || defaultTmuxExec;
@@ -134,12 +163,22 @@ export function createTerminalOpener(deps = {}) {
134
163
 
135
164
  const mux = resolveMux(deps);
136
165
  if (isTmuxLikeMux(mux, platform)) {
137
- tmuxExec(
138
- `new-window -n ${shellQuote(title)} ${shellQuote(
139
- buildCommandString(spec),
140
- )}`,
141
- );
142
- return true;
166
+ const targetPane = resolveLeadPane(spec.targetPane);
167
+ if (!targetPane) return false;
168
+ const targetWindow = resolveLeadWindow(tmuxExec, targetPane);
169
+ if (!targetWindow) return false;
170
+ try {
171
+ // tmux new-window는 pane target(%N)을 거부하므로, 요청 pane에서 해석한
172
+ // 정확한 session:window 뒤에 새 window를 삽입한다.
173
+ tmuxExec(
174
+ `new-window -a -t ${shellQuote(targetWindow)} -n ${shellQuote(
175
+ title,
176
+ )} ${shellQuote(buildCommandString(spec))}`,
177
+ );
178
+ return true;
179
+ } catch {
180
+ return false;
181
+ }
143
182
  }
144
183
 
145
184
  if (platform === "darwin") {
@@ -174,14 +213,30 @@ export function createTerminalOpener(deps = {}) {
174
213
 
175
214
  const mux = resolveMux(deps);
176
215
  if (isTmuxLikeMux(mux, platform)) {
177
- const attachCommand = buildAttachCommand(mux, sessionName, deps);
216
+ const targetPane = resolveLeadPane(opts.targetPane);
217
+ if (!targetPane) return false;
218
+ const socketPath =
219
+ mux === "psmux" ? null : resolveLeadSocket(tmuxExec, targetPane);
220
+ if (mux !== "psmux" && !socketPath) return false;
221
+ const attachCommand =
222
+ mux === "psmux"
223
+ ? buildAttachCommand(mux, sessionName, deps)
224
+ : `env -u TMUX tmux -S ${shellQuote(
225
+ socketPath,
226
+ )} attach-session -t ${shellQuote(sessionName)}`;
178
227
  if (!attachCommand) return false;
179
- tmuxExec(
180
- `new-window -n ${shellQuote(opts.title ?? sessionName)} ${shellQuote(
181
- attachCommand,
182
- )}`,
183
- );
184
- return true;
228
+ const targetWindow = resolveLeadWindow(tmuxExec, targetPane);
229
+ if (!targetWindow) return false;
230
+ try {
231
+ tmuxExec(
232
+ `new-window -a -t ${shellQuote(targetWindow)} -n ${shellQuote(
233
+ opts.title ?? sessionName,
234
+ )} ${shellQuote(attachCommand)}`,
235
+ );
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
185
240
  }
186
241
 
187
242
  return false;
@@ -11,6 +11,7 @@ import { join } from "node:path";
11
11
  import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
12
12
  import { openHeadlessDashboardTarget } from "./dashboard-open.mjs";
13
13
  import { processHandoff } from "./handoff.mjs";
14
+ import { getMultiplexerType } from "./psmux.mjs";
14
15
  import { createLogDashboard } from "./tui.mjs";
15
16
  import { createLiteDashboard } from "./tui-lite.mjs";
16
17
 
@@ -24,23 +25,36 @@ function argVal(flag) {
24
25
  const SESSION = argVal("--session");
25
26
  const RESULT_DIR = argVal("--result-dir") ?? join(tmpdir(), "tfx-headless");
26
27
  const LAYOUT = argVal("--layout") ?? "single";
27
-
28
- if (!SESSION) {
28
+ const SOURCE = argVal("--source") === "files" ? "files" : "mux";
29
+ const LEAD_PANE = argVal("--lead-pane");
30
+ const parsedWorkerCount = Number.parseInt(argVal("--workers") || "0", 10);
31
+ const WORKER_COUNT = Number.isFinite(parsedWorkerCount)
32
+ ? Math.min(64, Math.max(0, parsedWorkerCount))
33
+ : 0;
34
+ const MUX_BIN = getMultiplexerType();
35
+
36
+ if (!SESSION || !/^[a-zA-Z0-9_-]+$/u.test(SESSION)) {
29
37
  process.stderr.write(
30
38
  "Usage: node tui-viewer.mjs --session <name> [--result-dir <dir>] [--layout <name>]\n",
31
39
  );
32
40
  process.exit(1);
33
41
  }
34
-
35
- try {
36
- execFileSync("psmux", ["--version"], { encoding: "utf8", timeout: 2000 });
37
- } catch {
38
- process.stderr.write(
39
- `ERROR: psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}\n`,
40
- );
42
+ if (SOURCE === "files" && WORKER_COUNT === 0) {
43
+ process.stderr.write("ERROR: --source files requires --workers <count>\n");
41
44
  process.exit(1);
42
45
  }
43
46
 
47
+ if (SOURCE === "mux") {
48
+ try {
49
+ execFileSync(MUX_BIN, ["-V"], { encoding: "utf8", timeout: 2000 });
50
+ } catch {
51
+ process.stderr.write(
52
+ `ERROR: primary multiplexer 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}\n`,
53
+ );
54
+ process.exit(1);
55
+ }
56
+ }
57
+
44
58
  // ── 메모리 보호 상수 ──
45
59
  const MAX_BODY_BYTES = 10240;
46
60
 
@@ -59,11 +73,13 @@ const tui = tuiFactory({
59
73
  worker: workerName,
60
74
  openAll: false,
61
75
  cwd: process.cwd(),
76
+ targetPane: LEAD_PANE,
62
77
  }),
63
78
  onOpenAllWorkers: () =>
64
79
  openHeadlessDashboardTarget(SESSION, {
65
80
  openAll: true,
66
81
  cwd: process.cwd(),
82
+ targetPane: LEAD_PANE,
67
83
  }),
68
84
  });
69
85
  const startTime = Date.now();
@@ -166,9 +182,16 @@ function estimateProgress(lines, context = {}) {
166
182
 
167
183
  // ── psmux 래퍼 ──
168
184
  function listPanes() {
185
+ if (SOURCE === "files") {
186
+ return Array.from({ length: WORKER_COUNT }, (_, index) => ({
187
+ index: index + 1,
188
+ title: `worker-${index + 1} (daemon)`,
189
+ pid: "",
190
+ }));
191
+ }
169
192
  try {
170
193
  const out = execFileSync(
171
- "psmux",
194
+ MUX_BIN,
172
195
  [
173
196
  "list-panes",
174
197
  "-t",
@@ -192,9 +215,35 @@ function listPanes() {
192
215
  }
193
216
 
194
217
  function capturePane(paneIdx, lines = 20) {
218
+ if (SOURCE === "files") {
219
+ const base = join(RESULT_DIR, `${SESSION}-worker-${paneIdx}.txt`);
220
+ const artifacts = [
221
+ { path: base, label: "output" },
222
+ { path: `${base}.partial`, label: "daemon" },
223
+ { path: `${base}.err`, label: "stderr" },
224
+ ];
225
+ const snapshots = [];
226
+ for (const artifact of artifacts) {
227
+ try {
228
+ const content = readFileSync(artifact.path, "utf8").trim();
229
+ if (!content) continue;
230
+ snapshots.push(
231
+ `[${artifact.label}]\n${content.split("\n").slice(-lines).join("\n")}`,
232
+ );
233
+ } catch {
234
+ /* artifact is optional while the daemon job is starting */
235
+ }
236
+ }
237
+ return (
238
+ snapshots.join("\n") ||
239
+ `worker-${paneIdx} daemon active · waiting for output · ${Math.round(
240
+ (Date.now() - startTime) / 1000,
241
+ )}s`
242
+ );
243
+ }
195
244
  try {
196
245
  return execFileSync(
197
- "psmux",
246
+ MUX_BIN,
198
247
  ["capture-pane", "-t", `${SESSION}:0.${paneIdx}`, "-p"],
199
248
  { encoding: "utf8", timeout: 2000 },
200
249
  )
@@ -368,7 +417,7 @@ function ingest() {
368
417
  const tokens = extractTokenLabel(snapshot);
369
418
  const elapsed = Math.round((Date.now() - startTime) / 1000);
370
419
 
371
- if (resultSize > 10 || shellReturned) {
420
+ if (SOURCE !== "files" && (resultSize > 10 || shellReturned)) {
372
421
  const resultContent = existsSync(resultFile)
373
422
  ? readFileSync(resultFile, "utf8")
374
423
  : snapshot;
@@ -410,8 +459,10 @@ function ingest() {
410
459
  // 진행 중
411
460
  const progress = estimateProgress(lines, {
412
461
  tokens,
413
- resultSize,
414
- shellReturned,
462
+ // daemon output files grow while the job is still running; file size is
463
+ // activity evidence, not a completion signal in read-only file mode.
464
+ resultSize: SOURCE === "files" ? 0 : resultSize,
465
+ shellReturned: SOURCE === "files" ? false : shellReturned,
415
466
  done: false,
416
467
  });
417
468
  const verdict = lastLine;