@triflux/remote 10.53.0 → 10.55.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 (47) hide show
  1. package/hub/team/backend.mjs +10 -3
  2. package/hub/team/cli/commands/start/index.mjs +0 -2
  3. package/hub/team/handoff.mjs +1 -1
  4. package/hub/team/headless.mjs +41 -57
  5. package/hub/team/intervention.mjs +6 -4
  6. package/hub/team/nativeProxy.mjs +2 -48
  7. package/hub/team/pane.mjs +31 -18
  8. package/hub/team/psmux.mjs +36 -20
  9. package/hub/team/remote-session.mjs +66 -56
  10. package/hub/team/retry-state-machine.mjs +1 -1
  11. package/hub/team/session.mjs +80 -59
  12. package/hub/team/terminal-opener.mjs +5 -19
  13. package/hub/team/tui-viewer.mjs +2 -2
  14. package/hub/team/tui.mjs +0 -6
  15. package/hub/workers/factory.mjs +1 -10
  16. package/hub/workers/interface.mjs +0 -7
  17. package/package.json +2 -6
  18. package/scripts/lib/backup-rotation.mjs +60 -0
  19. package/scripts/lib/codex-profile-config.mjs +20 -7
  20. package/scripts/lib/codex-recovery.sh +2 -3
  21. package/scripts/lib/install-blob-history.json +3196 -0
  22. package/scripts/lib/install-retire.mjs +203 -0
  23. package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
  24. package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
  25. package/scripts/lib/machine-profile.mjs +2 -2
  26. package/scripts/lib/mcp-filter.mjs +1 -42
  27. package/scripts/lib/mcp-guard-engine.mjs +198 -19
  28. package/scripts/lib/mcp-manifest.mjs +0 -40
  29. package/scripts/lib/process-utils.mjs +19 -0
  30. package/scripts/lib/stable-node.mjs +35 -0
  31. package/scripts/lib/test-env.mjs +20 -0
  32. package/cto/brief.mjs +0 -73
  33. package/cto/collect.mjs +0 -490
  34. package/cto/current.schema.json +0 -148
  35. package/cto/events.mjs +0 -208
  36. package/cto/hygiene.mjs +0 -320
  37. package/cto/lake-root.mjs +0 -127
  38. package/cto/status.mjs +0 -124
  39. package/hub/lib/cto-env.mjs +0 -1
  40. package/hub/team/notify.mjs +0 -385
  41. package/hub/team/process-cleanup.mjs +0 -386
  42. package/hub/team/routing.mjs +0 -249
  43. package/hub/team/staleState.mjs +0 -403
  44. package/hub/team/worker-signal.mjs +0 -263
  45. package/hub/team/worker-signal.types.d.ts +0 -52
  46. package/hub/workers/codex-mcp.mjs +0 -853
  47. package/scripts/lib/remote-spawn-transfer.mjs +0 -226
@@ -1,11 +1,18 @@
1
+ import { writePromptToTmpFile } from "@triflux/core/hub/lib/prompt-tmp.mjs";
1
2
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
2
3
  import { powershellSingleQuote, shellQuote } from "./terminal-opener.mjs";
3
4
 
