@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
@@ -3,8 +3,10 @@
3
3
 
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
6
7
  import {
7
8
  basename,
9
+ isAbsolute,
8
10
  join,
9
11
  posix as posixPath,
10
12
  win32 as win32Path,
@@ -21,12 +23,25 @@ export function shellQuote(value) {
21
23
  return `'${String(value).replace(/'/g, "'\\''")}'`;
22
24
  }
23
25
 
26
+ // PowerShell 은 ‘ ’ ‚ ‛ 도 작은따옴표로 읽는다. 따옴표 문자를 모두 겹쳐 써야 인용이 안 끝난다.
24
27
  export function escapePwshSingleQuoted(value) {
25
- return String(value).replace(/'/g, "''");
28
+ return String(value).replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&");
26
29
  }
27
30
 
31
+ // 큰따옴표 안에서는 $ 가 확장되고 “ ” „ 도 따옴표로 읽힌다. 모두 백틱으로 막는다.
28
32
  export function escapePwshDoubleQuoted(value) {
29
- return String(value).replace(/`/g, "``").replace(/"/g, '`"');
33
+ return String(value).replace(/[`$"\u201C\u201D\u201E]/g, "`$&");
34
+ }
35
+
36
+ // ssh 는 원격 명령 인자를 공백으로 이어 원격 셸에 다시 넘긴다. 스크립트는 인용한 문자열 하나로 만든다.
37
+ export function posixRemoteCommand(script) {
38
+ return `sh -lc ${shellQuote(script)}`;
39
+ }
40
+
41
+ // pwsh 스크립트는 base64 로 넘겨 원격 기본 셸(cmd 나 pwsh)이 따옴표와 파이프를 해석하지 않게 한다.
42
+ export function pwshRemoteCommand(script) {
43
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
44
+ return `pwsh -NoProfile -EncodedCommand ${encoded}`;
30
45
  }
31
46
 
32
47
  function normalizeCommandPath(value) {
@@ -146,6 +161,24 @@ function probeRemoteEnvViaPosix(host) {
146
161
 
147
162
  // ── Cache ───────────────────────────────────────────────────────
148
163
 
164
+ // cwd 의 .omc 에 두면 실행한 저장소마다 원격 홈 경로가 흩어져 남는다. 사용자 상태 경로에 둔다.
165
+ export function tfxStateDir(env = process.env) {
166
+ // XDG 명세대로 상대 경로는 무시한다. 받아들이면 캐시가 다시 cwd 아래로 간다.
167
+ const xdgState = isAbsolute(env.XDG_STATE_HOME || "")
168
+ ? env.XDG_STATE_HOME
169
+ : "";
170
+ const stateRoot =
171
+ xdgState ||
172
+ (process.platform === "win32"
173
+ ? env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
174
+ : join(homedir(), ".local", "state"));
175
+ return join(stateRoot, "triflux");
176
+ }
177
+
178
+ export function remoteEnvCacheDir(env = process.env) {
179
+ return join(tfxStateDir(env), "remote-env");
180
+ }
181
+
149
182
  function getEnvCachePath(host, cacheDir) {
150
183
  return join(cacheDir, `${host}.json`);
151
184
  }
@@ -161,13 +194,11 @@ function readEnvCache(host, cacheDir) {
161
194
  }
162
195
  }
163
196
 
164
- function isEnvCacheFresh(entry) {
165
- return Boolean(
166
- entry &&
167
- typeof entry.cachedAt === "number" &&
168
- entry.env &&
169
- Date.now() - entry.cachedAt < REMOTE_ENV_TTL_MS,
170
- );
197
+ export function isEnvCacheFresh(entry, now = Date.now()) {
198
+ if (!entry?.env || typeof entry.cachedAt !== "number") return false;
199
+ const age = now - entry.cachedAt;
200
+ // 미래 시각을 허용하면 그 캐시가 영원히 신선하게 남는다.
201
+ return age >= 0 && age < REMOTE_ENV_TTL_MS;
171
202
  }
172
203
 
173
204
  function writeEnvCache(host, env, cacheDir) {
@@ -186,13 +217,13 @@ function writeEnvCache(host, env, cacheDir) {
186
217
  * @param {string} host — SSH host
187
218
  * @param {object} [opts]
188
219
  * @param {boolean} [opts.force=false] — bypass cache
189
- * @param {string} [opts.cacheDir] — cache directory (default: .omc/state/remote-env)
220
+ * @param {string} [opts.cacheDir]: cache directory (default: remoteEnvCacheDir())
190
221
  * @returns {Readonly<RemoteEnv>}
191
222
  */
192
223
  export function probeRemoteEnv(host, opts = {}) {
193
224
  validateHost(host);
194
225
  const force = opts.force === true;
195
- const cacheDir = opts.cacheDir || join(".omc", "state", "remote-env");
226
+ const cacheDir = opts.cacheDir || remoteEnvCacheDir();
196
227
 
197
228
  if (!force) {
198
229
  const cached = readEnvCache(host, cacheDir);
@@ -266,26 +297,13 @@ export function resolveRemoteStageDir(env, stageId) {
266
297
  * @param {string} remoteStageDir
267
298
  */
268
299
  export function ensureRemoteStageDir(host, env, remoteStageDir) {
269
- if (env.os === "win32") {
270
- const safePath = escapePwshSingleQuoted(remoteStageDir);
271
- execFileSync(
272
- "ssh",
273
- [
274
- host,
275
- "pwsh",
276
- "-NoProfile",
277
- "-Command",
278
- `New-Item -ItemType Directory -Path '${safePath}' -Force | Out-Null`,
279
- ],
280
- { timeout: 10000, stdio: "pipe" },
281
- );
282
- return;
283
- }
284
- execFileSync(
285
- "ssh",
286
- [host, "sh", "-lc", `mkdir -p ${shellQuote(remoteStageDir)}`],
287
- { timeout: 10000, stdio: "pipe" },
288
- );
300
+ const command =
301
+ env.os === "win32"
302
+ ? pwshRemoteCommand(
303
+ `New-Item -ItemType Directory -Path '${escapePwshSingleQuoted(remoteStageDir)}' -Force | Out-Null`,
304
+ )
305
+ : posixRemoteCommand(`mkdir -p ${shellQuote(remoteStageDir)}`);
306
+ execFileSync("ssh", [host, command], { timeout: 10000, stdio: "pipe" });
289
307
  }
290
308
 
291
309
  /**
@@ -342,29 +360,21 @@ export function stageRemotePromptFiles(host, env, transferCandidates, stageId) {
342
360
  * @returns {string} stdout
343
361
  */
344
362
  export function remoteGit(host, env, gitArgs, cwd) {
345
- const gitCmd = ["git", ...gitArgs].map((a) => shellQuote(a)).join(" ");
346
-
347
- if (env.os === "win32") {
348
- const cdPath = escapePwshSingleQuoted(cwd);
349
- const command = `Set-Location '${cdPath}'; ${gitCmd}`;
350
- return execFileSync(
351
- "ssh",
352
- [host, "pwsh", "-NoProfile", "-Command", command],
353
- {
354
- encoding: "utf8",
355
- timeout: 30_000,
356
- stdio: ["pipe", "pipe", "pipe"],
357
- },
358
- ).trim();
359
- }
360
-
361
- return execFileSync(
362
- "ssh",
363
- [host, "sh", "-lc", `cd ${shellQuote(cwd)} && ${gitCmd}`],
364
- {
365
- encoding: "utf8",
366
- timeout: 30_000,
367
- stdio: ["pipe", "pipe", "pipe"],
368
- },
369
- ).trim();
363
+ const command =
364
+ env.os === "win32"
365
+ ? pwshRemoteCommand(
366
+ [
367
+ `Set-Location '${escapePwshSingleQuoted(cwd)}';`,
368
+ "git",
369
+ ...gitArgs.map((arg) => `'${escapePwshSingleQuoted(arg)}'`),
370
+ ].join(" "),
371
+ )
372
+ : posixRemoteCommand(
373
+ `cd ${shellQuote(cwd)} && ${["git", ...gitArgs].map(shellQuote).join(" ")}`,
374
+ );
375
+ return execFileSync("ssh", [host, command], {
376
+ encoding: "utf8",
377
+ timeout: 30_000,
378
+ stdio: ["pipe", "pipe", "pipe"],
379
+ }).trim();
370
380
  }
@@ -1,6 +1,6 @@
1
1
  // hub/team/retry-state-machine.mjs
2
2
  // Phase 3 Step A — true ralph / auto-escalate retry state machine.
3
- // 설계 문서: .triflux/plans/phase3-lead-codex-ralph-escalate.md
3
+ // 설계: 구현 커밋 565a2bad 메시지, 규칙은 .claude/rules/tfx-escalation-chain.md
4
4
  //
5
5
  // Modes:
6
6
  // bounded — --retry 1 (기본): maxIterations 도달 시 BUDGET_EXCEEDED.
@@ -1,5 +1,5 @@
1
1
  // tmux/psmux 세션 생명주기 관리
2
- import { execSync, spawnSync } from "node:child_process";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
3
  import { resolveGitBashExecutable } from "@triflux/core/hub/lib/bash-path.mjs";
4
4
  import { getEnvironment } from "@triflux/core/hub/lib/env-detect.mjs";
5
5
  import {
@@ -14,6 +14,7 @@ import {
14
14
  listPsmuxSessions,
15
15
  psmuxExec,
16
16
  psmuxSessionExists,
17
+ tokenizeCommand,
17
18
  } from "./psmux.mjs";
18
19
 
19
20
  /** Windows Terminal 실행 파일 존재 여부 */
@@ -29,7 +30,11 @@ export function hasWindowsTerminalSession() {
29
30
  /** tmux 실행 가능 여부 확인 */
30
31
  function hasTmux() {
31
32
  try {
32
- execSync("tmux -V", { stdio: "ignore", timeout: 3000, windowsHide: true });
33
+ execFileSync("tmux", ["-V"], {
34
+ stdio: "ignore",
35
+ timeout: 3000,
36
+ windowsHide: true,
37
+ });
33
38
  return true;
34
39
  } catch {
35
40
  return false;
@@ -100,10 +105,16 @@ export function detectMultiplexer() {
100
105
  return _cachedMux;
101
106
  }
102
107
 
108
+ function bashArg(value) {
109
+ return /^[\w@%+=:,./-]+$/u.test(value)
110
+ ? value
111
+ : `'${value.replace(/'/g, "'\\''")}'`;
112
+ }
113
+
103
114
  /**
104
115
  * tmux/psmux 커맨드 실행
105
- * @param {string} args — tmux 서브커맨드 + 인자
106
- * @param {object} opts — execSync 옵션
116
+ * @param {string[]|string} args: tmux 서브커맨드 + 인자. 문자열은 psmux 와 같은 규칙으로 쪼갠다.
117
+ * @param {object} opts: execFileSync 옵션
107
118
  * @returns {string} stdout
108
119
  */
109
120
  function tmux(args, opts = {}) {
@@ -120,16 +131,22 @@ function tmux(args, opts = {}) {
120
131
  if (mux === "psmux") {
121
132
  return psmuxExec(args, opts);
122
133
  }
134
+ // 셸을 거치지 않게 인자 배열로 넘긴다. 세션 이름이나 pane 대상이 셸 문법으로 읽히지 않는다.
135
+ const argv = Array.isArray(args) ? args.map(String) : tokenizeCommand(args);
123
136
  if (mux === "git-bash-tmux") {
124
137
  const bash = resolveGitBashExecutable();
125
138
  if (!bash) throw new Error("git-bash-tmux 감지 실패");
126
- const r = spawnSync(bash, ["-lc", `tmux ${args}`], {
127
- encoding: "utf8",
128
- timeout: 10000,
129
- stdio: ["pipe", "pipe", "pipe"],
130
- windowsHide: true,
131
- ...opts,
132
- });
139
+ const r = spawnSync(
140
+ bash,
141
+ ["-lc", ["tmux", ...argv.map(bashArg)].join(" ")],
142
+ {
143
+ encoding: "utf8",
144
+ timeout: 10000,
145
+ stdio: ["pipe", "pipe", "pipe"],
146
+ windowsHide: true,
147
+ ...opts,
148
+ },
149
+ );
133
150
  if ((r.status ?? 1) !== 0) {
134
151
  const e = new Error(r.stderr || "tmux command failed");
135
152
  e.status = r.status;
@@ -138,7 +155,7 @@ function tmux(args, opts = {}) {
138
155
  return (r.stdout || "").trim();
139
156
  }
140
157
 
141
- const result = execSync(`tmux ${args}`, {
158
+ const result = execFileSync("tmux", argv, {
142
159
  encoding: "utf8",
143
160
  timeout: 10000,
144
161
  stdio: ["pipe", "pipe", "pipe"],
@@ -150,7 +167,7 @@ function tmux(args, opts = {}) {
150
167
 
151
168
  /**
152
169
  * tmux 명령 직접 실행 (고수준 모듈에서 재사용)
153
- * @param {string} args
170
+ * @param {string[]|string} args
154
171
  * @param {object} opts
155
172
  * @returns {string}
156
173
  */
@@ -181,7 +198,7 @@ export function resolveAttachCommand(sessionName, opts = {}) {
181
198
  if (!bash) throw new Error("git-bash-tmux 감지 실패");
182
199
  return {
183
200
  command: bash,
184
- args: ["-lc", `tmux attach-session -t ${sessionName}`],
201
+ args: ["-lc", `tmux attach-session -t ${bashArg(sessionName)}`],
185
202
  };
186
203
  }
187
204
 
@@ -213,17 +230,17 @@ export function createSession(sessionName, opts = {}) {
213
230
  }
214
231
 
215
232
  // 새 세션 생성 (detached)
216
- tmux(`new-session -d -s ${sessionName} -x 220 -y 55`);
233
+ tmux(["new-session", "-d", "-s", sessionName, "-x", "220", "-y", "55"]);
217
234
 
218
235
  const panes = [`${sessionName}:0.0`];
219
236
 
220
237
  if (layout === "2x2" && paneCount >= 3) {
221
238
  // 3-pane 기본: lead 왼쪽, workers 오른쪽 상/하
222
239
  // 4-pane: 좌/우 각각 상/하(균등 2x2)
223
- tmux(`split-window -h -t ${sessionName}:0.0`);
224
- tmux(`split-window -v -t ${sessionName}:0.1`);
240
+ tmux(["split-window", "-h", "-t", `${sessionName}:0.0`]);
241
+ tmux(["split-window", "-v", "-t", `${sessionName}:0.1`]);
225
242
  if (paneCount >= 4) {
226
- tmux(`split-window -v -t ${sessionName}:0.0`);
243
+ tmux(["split-window", "-v", "-t", `${sessionName}:0.0`]);
227
244
  }
228
245
  // pane ID 재수집
229
246
  panes.length = 0;
@@ -233,9 +250,9 @@ export function createSession(sessionName, opts = {}) {
233
250
  } else if (layout === "1xN") {
234
251
  // 세로 분할(좌/우 컬럼 확장)
235
252
  for (let i = 1; i < paneCount; i++) {
236
- tmux(`split-window -h -t ${sessionName}:0`);
253
+ tmux(["split-window", "-h", "-t", `${sessionName}:0`]);
237
254
  }
238
- tmux(`select-layout -t ${sessionName}:0 even-horizontal`);
255
+ tmux(["select-layout", "-t", `${sessionName}:0`, "even-horizontal"]);
239
256
  panes.length = 0;
240
257
  for (let i = 0; i < paneCount; i++) {
241
258
  panes.push(`${sessionName}:0.${i}`);
@@ -243,9 +260,9 @@ export function createSession(sessionName, opts = {}) {
243
260
  } else {
244
261
  // Nx1 가로 분할(상/하 스택)
245
262
  for (let i = 1; i < paneCount; i++) {
246
- tmux(`split-window -v -t ${sessionName}:0`);
263
+ tmux(["split-window", "-v", "-t", `${sessionName}:0`]);
247
264
  }
248
- tmux(`select-layout -t ${sessionName}:0 even-vertical`);
265
+ tmux(["select-layout", "-t", `${sessionName}:0`, "even-vertical"]);
249
266
  panes.length = 0;
250
267
  for (let i = 0; i < paneCount; i++) {
251
268
  panes.push(`${sessionName}:0.${i}`);
@@ -263,10 +280,10 @@ export function createSession(sessionName, opts = {}) {
263
280
  */
264
281
  export function focusPane(target, opts = {}) {
265
282
  const { zoom = false } = opts;
266
- tmux(`select-pane -t ${target}`);
283
+ tmux(["select-pane", "-t", target]);
267
284
  if (zoom) {
268
285
  try {
269
- tmux(`resize-pane -t ${target} -Z`);
286
+ tmux(["resize-pane", "-t", target, "-Z"]);
270
287
  } catch {}
271
288
  }
272
289
  }
@@ -296,45 +313,46 @@ export function configureTeammateKeybindings(sessionName, opts = {}) {
296
313
  // Shift+Up이 터미널/호스트 조합에 따라 전달되지 않는 경우가 있어
297
314
  // 좌/우/Shift+Tab 대체 키를 함께 바인딩한다.
298
315
  const bindNext = inProcess
299
- ? `'select-pane -t :.+ \\; resize-pane -Z'`
300
- : `'select-pane -t :.+'`;
316
+ ? "select-pane -t :.+ \\; resize-pane -Z"
317
+ : "select-pane -t :.+";
301
318
  const bindPrev = inProcess
302
- ? `'select-pane -t :.- \\; resize-pane -Z'`
303
- : `'select-pane -t :.-'`;
304
-
305
- tmux(
306
- `bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`,
307
- );
308
- tmux(
309
- `bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`,
310
- );
319
+ ? "select-pane -t :.- \\; resize-pane -Z"
320
+ : "select-pane -t :.-";
321
+ const bindIf = (key, onMatch, otherwise) =>
322
+ tmux([
323
+ "bind-key",
324
+ "-T",
325
+ "root",
326
+ "-n",
327
+ key,
328
+ "if-shell",
329
+ "-F",
330
+ cond,
331
+ onMatch,
332
+ otherwise,
333
+ ]);
334
+
335
+ bindIf("S-Down", bindNext, "send-keys S-Down");
336
+ bindIf("S-Up", bindPrev, "send-keys S-Up");
311
337
 
312
338
  // 대체 키: 일부 환경에서 S-Up이 누락될 때 사용
313
- tmux(
314
- `bind-key -T root -n S-Right if-shell -F '${cond}' ${bindNext} 'send-keys S-Right'`,
315
- );
316
- tmux(
317
- `bind-key -T root -n S-Left if-shell -F '${cond}' ${bindPrev} 'send-keys S-Left'`,
318
- );
319
- tmux(
320
- `bind-key -T root -n BTab if-shell -F '${cond}' ${bindPrev} 'send-keys BTab'`,
321
- );
339
+ bindIf("S-Right", bindNext, "send-keys S-Right");
340
+ bindIf("S-Left", bindPrev, "send-keys S-Left");
341
+ bindIf("BTab", bindPrev, "send-keys BTab");
322
342
 
323
343
  // 현재 활성 pane 인터럽트
324
- tmux(
325
- `bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`,
326
- );
344
+ bindIf("Escape", "send-keys C-c", "send-keys Escape");
327
345
 
328
346
  // 태스크 목록 토글 (tmux 3.2+ popup 우선, 실패 시 안내 메시지)
329
347
  if (taskListCommand) {
330
348
  const escaped = taskListCommand.replace(/'/g, "'\\''");
331
349
  try {
332
- tmux(
333
- `bind-key -T root -n C-t if-shell -F '${cond}' "display-popup -E '${escaped}'" "send-keys C-t"`,
334
- );
350
+ bindIf("C-t", `display-popup -E '${escaped}'`, "send-keys C-t");
335
351
  } catch {
336
- tmux(
337
- `bind-key -T root -n C-t if-shell -F '${cond}' 'display-message "tfx multi tasks 명령으로 태스크 확인"' 'send-keys C-t'`,
352
+ bindIf(
353
+ "C-t",
354
+ 'display-message "tfx multi tasks 명령으로 태스크 확인"',
355
+ "send-keys C-t",
338
356
  );
339
357
  }
340
358
  }
@@ -432,7 +450,8 @@ export function sessionExists(sessionName) {
432
450
  }
433
451
 
434
452
  try {
435
- tmux(`has-session -t ${sessionName}`, { stdio: "ignore" });
453
+ // '=' 가 없으면 tmux 가 접두사로 다른 세션을 잡는다(#548).
454
+ tmux(["has-session", "-t", `=${sessionName}`], { stdio: "ignore" });
436
455
  return true;
437
456
  } catch {
438
457
  return false;
@@ -450,7 +469,7 @@ export function killSession(sessionName) {
450
469
  }
451
470
 
452
471
  try {
453
- tmux(`kill-session -t ${sessionName}`, { stdio: "ignore" });
472
+ tmux(["kill-session", "-t", `=${sessionName}`], { stdio: "ignore" });
454
473
  } catch {
455
474
  // 이미 종료된 세션 — 무시
456
475
  }
@@ -466,7 +485,7 @@ export function listSessions() {
466
485
  }
467
486
 
468
487
  try {
469
- const output = tmux('list-sessions -F "#{session_name}"');
488
+ const output = tmux(["list-sessions", "-F", "#{session_name}"]);
470
489
  return output.split("\n").filter((s) => s.startsWith("tfx-multi-"));
471
490
  } catch {
472
491
  return [];
@@ -484,9 +503,11 @@ export function getSessionAttachedCount(sessionName) {
484
503
  }
485
504
 
486
505
  try {
487
- const output = tmux(
488
- 'list-sessions -F "#{session_name} #{session_attached}"',
489
- );
506
+ const output = tmux([
507
+ "list-sessions",
508
+ "-F",
509
+ "#{session_name} #{session_attached}",
510
+ ]);
490
511
  const line = output
491
512
  .split("\n")
492
513
  .find((l) => l.startsWith(`${sessionName} `));
@@ -511,7 +532,7 @@ export function capturePaneOutput(target, lines = 5) {
511
532
 
512
533
  try {
513
534
  // -l 플래그는 일부 tmux 빌드(MSYS2)에서 미지원 → 전체 캡처 후 JS에서 절삭
514
- const full = tmux(`capture-pane -t ${target} -p`);
535
+ const full = tmux(["capture-pane", "-t", target, "-p"]);
515
536
  const nonEmpty = full.split("\n").filter((l) => l.trim() !== "");
516
537
  return nonEmpty.slice(-lines).join("\n");
517
538
  } catch {
@@ -1,4 +1,4 @@
1
- import { exec as defaultExec, spawnSync } from "node:child_process";
1
+ import { spawnSync } from "node:child_process";
2
2
  import { platform as osPlatform } from "node:os";
3
3
  import { psmuxExec as defaultPsmuxExec } from "./psmux.mjs";
4
4
  import { tmuxExec as defaultTmuxExec, detectMultiplexer } from "./session.mjs";
@@ -17,8 +17,9 @@ export function shellQuote(value) {
17
17
  return `'${String(value).replace(/'/g, "'\\''")}'`;
18
18
  }
19
19
 
20
+ // PowerShell 은 ‘ ’ ‚ ‛ 도 작은따옴표로 읽는다. 따옴표 문자를 모두 겹쳐 써야 인용이 안 끝난다.
20
21
  export function powershellSingleQuote(value) {
21
- return `'${String(value).replace(/'/g, "''")}'`;
22
+ return `'${String(value).replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`;
22
23
  }
23
24
 
24
25
  export function buildCommandString({ cwd, command }) {
@@ -48,14 +49,6 @@ function createTabSpec(spec, title) {
48
49
  };
49
50
  }
50
51
 
51
- function execOpenTerminal(execFn) {
52
- return new Promise((resolve) => {
53
- execFn("open -a Terminal", { timeout: 5000 }, (error) => {
54
- resolve(!error);
55
- });
56
- });
57
- }
58
-
59
52
  function isTmuxLikeMux(mux) {
60
53
  return TMUX_LIKE_MUXES.has(mux);
61
54
  }
@@ -125,7 +118,6 @@ export function createTerminalOpener(deps = {}) {
125
118
  const platform = resolvePlatform(deps);
126
119
  const tmuxExec = deps.tmuxExec || defaultTmuxExec;
127
120
  const psmuxExec = deps.psmuxExec || defaultPsmuxExec;
128
- const exec = deps.exec || defaultExec;
129
121
  const createWtManager = deps.createWtManager || defaultCreateWtManager;
130
122
 
131
123
  async function openCommand(spec = {}) {
@@ -162,10 +154,8 @@ export function createTerminalOpener(deps = {}) {
162
154
  }
163
155
  }
164
156
 
165
- if (platform === "darwin") {
166
- return execOpenTerminal(exec);
167
- }
168
-
157
+ // 멀티플렉서가 없으면 명령을 실을 경로가 없다. 빈 Terminal 창을 띄우고
158
+ // 성공이라 하지 않고 실패를 돌려준다(macOS 포함).
169
159
  return false;
170
160
  }
171
161
 
@@ -238,10 +228,6 @@ export function createTerminalOpener(deps = {}) {
238
228
  return { openCommand, openSession, focusPane };
239
229
  }
240
230
 
241
- export function openSessionTarget(sessionName, opts = {}) {
242
- return createTerminalOpener(opts._deps).openSession(sessionName, opts);
243
- }
244
-
245
231
  export function focusSessionPane(sessionName, workerNumber, opts = {}) {
246
232
  return createTerminalOpener(opts._deps).focusPane(sessionName, workerNumber);
247
233
  }
@@ -6,9 +6,9 @@
6
6
 
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { existsSync, readFileSync, statSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
9
  import { join } from "node:path";
11
10
  import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
11
+ import { privateTmpDir } from "@triflux/core/hub/lib/private-tmp.mjs";
12
12
  import { openHeadlessDashboardTarget } from "./dashboard-open.mjs";
13
13
  import { processHandoff } from "./handoff.mjs";
14
14
  import { getMultiplexerType } from "./psmux.mjs";
@@ -23,7 +23,7 @@ function argVal(flag) {
23
23
  }
24
24
 
25
25
  const SESSION = argVal("--session");
26
- const RESULT_DIR = argVal("--result-dir") ?? join(tmpdir(), "tfx-headless");
26
+ const RESULT_DIR = argVal("--result-dir") ?? privateTmpDir("tfx-headless");
27
27
  const LAYOUT = argVal("--layout") ?? "single";
28
28
  const LEAD_PANE = argVal("--lead-pane");
29
29
  const MUX_BIN = getMultiplexerType();
package/hub/team/tui.mjs CHANGED
@@ -290,8 +290,6 @@ const SUMMARY_KEYS = [
290
290
  "partial_output",
291
291
  ];
292
292
 
293
- // 텍스트/상태/색상 유틸은 tui-core.mjs에서 import (위 참조)
294
-
295
293
  // ── MOCHA RGB (gradual fade 보간용) ──
296
294
  const MOCHA_RGB = {
297
295
  ok: { r: 166, g: 227, b: 161 },
@@ -380,8 +378,6 @@ function dedupeRole(role, name, cli) {
380
378
  return r;
381
379
  }
382
380
 
383
- // wrapLine, wrapTextAll → tui-core.mjs에서 import
384
-
385
381
  // ── virtual row buffer ────────────────────────────────────────────────────
386
382
  class RowBuffer {
387
383
  constructor() {
@@ -415,8 +411,6 @@ class RowBuffer {
415
411
  }
416
412
  }
417
413
 
418
- // countStatuses → tui-core.mjs에서 import
419
-
420
414
  // ── Tier1: 상단 고정 1행 ─────────────────────────────────────────────────
421
415
  function phaseColor(phase, time = Date.now()) {
422
416
  const shimmer = currentShimmer(time);
@@ -1,13 +1,8 @@
1
1
  // hub/workers/factory.mjs — Worker 생성 팩토리
2
- //
3
- // Supported worker types:
4
- // - 'claude' → ClaudeWorker
5
- // - 'codex' → CodexMcpWorker
6
- //
7
2
  import { ClaudeWorker } from "./claude-worker.mjs";
8
3
 
9
4
  /**
10
- * @param {'claude'|'codex'} type
5
+ * @param {'claude'} type
11
6
  * @param {object} [opts]
12
7
  * @returns {Promise<import('./interface.mjs').IWorker>}
13
8
  */
@@ -15,10 +10,6 @@ export async function createWorker(type, opts = {}) {
15
10
  switch (type) {
16
11
  case "claude":
17
12
  return new ClaudeWorker(opts);
18
- case "codex": {
19
- const { CodexMcpWorker } = await import("./codex-mcp.mjs");
20
- return new CodexMcpWorker(opts);
21
- }
22
13
  default:
23
14
  throw new Error(`Unknown worker type: ${type}`);
24
15
  }
@@ -59,10 +59,3 @@
59
59
  * @property {() => boolean} isReady
60
60
  * @property {'codex'|'gemini'|'antigravity'|'claude'} type
61
61
  */
62
-
63
- export const WORKER_TYPES = Object.freeze([
64
- "codex",
65
- "gemini",
66
- "antigravity",
67
- "claude",
68
- ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.53.0",
3
+ "version": "10.55.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -9,17 +9,13 @@
9
9
  "./hub/*": "./hub/*"
10
10
  },
11
11
  "engines": {
12
- "node": ">=20"
12
+ "node": ">=22"
13
13
  },
14
14
  "peerDependencies": {
15
15
  "@triflux/core": "^10.0.0-alpha.1"
16
16
  },
17
- "dependencies": {
18
- "@modelcontextprotocol/sdk": "^1.27.1"
19
- },
20
17
  "files": [
21
18
  "hub",
22
- "cto",
23
19
  "scripts/lib"
24
20
  ],
25
21
  "keywords": [