4
5
  class ClaudeBackend {
5
- buildArgs(prompt, resultFile, { isWindows = IS_WINDOWS } = {}) {
6
+ // 프롬프트를 argv 로 넘기면 ps 에 보인다. claude --print 는 프롬프트 인자가 없으면 stdin 을 읽는다.
7
+ buildArgs(prompt, resultFile, { isWindows = IS_WINDOWS, promptFile } = {}) {
8
+ const file = promptFile || writePromptToTmpFile(prompt);
6
9
  const quote = isWindows ? powershellSingleQuote : shellQuote;
7
- const args = ["--print", prompt, "--output-format", "text"];
8
- return `claude ${args.map(quote).join(" ")} > ${quote(resultFile)} 2>&1`;
10
+ const run = `claude --print --output-format text > ${quote(resultFile)} 2>&1`;
11
+ // PowerShell 5.1 은 BOM 없는 파일을 ANSI 로 읽고 파이프를 ASCII 로 보내서 UTF-8 을 명시한다.
12
+ // -LiteralPath 는 경로의 [] 를 와일드카드로 읽지 않게 한다.
13
+ return isWindows
14
+ ? `$OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-Content -LiteralPath ${quote(file)} -Raw -Encoding UTF8 | ${run}`
15
+ : `${run} < ${quote(file)}`;
9
16
  }
10
17
  }
11
18
 
@@ -41,8 +41,6 @@ function printWorkerPreview(agents, subtasks) {
41
41
  console.log("");
42
42
  }
43
43
 
44
- export { parseTeamArgs };
45
-
46
44
  export function resolveTeamWorkers({ agents, task, assigns = [] }) {
47
45
  if (assigns.length > 0) {
48
46
  return {
@@ -1,6 +1,6 @@
1
1
  // hub/team/handoff.mjs — Worker → Lead handoff 프로토콜
2
2
  // HANDOFF 블록 파싱, 검증, fallback 생성, Lead 포맷팅
3
- // 설계: docs/design/handoff-schema-v7.md
3
+ // 설계: docs/design/handoff-schema-v7.md (PR #70 에서 삭제, c0722ecd 에 남아 있다)
4
4
 
5
5
  // ── enum 허용 값 ──
6
6
  const STATUS_VALUES = ["ok", "partial", "failed"];
@@ -5,18 +5,17 @@ import { execSync } from "node:child_process";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import {
7
7
  existsSync,
8
- mkdirSync,
9
8
  readFileSync,
10
9
  rmSync,
11
10
  statSync,
12
11
  writeFileSync,
13
12
  } from "node:fs";
14
13
  import { createRequire } from "node:module";
15
- import { tmpdir } from "node:os";
16
- import { dirname, join, resolve } from "node:path";
14
+ import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
17
15
  import { fileURLToPath } from "node:url";
18
16
  import { resolveNestedCodexAgentProfile } from "../../scripts/lib/agent-route-policy.mjs";
19
17
  import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
18
+ import { privateTmpDir } from "@triflux/core/hub/lib/private-tmp.mjs";
20
19
  import {
21
20
  createActivityLifecycle,
22
21
  isActivityLifecycleEnabled,
@@ -47,7 +46,10 @@ import { createResultsIndex, resultsIndexPath } from "./results-index.mjs";
47
46
  import { createLogDashboard } from "./tui.mjs";
48
47
  import { createWtManager } from "./wt-manager.mjs";
49
48
 
50
- const RESULT_DIR = join(tmpdir(), "tfx-headless");
49
+ // 결과와 프롬프트, 실행 스크립트가 남으므로 사용자별 0700 디렉터리에 둔다.
50
+ function resultDir() {
51
+ return privateTmpDir("tfx-headless");
52
+ }
51
53
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
52
54
 
53
55
  /** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
@@ -85,27 +87,37 @@ function shellQuote(value) {
85
87
  return `'${String(value).replace(/'/g, "'\\''")}'`;
86
88
  }
87
89
 
90
+ // 빈 항목과 상대 경로는 cwd 를 뜻하므로 건너뛴다.
91
+ function findOnPath(name) {
92
+ for (const dir of String(process.env.PATH || "").split(delimiter)) {
93
+ if (!dir || !isAbsolute(dir)) continue;
94
+ const candidate = join(dir, name);
95
+ if (existsSync(candidate)) return candidate;
96
+ }
97
+ return "";
98
+ }
99
+
100
+ // cwd 의 스크립트는 후보에 넣지 않는다. 신뢰하지 않는 저장소에서 돌리면 그 저장소의 스크립트가
101
+ // 실행된다. bash 에 맨 이름만 넘겨도 cwd 를 먼저 읽으므로 찾지 못하면 실행하지 않는다.
88
102
  function resolveHeadlessRouteScript(opts = {}) {
103
+ if (opts.routeScript) return opts.routeScript;
89
104
  const candidates = [
90
- opts.routeScript,
91
105
  process.env.TFX_ROUTE_SCRIPT,
92
106
  process.env.TFX_DELEGATOR_ROUTE_SCRIPT,
93
- join(process.cwd(), "scripts", "tfx-route.sh"),
94
107
  join(SCRIPT_DIR, "..", "..", "scripts", "tfx-route.sh"),
95
108
  process.env.HOME
96
109
  ? join(process.env.HOME, ".claude", "scripts", "tfx-route.sh")
97
110
  : "",
98
- "tfx-route.sh",
99
111
  ];
100
-
101
- for (const candidate of candidates) {
102
- if (!candidate) continue;
103
- if (candidate === "tfx-route.sh" || existsSync(candidate)) {
104
- return candidate;
105
- }
112
+ const found =
113
+ candidates.find((candidate) => candidate && existsSync(candidate)) ||
114
+ findOnPath("tfx-route.sh");
115
+ if (!found) {
116
+ throw new Error(
117
+ "tfx-route.sh 를 찾지 못했다. TFX_ROUTE_SCRIPT 로 경로를 지정한다.",
118
+ );
106
119
  }
107
-
108
- return "tfx-route.sh";
120
+ return found;
109
121
  }
110
122
 
111
123
  function resolveRouteAgentForHeadless(resolvedCli, opts = {}) {
@@ -128,11 +140,14 @@ export function normalizeHeadlessRole(role) {
128
140
  }
129
141
 
130
142
  export function headlessResultFile(sessionName, paneName) {
131
- return join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
143
+ return join(resultDir(), `${sessionName}-${paneName}.txt`).replace(
144
+ /\\/g,
145
+ "/",
146
+ );
132
147
  }
133
148
 
134
149
  export function headlessResultsIndexPath(sessionName) {
135
- return resultsIndexPath(RESULT_DIR, sessionName).replace(/\\/g, "/");
150
+ return resultsIndexPath(resultDir(), sessionName).replace(/\\/g, "/");
136
151
  }
137
152
 
138
153
  export function resolveHeadlessDisplayName(assignment = {}, paneName = "") {
@@ -200,25 +215,6 @@ const MCP_PROFILE_HINTS = {
200
215
  docs: "Focus on documentation and explanation tasks.",
201
216
  };
202
217
 
203
- export function buildDashboardAttachArgs(
204
- sessionName,
205
- layout,
206
- workerCount,
207
- anchor = "window",
208
- ) {
209
- const safeName = String(sessionName).replace(/[^a-zA-Z0-9_-]/g, "");
210
- const base = anchor === "tab" ? ["-w", "0", "nt"] : ["-w", "new"];
211
- return [
212
- ...base,
213
- "--session",
214
- safeName,
215
- "--layout",
216
- layout,
217
- "--workers",
218
- String(workerCount),
219
- ];
220
- }
221
-
222
218
  export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
223
219
  const { handoff = true, mcp, contextFile, model, cwd } = opts;
224
220
  const resolvedCli = resolveCliType(cli);
@@ -256,15 +252,9 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
256
252
  const handoffHint = handoff ? `\n\n${HANDOFF_INSTRUCTION_SHORT}` : "";
257
253
  const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${workspaceHint}${handoffHint}`;
258
254
 
259
- // 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달 (셸 주입 방지).
260
- // 이전 구현은 `"$(cat 'PATH')"` shell-expansion expression 을 만들어
261
- // backend.buildArgs 에 전달했는데, 새 stdin-prompt 모드 (PR #252) 가
262
- // 그 literal 을 user message 로 해석해서 codex 가 prompt 가 아닌
263
- // shell expression 자체를 받는 회귀가 있었다. 이제 fullPrompt 를 그대로
264
- // 전달하고 buildExecCommand 가 자체적으로 tmp file 작성 + stdin redirect.
265
- if (!existsSync(RESULT_DIR)) mkdirSync(RESULT_DIR, { recursive: true });
255
+ // 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달한다(셸 주입 방지).
266
256
  const promptFile = join(
267
- RESULT_DIR,
257
+ resultDir(),
268
258
  "prompt-" + randomUUID().slice(0, 8) + ".txt",
269
259
  ).replace(/\\/g, "/");
270
260
  writeFileSync(promptFile, fullPrompt, "utf8");
@@ -655,7 +645,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
655
645
  cwd: assignment.cwd || assignment.workdir,
656
646
  },
657
647
  );
658
- const scriptDir = join(RESULT_DIR, sessionName);
648
+ const scriptDir = join(resultDir(), sessionName);
659
649
  const dispatch = dispatchCommand(sessionName, paneName, cmd, {
660
650
  scriptDir,
661
651
  scriptName: paneName,
@@ -912,7 +902,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
912
902
  onIntervene,
913
903
  };
914
904
 
915
- mkdirSync(RESULT_DIR, { recursive: true });
905
+ resultDir();
916
906
  const normalizedAssignments = (assignments || []).map((assignment, index) =>
917
907
  normalizeHeadlessAssignment(assignment, index),
918
908
  );
@@ -970,7 +960,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
970
960
  }
971
961
  } else if (dashboard) {
972
962
  // stdout은 결과 파싱 대상이므로 안내는 stderr로 보낸다.
973
- const logDir = join(tmpdir(), "tfx-headless");
963
+ const logDir = resultDir();
974
964
  process.stderr.write(
975
965
  `\n⚠ stdout is not a TTY; dashboard is skipped.\n` +
976
966
  ` Session is running in background. Worker logs:\n` +
@@ -1446,14 +1436,11 @@ export async function autoAttachTerminal(
1446
1436
  opts.dashboardLayout || "single",
1447
1437
  workerCount,
1448
1438
  );
1449
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
1450
- /\\/g,
1451
- "/",
1452
- );
1439
+ const viewerPath = join(SCRIPT_DIR, "tui-viewer.mjs").replace(/\\/g, "/");
1453
1440
  await wt.createTab({
1454
1441
  title: buildAttachTitle(safeSession, "dashboard"),
1455
1442
  profile: "triflux",
1456
- command: `node "${viewerPath}" --session ${safeSession} --result-dir "${RESULT_DIR}" --layout ${resolvedLayout}`,
1443
+ command: `node "${viewerPath}" --session ${safeSession} --result-dir "${resultDir()}" --layout ${resolvedLayout}`,
1457
1444
  });
1458
1445
  } else {
1459
1446
  const panes = [];
@@ -1519,15 +1506,12 @@ export async function attachDashboardTab(
1519
1506
  try {
1520
1507
  const safeSession = sanitizeSessionName(sessionName);
1521
1508
  const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
1522
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
1523
- /\\/g,
1524
- "/",
1525
- );
1509
+ const viewerPath = join(SCRIPT_DIR, "tui-viewer.mjs").replace(/\\/g, "/");
1526
1510
 
1527
1511
  await wt.createTab({
1528
1512
  title: buildAttachTitle(safeSession, "dashboard"),
1529
1513
  profile: "triflux",
1530
- command: `node "${viewerPath}" --session ${safeSession} --result-dir "${RESULT_DIR}" --layout ${resolvedLayout}`,
1514
+ command: `node "${viewerPath}" --session ${safeSession} --result-dir "${resultDir()}" --layout ${resolvedLayout}`,
1531
1515
  });
1532
1516
  return true;
1533
1517
  } catch {
@@ -125,10 +125,12 @@ export async function resolveCodexRolloutFile({
125
125
  return null;
126
126
  }
127
127
 
128
- export function buildResumeArgv({ cli, sessionId, prompt } = {}) {
128
+ // 프롬프트는 argv 에 두면 ps 에 보여서 넣지 않는다. 실행하는 쪽이 prompt 를 stdin 으로 넘긴다.
129
+ // claude -p 는 프롬프트 인자가 없으면, codex exec resume 은 `-` 를 받으면 stdin 을 읽는다.
130
+ export function buildResumeArgv({ cli, sessionId } = {}) {
129
131
  if (!sessionId) return null;
130
- if (cli === "claude") return ["claude", "-p", "--resume", sessionId, prompt];
131
- if (cli === "codex") return ["codex", "exec", "resume", sessionId, prompt];
132
+ if (cli === "claude") return ["claude", "-p", "--resume", sessionId];
133
+ if (cli === "codex") return ["codex", "exec", "resume", sessionId, "-"];
132
134
  return null;
133
135
  }
134
136
 
@@ -474,7 +476,7 @@ export function createInterventionLadder({
474
476
  sessionId,
475
477
  prompt: reinstructPrompt,
476
478
  target,
477
- argv: buildResumeArgv({ cli, sessionId, prompt: reinstructPrompt }),
479
+ argv: buildResumeArgv({ cli, sessionId }),
478
480
  });
479
481
  return resumed?.ok === false
480
482
  ? { ok: false, detail: "resume_dispatch_failed" }
@@ -6,7 +6,6 @@ import {
6
6
  existsSync,
7
7
  mkdirSync,
8
8
  renameSync,
9
- rmSync,
10
9
  unlinkSync,
11
10
  writeFileSync,
12
11
  } from "node:fs";
@@ -23,7 +22,8 @@ import { isPidAlive } from "@triflux/core/hub/lib/process-utils.mjs";
23
22
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
24
23
 
25
24
  const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
26
- const CLAUDE_HOME = join(homedir(), ".claude");
25
+ // Claude Code 와 같은 규칙으로 CLAUDE_CONFIG_DIR 를 따른다. 테스트도 이걸로 실제 ~/.claude 를 피한다.
26
+ const CLAUDE_HOME = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
27
27
  const TEAMS_ROOT = join(CLAUDE_HOME, "teams");
28
28
  const TASKS_ROOT = join(CLAUDE_HOME, "tasks");
29
29
  const LOCK_STALE_MS = 30000;
@@ -232,52 +232,6 @@ export async function resolveTeamPaths(teamName) {
232
232
  };
233
233
  }
234
234
 
235
- export async function forceCleanupTeam(teamName) {
236
- validateTeamName(teamName);
237
-
238
- let paths;
239
- try {
240
- paths = await resolveTeamPaths(teamName);
241
- } catch {
242
- paths = {
243
- team_dir: join(TEAMS_ROOT, teamName),
244
- config_path: join(TEAMS_ROOT, teamName, "config.json"),
245
- tasks_dir: join(TASKS_ROOT, teamName),
246
- lead_session_id: null,
247
- config: null,
248
- };
249
- }
250
-
251
- try {
252
- const config = paths.config || (await readJsonSafe(paths.config_path));
253
- if (config && Array.isArray(config.members)) {
254
- atomicWriteJson(paths.config_path, {
255
- ...config,
256
- members: config.members.map((member) => ({
257
- ...member,
258
- isActive: false,
259
- })),
260
- });
261
- }
262
- } catch {}
263
-
264
- const cleanupTargets = new Set([
265
- paths.team_dir,
266
- join(TASKS_ROOT, teamName),
267
- paths.tasks_dir,
268
- ]);
269
- if (paths.lead_session_id) {
270
- cleanupTargets.add(join(TASKS_ROOT, paths.lead_session_id));
271
- }
272
-
273
- for (const targetPath of cleanupTargets) {
274
- if (!targetPath) continue;
275
- try {
276
- rmSync(targetPath, { recursive: true, force: true });
277
- } catch {}
278
- }
279
- }
280
-
281
235
  async function collectTaskFiles(tasksDir) {
282
236
  if (!existsSync(tasksDir)) return [];
283
237
 
package/hub/team/pane.mjs CHANGED
@@ -1,15 +1,12 @@
1
1
  // hub/team/pane.mjs — pane별 CLI 실행 + stdin 주입
2
2
  // 의존성: child_process, fs, os, path (Node.js 내장)만 사용
3
- import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
3
+ import { unlinkSync, writeFileSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
5
  import { join } from "node:path";
6
+ import { privateTmpDir } from "@triflux/core/hub/lib/private-tmp.mjs";
6
7
  import { psmuxExec } from "./psmux.mjs";
7
8
  import { detectMultiplexer, tmuxExec } from "./session.mjs";
8
9
 
9
- function quoteArg(value) {
10
- return `"${String(value).replace(/"/g, '\\"')}"`;
11
- }
12
-
13
10
  function getPsmuxSessionName(target) {
14
11
  return String(target).split(":")[0]?.trim() || "";
15
12
  }
@@ -46,15 +43,26 @@ function muxExec(args, opts = {}) {
46
43
  });
47
44
  }
48
45
 
46
+ const AGENT_TO_CLI = createRequire(import.meta.url)("./agent-map.json");
47
+
48
+ // 역할명(designer)과 별칭(agy)을 CLI 종류로 푼다. 실행과 주입 판정이 같은 값을 본다.
49
+ export function resolveCli(cli) {
50
+ const name = String(cli || "").toLowerCase();
51
+ return AGENT_TO_CLI[name] ?? name;
52
+ }
53
+
49
54
  /**
50
- * CLI 에이전트 시작 커맨드 생성
51
- * @param {'codex'|'antigravity'|'claude'} cli
55
+ * CLI 에이전트 시작 커맨드 생성. 역할명과 별칭은 agent-map 으로 CLI 를 고른다.
56
+ * @param {string} cli: CLI 이름, 별칭(agy) 또는 역할명
52
57
  * @returns {string} 실행할 셸 커맨드
53
58
  */
54
59
  export function buildCliCommand(cli) {
55
- switch (cli) {
60
+ switch (resolveCli(cli)) {
56
61
  case "codex":
57
62
  return "codex --dangerously-bypass-approvals-and-sandbox";
63
+ case "antigravity":
64
+ // 실행 파일은 agy 다. antigravity 는 셸 별칭이라 비대화형 셸에 없다.
65
+ return "agy";
58
66
  case "claude":
59
67
  // interactive 모드
60
68
  return "claude";
@@ -82,7 +90,7 @@ export function startCliInPane(target, command) {
82
90
  * @param {{ multiplexer: string, useFileRef: boolean, cli: string|null }} args
83
91
  */
84
92
  export function shouldUseFileRef({ multiplexer, useFileRef, cli }) {
85
- return multiplexer === "psmux" && useFileRef && cli !== "codex";
93
+ return multiplexer === "psmux" && useFileRef && resolveCli(cli) !== "codex";
86
94
  }
87
95
 
88
96
  /** 동기 sleep — injectPrompt는 sync 경로라 setTimeout을 쓸 수 없다 */
@@ -105,7 +113,7 @@ function capturePaneText(target) {
105
113
  "",
106
114
  );
107
115
  }
108
- return String(muxExec(`capture-pane -t ${target} -p`) ?? "");
116
+ return String(muxExec(["capture-pane", "-t", target, "-p"]) ?? "");
109
117
  } catch {
110
118
  return "";
111
119
  }
@@ -136,7 +144,7 @@ function waitForComposerReady(target) {
136
144
  * capture 불가 또는 재시도 소진을 성공으로 숨기지 않고 오류로 반환한다.
137
145
  */
138
146
  function isAntigravityCli(cli) {
139
- return ["agy", "antigravity"].includes(String(cli || "").toLowerCase());
147
+ return resolveCli(cli) === "antigravity";
140
148
  }
141
149
 
142
150
  function promptComposerNeedle(prompt) {
@@ -239,8 +247,7 @@ export function injectPrompt(
239
247
  prompt,
240
248
  { useFileRef = false, cli = null } = {},
241
249
  ) {
242
- const tmpDir = join(tmpdir(), "tfx-multi");
243
- mkdirSync(tmpDir, { recursive: true });
250
+ const tmpDir = privateTmpDir("tfx-multi");
244
251
 
245
252
  const safeTarget = target.replace(/[:.]/g, "-");
246
253
  const tmpFile = join(tmpDir, `prompt-${safeTarget}-${Date.now()}.txt`);
@@ -294,10 +301,11 @@ export function injectPrompt(
294
301
 
295
302
  // tmux load-buffer → paste-buffer → (정착 지연 + 제출 확인) Enter
296
303
  waitForComposerReady(target);
297
- muxExec(`load-buffer ${quoteArg(toMuxPath(tmpFile))}`);
298
- muxExec(`paste-buffer -t ${target}`);
304
+ muxExec(["load-buffer", toMuxPath(tmpFile)]);
305
+ // -p 가 없으면 줄바꿈이 CR(Enter)로 들어가 여러 줄 지시문이 줄마다 제출된다.
306
+ muxExec(["paste-buffer", "-p", "-t", target]);
299
307
  confirmSubmit(target, prompt, cli, () =>
300
- muxExec(`send-keys -t ${target} Enter`),
308
+ muxExec(["send-keys", "-t", target, "Enter"]),
301
309
  );
302
310
  } finally {
303
311
  try {
@@ -312,5 +320,10 @@ export function injectPrompt(
312
320
  * @param {string} keys — tmux 키 표현 (예: 'C-c', 'Enter')
313
321
  */
314
322
  export function sendKeys(target, keys) {
315
- muxExec(`send-keys -t ${target} ${keys}`);
323
+ muxExec([
324
+ "send-keys",
325
+ "-t",
326
+ target,
327
+ ...String(keys).split(/\s+/u).filter(Boolean),
328
+ ]);
316
329
  }
@@ -19,10 +19,11 @@ import {
19
19
  readFileSync,
20
20
  writeFileSync,
21
21
  } from "node:fs";
22
- import { homedir, tmpdir } from "node:os";
22
+ import { homedir } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
25
25
  import { resolveGitBashExecutable } from "@triflux/core/hub/lib/bash-path.mjs";
26
+ import { privateTmpDir } from "@triflux/core/hub/lib/private-tmp.mjs";
26
27
  import childProcess from "@triflux/core/hub/lib/spawn-trace.mjs";
27
28
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
28
29
 
@@ -92,12 +93,10 @@ const PWSH_BIN = (() => {
92
93
  })();
93
94
  const PSMUX_TIMEOUT_MS = 10000;
94
95
  const COMPLETION_PREFIX = "__TRIFLUX_DONE__:";
95
- const CAPTURE_ROOT =
96
- process.env.PSMUX_CAPTURE_ROOT || join(tmpdir(), "psmux-steering");
97
- const CAPTURE_HELPER_PATH = join(
98
- CAPTURE_ROOT,
99
- IS_WINDOWS ? "pipe-pane-capture.ps1" : "pipe-pane-capture.sh",
100
- );
96
+ // 실행되는 캡처 스크립트가 있어 남이 바꿀 수 없는 사용자별 0700 디렉터리에 둔다.
97
+ function captureRoot() {
98
+ return process.env.PSMUX_CAPTURE_ROOT || privateTmpDir("psmux-steering");
99
+ }
101
100
  const POLL_INTERVAL_MS = (() => {
102
101
  const ms = Number.parseInt(process.env.PSMUX_POLL_INTERVAL_MS || "", 10);
103
102
  if (Number.isFinite(ms) && ms > 0) return ms;
@@ -137,7 +136,7 @@ function sleepMsAsync(ms) {
137
136
  return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
138
137
  }
139
138
 
140
- function tokenizeCommand(command) {
139
+ export function tokenizeCommand(command) {
141
140
  const source = String(command || "").trim();
142
141
  if (!source) return [];
143
142
 
@@ -232,7 +231,7 @@ function ensurePsmuxInstalled() {
232
231
  }
233
232
 
234
233
  function getCaptureSessionDir(sessionName) {
235
- return join(CAPTURE_ROOT, sanitizePathPart(sessionName));
234
+ return join(captureRoot(), sanitizePathPart(sessionName));
236
235
  }
237
236
 
238
237
  function getCaptureLogPath(sessionName, paneName) {
@@ -243,10 +242,15 @@ function getCaptureLogPath(sessionName, paneName) {
243
242
  }
244
243
 
245
244
  function ensureCaptureHelper() {
246
- mkdirSync(CAPTURE_ROOT, { recursive: true });
245
+ const root = captureRoot();
246
+ mkdirSync(root, { recursive: true });
247
+ const helperPath = join(
248
+ root,
249
+ IS_WINDOWS ? "pipe-pane-capture.ps1" : "pipe-pane-capture.sh",
250
+ );
247
251
  if (IS_WINDOWS) {
248
252
  writeFileSync(
249
- CAPTURE_HELPER_PATH,
253
+ helperPath,
250
254
  [
251
255
  "param(",
252
256
  " [Parameter(Mandatory = $true)][string]$Path",
@@ -276,7 +280,7 @@ function ensureCaptureHelper() {
276
280
  } else {
277
281
  // macOS/Linux: bash 스크립트로 pipe-pane 캡처
278
282
  writeFileSync(
279
- CAPTURE_HELPER_PATH,
283
+ helperPath,
280
284
  [
281
285
  "#!/bin/bash",
282
286
  'mkdir -p "$(dirname "$1")" 2>/dev/null',
@@ -285,9 +289,9 @@ function ensureCaptureHelper() {
285
289
  ].join("\n"),
286
290
  "utf8",
287
291
  );
288
- chmodSync(CAPTURE_HELPER_PATH, 0o755);
292
+ chmodSync(helperPath, 0o755);
289
293
  }
290
- return CAPTURE_HELPER_PATH;
294
+ return helperPath;
291
295
  }
292
296
 
293
297
  function readCaptureLog(logPath) {
@@ -454,11 +458,17 @@ function parsePaneDetails(output) {
454
458
  .filter((entry) => entry.paneId);
455
459
  }
456
460
 
461
+ // tmux 는 -t 세션 이름을 접두사로도 매칭해, 대상이 이미 없으면 이름이 이어지는
462
+ // 다른 세션을 잡는다(#548). psmux 는 정확히만 매칭하고 3.3.8 전에는 '=' 를 모른다.
463
+ function exactSessionTarget(sessionName, windowSuffix = "") {
464
+ return `${IS_WINDOWS ? "" : "="}${sessionName}${windowSuffix}`;
465
+ }
466
+
457
467
  function collectSessionPanes(sessionName) {
458
468
  const output = psmuxExec([
459
469
  "list-panes",
460
470
  "-t",
461
- `${sessionName}:0`,
471
+ exactSessionTarget(sessionName, ":0"),
462
472
  "-F",
463
473
  "#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}",
464
474
  ]);
@@ -762,7 +772,7 @@ function collectPanePids(sessionName) {
762
772
  const output = psmuxExec([
763
773
  "list-panes",
764
774
  "-t",
765
- sessionName,
775
+ exactSessionTarget(sessionName, ":"),
766
776
  "-F",
767
777
  "#{pane_pid}",
768
778
  ]);
@@ -872,7 +882,7 @@ function disableAllPipeCaptures(sessionName, paneIds) {
872
882
  function killOrphanPipeHelpers(sessionName) {
873
883
  if (!IS_WINDOWS) {
874
884
  // macOS/Linux: pipe-pane helper cmdline contains
875
- // `<CAPTURE_ROOT>/<session>/<pane>.log`, so anchor on `/<session>/`.
885
+ // `<captureRoot()>/<session>/<pane>.log`, so anchor on `/<session>/`.
876
886
  const safeSessionUnix = escapeRegex(sanitizePathPart(sessionName));
877
887
  try {
878
888
  const pids = childProcess
@@ -1025,7 +1035,9 @@ export function killPsmuxSession(sessionName) {
1025
1035
 
1026
1036
  // 3. psmux 세션 자체 종료
1027
1037
  try {
1028
- psmuxExec(["kill-session", "-t", sessionName], { stdio: "ignore" });
1038
+ psmuxExec(["kill-session", "-t", exactSessionTarget(sessionName)], {
1039
+ stdio: "ignore",
1040
+ });
1029
1041
  } catch {
1030
1042
  // 이미 종료된 세션 — 무시
1031
1043
  }
@@ -1064,7 +1076,9 @@ export function killSessionByTitle(titlePattern) {
1064
1076
  const sessions = listSessions({ filterTitle: titlePattern });
1065
1077
  const killed = [];
1066
1078
  for (const session of sessions) {
1067
- psmuxExec(["kill-session", "-t", session.sessionName], { stdio: "ignore" });
1079
+ psmuxExec(["kill-session", "-t", exactSessionTarget(session.sessionName)], {
1080
+ stdio: "ignore",
1081
+ });
1068
1082
  killed.push(session.sessionName);
1069
1083
  }
1070
1084
  return {
@@ -1130,7 +1144,9 @@ export function pruneStale(opts = {}) {
1130
1144
  */
1131
1145
  export function psmuxSessionExists(sessionName) {
1132
1146
  try {
1133
- psmuxExec(["has-session", "-t", sessionName], { stdio: "ignore" });
1147
+ psmuxExec(["has-session", "-t", exactSessionTarget(sessionName)], {
1148
+ stdio: "ignore",
1149
+ });
1134
1150
  return true;
1135
1151
  } catch {
1136
1152
  return false;