triflux 7.5.1 → 8.0.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.
@@ -32,7 +32,7 @@ export class GeminiBackend {
32
32
  command() { return "gemini"; }
33
33
 
34
34
  buildArgs(prompt, resultFile, opts = {}) {
35
- return `gemini --prompt ${prompt} -o text > '${resultFile}' 2>'${resultFile}.err'`;
35
+ return `gemini --prompt ${prompt} --output text > '${resultFile}' 2>'${resultFile}.err'`;
36
36
  }
37
37
 
38
38
  env() { return {}; }
@@ -302,6 +302,13 @@ function sendLiteralToPane(paneId, text, submit = true) {
302
302
  }
303
303
  }
304
304
 
305
+ export function sendKeysToPane(paneId, text, submit = true) {
306
+ psmuxExec(["send-keys", "-t", paneId, "-l", text]);
307
+ if (submit) {
308
+ psmuxExec(["send-keys", "-t", paneId, "Enter"]);
309
+ }
310
+ }
311
+
305
312
  function toPatternRegExp(pattern) {
306
313
  if (pattern instanceof RegExp) {
307
314
  const flags = pattern.flags.includes("m") ? pattern.flags : `${pattern.flags}m`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "7.5.1",
3
+ "version": "8.0.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,15 +4,34 @@
4
4
  // Usage:
5
5
  // node remote-spawn.mjs --local [--dir <path>] [--prompt "..."] [--handoff <file>]
6
6
  // node remote-spawn.mjs --host <ssh-host> [--dir <path>] [--prompt "..."] [--handoff <file>]
7
+ // node remote-spawn.mjs --send <session> "prompt"
8
+ // node remote-spawn.mjs --list
9
+ // node remote-spawn.mjs --attach <session>
10
+ // node remote-spawn.mjs --probe <ssh-host>
7
11
 
12
+ import { randomUUID } from "crypto";
8
13
  import { execFileSync, spawn } from "child_process";
9
- import { readFileSync, existsSync, statSync } from "fs";
10
- import { resolve, join } from "path";
11
- import { homedir, platform } from "os";
14
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
15
+ import { homedir, platform as getPlatform, tmpdir } from "os";
16
+ import { join, posix as posixPath, resolve, win32 as win32Path } from "path";
17
+ import {
18
+ attachPsmuxSession,
19
+ capturePsmuxPane,
20
+ createPsmuxSession,
21
+ hasPsmux,
22
+ listPsmuxSessions,
23
+ psmuxExec,
24
+ psmuxSessionExists,
25
+ sendKeysToPane,
26
+ startCapture,
27
+ waitForPattern,
28
+ } from "../hub/team/psmux.mjs";
12
29
 
13
30
  const MAX_HANDOFF_BYTES = 1 * 1024 * 1024; // 1 MB
14
-
15
- // ── 입력 검증 ──
31
+ const REMOTE_ENV_TTL_MS = 86_400_000;
32
+ const REMOTE_ENV_CACHE_DIR = resolve(".omc", "state", "remote-env");
33
+ const SSH_PROMPT_PATTERN = /(\$|%|#|PS |>)\s*$/;
34
+ const IS_WINDOWS_LOCAL = getPlatform() === "win32";
16
35
 
17
36
  const SAFE_HOST_RE = /^[a-zA-Z0-9._-]+$/;
18
37
  const SAFE_DIR_RE = /^[a-zA-Z0-9_.~\/:\\-]+$/;
@@ -33,60 +52,162 @@ function validateDir(dir) {
33
52
  return dir;
34
53
  }
35
54
 
36
- function shellQuote(s) {
37
- return "'" + s.replace(/'/g, "'\\''") + "'";
55
+ function shellQuote(value) {
56
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
57
+ }
58
+
59
+ function escapePwshSingleQuoted(value) {
60
+ return String(value).replace(/'/g, "''");
61
+ }
62
+
63
+ function escapePwshDoubleQuoted(value) {
64
+ return String(value).replace(/`/g, "``").replace(/"/g, '`"');
65
+ }
66
+
67
+ function normalizeCommandPath(value) {
68
+ return String(value).replace(/\\/g, "/");
38
69
  }
39
70
 
40
- // ── CLI 파싱 ──
71
+ function sleepMs(ms) {
72
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
73
+ }
74
+
75
+ function usageText() {
76
+ return `Usage:
77
+ remote-spawn --local [--dir <path>] [--prompt "task"] [--handoff <file>]
78
+ remote-spawn --host <ssh-host> [--dir <path>] [--prompt "task"] [--handoff <file>]
79
+ remote-spawn --send <session> "prompt"
80
+ remote-spawn --list
81
+ remote-spawn --attach <session>
82
+ remote-spawn --probe <ssh-host>
83
+
84
+ Options:
85
+ --local 로컬 WT 탭에서 Claude 실행
86
+ --host <name> SSH 호스트로 원격 Claude 실행
87
+ --dir <path> 작업 디렉토리 (기본: 현재 디렉토리 / 원격 홈)
88
+ --prompt "..." Claude에 전달할 첫 메시지
89
+ --handoff <file> 핸드오프 파일 경로 (prompt와 결합 가능)
90
+ --send <session> 실행 중인 세션에 프롬프트 전송
91
+ --list tfx-spawn-* psmux 세션 목록
92
+ --attach <name> WT 새 탭에서 세션 attach
93
+ --probe <host> SSH 원격 환경 강제 프로브 + 캐시 갱신
94
+ --capture <name> 세션 pane 내용 캡처 출력
95
+ --wait <name> 세션의 Claude 준비 완료 대기 (기본 60초)`;
96
+ }
41
97
 
42
98
  function parseArgs(argv) {
43
- const args = { host: null, dir: null, prompt: null, handoff: null, local: false };
44
- for (let i = 2; i < argv.length; i++) {
45
- const a = argv[i];
46
- if (a === "--local") { args.local = true; continue; }
47
- if (a === "--host" && argv[i + 1]) { args.host = validateHost(argv[++i]); continue; }
48
- if (a === "--dir" && argv[i + 1]) { args.dir = validateDir(argv[++i]); continue; }
49
- if (a === "--prompt" && argv[i + 1]) { args.prompt = argv[++i]; continue; }
50
- if (a === "--handoff" && argv[i + 1]) { args.handoff = argv[++i]; continue; }
51
- // 미지정 인자는 prompt로 처리
52
- if (!args.prompt) args.prompt = a;
99
+ let command = "spawn";
100
+ let host = null;
101
+ let dir = null;
102
+ let prompt = null;
103
+ let handoff = null;
104
+ let local = false;
105
+ let sessionName = null;
106
+ let probeHost = null;
107
+ const promptParts = [];
108
+
109
+ for (let index = 2; index < argv.length; index += 1) {
110
+ const arg = argv[index];
111
+
112
+ if (arg === "--local") {
113
+ local = true;
114
+ continue;
115
+ }
116
+ if (arg === "--host" && argv[index + 1]) {
117
+ host = validateHost(argv[index + 1]);
118
+ index += 1;
119
+ continue;
120
+ }
121
+ if (arg === "--dir" && argv[index + 1]) {
122
+ dir = validateDir(argv[index + 1]);
123
+ index += 1;
124
+ continue;
125
+ }
126
+ if (arg === "--prompt" && argv[index + 1]) {
127
+ prompt = argv[index + 1];
128
+ index += 1;
129
+ continue;
130
+ }
131
+ if (arg === "--handoff" && argv[index + 1]) {
132
+ handoff = argv[index + 1];
133
+ index += 1;
134
+ continue;
135
+ }
136
+ if (arg === "--send" && argv[index + 1]) {
137
+ command = "send";
138
+ sessionName = argv[index + 1];
139
+ index += 1;
140
+ continue;
141
+ }
142
+ if (arg === "--list") {
143
+ command = "list";
144
+ continue;
145
+ }
146
+ if (arg === "--attach" && argv[index + 1]) {
147
+ command = "attach";
148
+ sessionName = argv[index + 1];
149
+ index += 1;
150
+ continue;
151
+ }
152
+ if (arg === "--probe" && argv[index + 1]) {
153
+ command = "probe";
154
+ probeHost = validateHost(argv[index + 1]);
155
+ index += 1;
156
+ continue;
157
+ }
158
+ if (arg === "--capture" && argv[index + 1]) {
159
+ command = "capture";
160
+ sessionName = argv[index + 1];
161
+ index += 1;
162
+ continue;
163
+ }
164
+ if (arg === "--wait" && argv[index + 1]) {
165
+ command = "wait";
166
+ sessionName = argv[index + 1];
167
+ index += 1;
168
+ continue;
169
+ }
170
+
171
+ promptParts.push(arg);
53
172
  }
54
- return args;
55
- }
56
173
 
57
- // ── Claude 실행 경로 감지 ──
174
+ const mergedPrompt = prompt ?? (promptParts.length > 0 ? promptParts.join(" ") : null);
175
+ return {
176
+ command,
177
+ dir,
178
+ handoff,
179
+ host,
180
+ local,
181
+ probeHost,
182
+ prompt: mergedPrompt,
183
+ sessionName,
184
+ };
185
+ }
58
186
 
59
187
  function detectClaudePath() {
60
- // 1. 환경변수 오버라이드
61
188
  if (process.env.CLAUDE_BIN_PATH) return process.env.CLAUDE_BIN_PATH;
62
189
 
63
- // 2. WinGet Links
64
190
  const wingetPath = join(homedir(), "AppData", "Local", "Microsoft", "WinGet", "Links", "claude.exe");
65
191
  if (existsSync(wingetPath)) return wingetPath;
66
192
 
67
- // 3. npm global
68
193
  const npmPath = join(process.env.APPDATA || "", "npm", "claude.cmd");
69
194
  if (existsSync(npmPath)) return npmPath;
70
195
 
71
- // 3. PATH에서 찾기
72
196
  try {
73
- const cmd = platform() === "win32" ? "where" : "which";
74
- const result = execFileSync(cmd, ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
75
- if (result) return result.split("\n")[0].trim();
76
- } catch { /* not found */ }
197
+ const command = IS_WINDOWS_LOCAL ? "where" : "which";
198
+ const result = execFileSync(command, ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
199
+ if (result) return result.split(/\r?\n/u)[0].trim();
200
+ } catch {
201
+ // not found
202
+ }
77
203
 
78
- return "claude"; // fallback — PATH에 있다고 가정
204
+ return "claude";
79
205
  }
80
206
 
81
- // ── 권한 플래그 ──
82
-
83
207
  function getPermissionFlag() {
84
- if (process.env.TFX_CLAUDE_SAFE_MODE === "1") return [];
85
- return ["--PLACEHOLDER_PERM_FLAG"];
208
+ return process.env.TFX_CLAUDE_SAFE_MODE === "1" ? [] : ["--dangerously-skip-permissions"];
86
209
  }
87
210
 
88
- // ── 핸드오프 컨텐츠 생성 ──
89
-
90
211
  function buildPrompt(args) {
91
212
  let content = "";
92
213
 
@@ -111,13 +232,10 @@ function buildPrompt(args) {
111
232
  return content;
112
233
  }
113
234
 
114
- // ── 로컬 Spawn (WT 탭) ──
115
-
116
- function spawnLocal(args, claudePath, prompt) {
235
+ function spawnLocalFallback(args, claudePath, prompt) {
117
236
  const dir = args.dir ? resolve(args.dir) : process.cwd();
118
237
 
119
- if (platform() !== "win32") {
120
- // Linux/macOS: 직접 실행
238
+ if (!IS_WINDOWS_LOCAL) {
121
239
  const cliArgs = [...getPermissionFlag()];
122
240
  if (prompt) cliArgs.push(prompt);
123
241
 
@@ -129,15 +247,15 @@ function spawnLocal(args, claudePath, prompt) {
129
247
  return;
130
248
  }
131
249
 
132
- // Windows: wt.exe new-tab
133
250
  const wtArgs = ["new-tab", "-d", dir, "--"];
134
251
  const claudeForward = claudePath.replace(/\\/g, "/");
135
252
 
136
253
  if (prompt) {
137
- // pwsh single-quote: 내부 ' '' 이스케이프
138
- const psQuoted = "'" + prompt.replace(/'/g, "''") + "'";
254
+ const psQuoted = `'${prompt.replace(/'/g, "''")}'`;
139
255
  wtArgs.push(
140
- "pwsh", "-NoProfile", "-Command",
256
+ "pwsh",
257
+ "-NoProfile",
258
+ "-Command",
141
259
  `& '${claudeForward}' ${getPermissionFlag().join(" ")} ${psQuoted}`,
142
260
  );
143
261
  } else {
@@ -147,15 +265,13 @@ function spawnLocal(args, claudePath, prompt) {
147
265
  try {
148
266
  spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false }).unref();
149
267
  console.log(`spawned local Claude in WT tab → ${dir}`);
150
- } catch (err) {
151
- console.error("wt.exe spawn failed:", err.message);
268
+ } catch (error) {
269
+ console.error("wt.exe spawn failed:", error.message);
152
270
  process.exit(1);
153
271
  }
154
272
  }
155
273
 
156
- // ── 원격 Spawn (SSH) ──
157
-
158
- function spawnRemote(args, prompt) {
274
+ function spawnRemoteFallback(args, prompt) {
159
275
  const { host } = args;
160
276
  if (!host) {
161
277
  console.error("--host required for remote spawn");
@@ -163,60 +279,502 @@ function spawnRemote(args, prompt) {
163
279
  }
164
280
 
165
281
  const dir = args.dir || "~";
166
- const quotedDir = shellQuote(dir);
167
- const permFlag = getPermissionFlag().join(" ");
168
- const remoteCmd = prompt
169
- ? `cd ${quotedDir} && claude ${permFlag} ${shellQuote(prompt)}`
170
- : `cd ${quotedDir} && claude ${permFlag}`;
171
-
172
- if (platform() === "win32") {
173
- // WT 탭에서 SSH 세션 열기
282
+ const permFlags = getPermissionFlag();
283
+ const scriptLines = [
284
+ `cd '${dir.replace(/'/g, "''")}'`,
285
+ ];
286
+
287
+ if (prompt) {
288
+ const safePrompt = prompt.replace(/'/g, "''");
289
+ scriptLines.push(`& "$env:USERPROFILE\\.local\\bin\\claude.exe" ${permFlags.join(" ")} '${safePrompt}'`);
290
+ } else {
291
+ scriptLines.push(`& "$env:USERPROFILE\\.local\\bin\\claude.exe" ${permFlags.join(" ")}`);
292
+ }
293
+
294
+ const scriptContent = scriptLines.join("\n");
295
+ const localScript = join(tmpdir(), "tfx-remote-spawn.ps1");
296
+ writeFileSync(localScript, scriptContent, "utf8");
297
+
298
+ try {
299
+ execFileSync("scp", [localScript, `${host}:tfx-remote-spawn.ps1`], { timeout: 10000, stdio: "pipe" });
300
+ } catch (error) {
301
+ console.error("failed to copy script to remote:", error.message);
302
+ process.exit(1);
303
+ }
304
+
305
+ let remoteHome;
306
+ try {
307
+ remoteHome = execFileSync("ssh", [host, "echo", "$env:USERPROFILE"], { encoding: "utf8", timeout: 5000 }).trim();
308
+ } catch {
309
+ remoteHome = `C:\\Users\\${host}`;
310
+ }
311
+
312
+ const remoteScript = `${remoteHome.replace(/\\/g, "/")}/tfx-remote-spawn.ps1`;
313
+ const remoteCmd = `pwsh -NoExit -File ${remoteScript}`;
314
+
315
+ if (IS_WINDOWS_LOCAL) {
174
316
  const wtArgs = [
175
- "new-tab", "--title", `Claude@${host}`, "--",
176
- "ssh", "-t", "--", host, remoteCmd,
317
+ "new-tab",
318
+ "--title",
319
+ `Claude@${host}`,
320
+ "--",
321
+ "ssh",
322
+ "-t",
323
+ "--",
324
+ host,
325
+ remoteCmd,
177
326
  ];
178
-
179
327
  try {
180
328
  spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false }).unref();
181
329
  console.log(`spawned remote Claude → ${host}:${dir}`);
182
- } catch (err) {
183
- console.error("wt.exe spawn failed:", err.message);
330
+ } catch (error) {
331
+ console.error("wt.exe spawn failed:", error.message);
184
332
  process.exit(1);
185
333
  }
186
334
  } else {
187
- // Linux/macOS: 직접 SSH
188
335
  const child = spawn("ssh", ["-t", "--", host, remoteCmd], { stdio: "inherit" });
189
336
  child.on("exit", (code) => process.exit(code || 0));
190
337
  }
191
338
  }
192
339
 
193
- // ── main ──
340
+ function shouldUsePsmux() {
341
+ return IS_WINDOWS_LOCAL && hasPsmux();
342
+ }
343
+
344
+ function requirePsmux() {
345
+ if (!hasPsmux()) {
346
+ throw new Error("psmux is required for this command");
347
+ }
348
+ }
349
+
350
+ function parseProbeLines(text) {
351
+ return Object.fromEntries(
352
+ text
353
+ .split(/\r?\n/u)
354
+ .map((line) => line.trim())
355
+ .filter(Boolean)
356
+ .map((line) => {
357
+ const separatorIndex = line.indexOf("=");
358
+ return separatorIndex === -1
359
+ ? null
360
+ : [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)];
361
+ })
362
+ .filter(Boolean),
363
+ );
364
+ }
365
+
366
+ function normalizePwshProbeEnv(host, parsed) {
367
+ if (parsed.shell !== "pwsh" || parsed.os !== "win32") {
368
+ return null;
369
+ }
370
+
371
+ if (!parsed.home) {
372
+ return null;
373
+ }
374
+
375
+ return Object.freeze({
376
+ claudePath: (!parsed.claude || parsed.claude === "notfound") ? null : parsed.claude,
377
+ home: parsed.home,
378
+ os: "win32",
379
+ shell: "pwsh",
380
+ });
381
+ }
382
+
383
+ function normalizePosixProbeEnv(host, parsed) {
384
+ const os = parsed.os === "darwin" ? "darwin" : parsed.os === "linux" ? "linux" : null;
385
+ if (!os || !parsed.home) {
386
+ return null;
387
+ }
388
+
389
+ return Object.freeze({
390
+ claudePath: (!parsed.claude || parsed.claude === "notfound") ? null : parsed.claude,
391
+ home: parsed.home,
392
+ os,
393
+ shell: parsed.shell === "zsh" ? "zsh" : "bash",
394
+ });
395
+ }
396
+
397
+ function getRemoteEnvCachePath(host) {
398
+ return join(REMOTE_ENV_CACHE_DIR, `${host}.json`);
399
+ }
400
+
401
+ function readRemoteEnvCache(host) {
402
+ const cachePath = getRemoteEnvCachePath(host);
403
+ if (!existsSync(cachePath)) {
404
+ return null;
405
+ }
406
+
407
+ try {
408
+ const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
409
+ return parsed && typeof parsed === "object" ? parsed : null;
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ function isRemoteEnvCacheFresh(cacheEntry) {
416
+ return Boolean(
417
+ cacheEntry
418
+ && typeof cacheEntry.cachedAt === "number"
419
+ && cacheEntry.env
420
+ && (Date.now() - cacheEntry.cachedAt) < REMOTE_ENV_TTL_MS,
421
+ );
422
+ }
423
+
424
+ function writeRemoteEnvCache(host, env) {
425
+ mkdirSync(REMOTE_ENV_CACHE_DIR, { recursive: true });
426
+ writeFileSync(
427
+ getRemoteEnvCachePath(host),
428
+ JSON.stringify({ cachedAt: Date.now(), env }, null, 2),
429
+ "utf8",
430
+ );
431
+ }
432
+
433
+ function probeRemoteEnvViaPwsh(host) {
434
+ const command = [
435
+ "Write-Output 'shell=pwsh'",
436
+ 'Write-Output "home=$env:USERPROFILE"',
437
+ 'if (Test-Path "$env:USERPROFILE\\.local\\bin\\claude.exe") { Write-Output "claude=$env:USERPROFILE\\.local\\bin\\claude.exe" } elseif (Get-Command claude -ErrorAction SilentlyContinue) { Write-Output "claude=$((Get-Command claude).Source)" } else { Write-Output \'claude=notfound\' }',
438
+ 'Write-Output "os=$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows) ? \'win32\' : \'other\')"',
439
+ ].join("; ");
440
+
441
+ let output;
442
+ try {
443
+ output = execFileSync(
444
+ "ssh",
445
+ [host, "pwsh", "-NoProfile", "-Command", command],
446
+ { encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"] },
447
+ );
448
+ } catch {
449
+ return null;
450
+ }
451
+
452
+ return normalizePwshProbeEnv(host, parseProbeLines(output));
453
+ }
454
+
455
+ function probeRemoteEnvViaPosix(host) {
456
+ const script = [
457
+ "echo shell=$(basename $SHELL)",
458
+ "echo home=$HOME",
459
+ "command -v claude >/dev/null 2>&1 && echo claude=$(command -v claude) || echo claude=notfound",
460
+ "echo os=$(uname -s | tr A-Z a-z)",
461
+ ].join("\n");
462
+
463
+ let output;
464
+ try {
465
+ output = execFileSync("ssh", [host, "sh"], {
466
+ encoding: "utf8",
467
+ timeout: 15000,
468
+ input: script,
469
+ });
470
+ } catch {
471
+ return null;
472
+ }
473
+
474
+ return normalizePosixProbeEnv(host, parseProbeLines(output));
475
+ }
476
+
477
+ function probeRemoteEnv(host, opts = {}) {
478
+ const force = opts.force === true;
479
+
480
+ if (!force) {
481
+ const cached = readRemoteEnvCache(host);
482
+ if (isRemoteEnvCacheFresh(cached)) {
483
+ return cached.env;
484
+ }
485
+ }
486
+
487
+ const pwshEnv = probeRemoteEnvViaPwsh(host);
488
+ if (pwshEnv) {
489
+ writeRemoteEnvCache(host, pwshEnv);
490
+ return pwshEnv;
491
+ }
492
+
493
+ const posixEnv = probeRemoteEnvViaPosix(host);
494
+ if (posixEnv) {
495
+ writeRemoteEnvCache(host, posixEnv);
496
+ return posixEnv;
497
+ }
498
+
499
+ throw new Error(`remote probe failed for ${host}`);
500
+ }
501
+
502
+ function isWindowsAbsolutePath(value) {
503
+ return /^[a-zA-Z]:[\\/]/u.test(value) || value.startsWith("\\\\");
504
+ }
505
+
506
+ function resolveRemoteDir(dir, env) {
507
+ const requestedDir = dir || env.home;
508
+
509
+ if (env.os === "win32") {
510
+ const winDir = requestedDir.replace(/\//g, "\\");
511
+ if (winDir === "~") return env.home;
512
+ if (/^~[\\/]/u.test(winDir)) return win32Path.join(env.home, winDir.slice(2));
513
+ if (isWindowsAbsolutePath(winDir)) return winDir;
514
+ return win32Path.join(env.home, winDir);
515
+ }
194
516
 
195
- function main() {
517
+ if (requestedDir === "~") return env.home;
518
+ if (requestedDir.startsWith("~/")) return posixPath.join(env.home, requestedDir.slice(2));
519
+ if (requestedDir.startsWith("/")) return requestedDir;
520
+ return posixPath.join(env.home, requestedDir);
521
+ }
522
+
523
+ function listSessionNamesFromRawOutput(output) {
524
+ return output
525
+ .split(/\r?\n/u)
526
+ .map((line) => line.trim())
527
+ .filter(Boolean)
528
+ .map((line) => line.split(":")[0]?.trim())
529
+ .filter(Boolean);
530
+ }
531
+
532
+ function listSpawnSessions() {
533
+ const helperSessions = listPsmuxSessions().filter((name) => name.startsWith("tfx-spawn-"));
534
+ if (helperSessions.length > 0) {
535
+ return helperSessions;
536
+ }
537
+
538
+ try {
539
+ return listSessionNamesFromRawOutput(psmuxExec(["list-sessions"]))
540
+ .filter((name) => name.startsWith("tfx-spawn-"));
541
+ } catch {
542
+ return [];
543
+ }
544
+ }
545
+
546
+ function openAttachTab(sessionName, title = null) {
547
+ if (IS_WINDOWS_LOCAL) {
548
+ const wtArgs = title
549
+ ? ["new-tab", "--title", title, "--", "psmux", "attach", "-t", sessionName]
550
+ : ["new-tab", "--", "psmux", "attach", "-t", sessionName];
551
+ spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false }).unref();
552
+ return;
553
+ }
554
+
555
+ attachPsmuxSession(sessionName);
556
+ }
557
+
558
+ function getLastNonEmptyLine(text) {
559
+ const lines = String(text)
560
+ .split(/\r?\n/u)
561
+ .map((line) => line.trimEnd())
562
+ .filter((line) => line.trim().length > 0);
563
+ return lines.at(-1) || "";
564
+ }
565
+
566
+ async function waitForRemotePrompt(sessionName, paneId) {
567
+ const baseline = capturePsmuxPane(paneId, 20);
568
+ const capture = startCapture(sessionName, paneId);
569
+ const deadline = Date.now() + 15_000;
570
+
571
+ while (Date.now() <= deadline) {
572
+ const remainingMs = Math.max(250, deadline - Date.now());
573
+ await waitForPattern(
574
+ sessionName,
575
+ paneId,
576
+ SSH_PROMPT_PATTERN,
577
+ Math.min(1, remainingMs / 1000),
578
+ { logPath: capture.logPath },
579
+ );
580
+
581
+ const tail = capturePsmuxPane(paneId, 20);
582
+ const lastLine = getLastNonEmptyLine(tail);
583
+ if (tail !== baseline && SSH_PROMPT_PATTERN.test(lastLine)) {
584
+ return;
585
+ }
586
+ }
587
+
588
+ throw new Error(`ssh prompt wait timed out for ${sessionName}: ${capturePsmuxPane(paneId, 20)}`);
589
+ }
590
+
591
+ function spawnLocal(args, claudePath, prompt) {
592
+ if (!shouldUsePsmux()) {
593
+ spawnLocalFallback(args, claudePath, prompt);
594
+ return;
595
+ }
596
+
597
+ const dir = args.dir ? resolve(args.dir) : process.cwd();
598
+ const sessionName = `tfx-spawn-${randomUUID().slice(0, 8)}`;
599
+ const paneId = `${sessionName}:0.0`;
600
+ const permissionFlags = getPermissionFlag().join(" ");
601
+ const command = `& '${escapePwshSingleQuoted(normalizeCommandPath(claudePath))}'${permissionFlags ? ` ${permissionFlags}` : ""}`;
602
+
603
+ createPsmuxSession(sessionName, { layout: "1xN", paneCount: 1 });
604
+ sendKeysToPane(paneId, `cd '${escapePwshSingleQuoted(dir)}'`);
605
+ sleepMs(500);
606
+ sendKeysToPane(paneId, command);
607
+ if (prompt) {
608
+ sleepMs(2000);
609
+ sendKeysToPane(paneId, prompt);
610
+ }
611
+ openAttachTab(sessionName, "Claude@local");
612
+ console.log(sessionName);
613
+ }
614
+
615
+ async function spawnRemote(args, prompt) {
616
+ const { host } = args;
617
+ if (!host) {
618
+ console.error("--host required for remote spawn");
619
+ process.exit(1);
620
+ }
621
+
622
+ if (!shouldUsePsmux()) {
623
+ spawnRemoteFallback(args, prompt);
624
+ return;
625
+ }
626
+
627
+ const env = probeRemoteEnv(host);
628
+ if (!env.claudePath) {
629
+ console.error(`claude not found on ${host}. Install Claude Code on the remote host first.`);
630
+ process.exit(1);
631
+ }
632
+ const resolvedDir = resolveRemoteDir(args.dir, env);
633
+ const sessionName = `tfx-spawn-${host}-${randomUUID().slice(0, 8)}`;
634
+ const paneId = `${sessionName}:0.0`;
635
+ const permissionFlags = getPermissionFlag().join(" ");
636
+
637
+ createPsmuxSession(sessionName, { layout: "1xN", paneCount: 1 });
638
+ sendKeysToPane(paneId, `ssh -t ${host}`);
639
+ await waitForRemotePrompt(sessionName, paneId);
640
+
641
+ if (env.shell === "pwsh") {
642
+ const claudeCommand = `& "${escapePwshDoubleQuoted(env.claudePath)}"${permissionFlags ? ` ${permissionFlags}` : ""}`;
643
+ sendKeysToPane(paneId, `cd '${escapePwshSingleQuoted(resolvedDir)}'`);
644
+ sendKeysToPane(paneId, claudeCommand);
645
+ } else {
646
+ const claudeCommand = `${shellQuote(env.claudePath)}${permissionFlags ? ` ${permissionFlags}` : ""}`;
647
+ sendKeysToPane(paneId, `cd ${shellQuote(resolvedDir)}`);
648
+ sendKeysToPane(paneId, claudeCommand);
649
+ }
650
+
651
+ if (prompt) {
652
+ sleepMs(2000);
653
+ sendKeysToPane(paneId, prompt);
654
+ }
655
+
656
+ openAttachTab(sessionName, `Claude@${host}`);
657
+ console.log(sessionName);
658
+ }
659
+
660
+ function sendPromptToSession(sessionName, prompt) {
661
+ requirePsmux();
662
+ if (!psmuxSessionExists(sessionName)) {
663
+ throw new Error(`psmux session not found: ${sessionName}`);
664
+ }
665
+ sendKeysToPane(`${sessionName}:0.0`, prompt);
666
+ }
667
+
668
+ function attachSession(sessionName) {
669
+ requirePsmux();
670
+ if (!psmuxSessionExists(sessionName)) {
671
+ throw new Error(`psmux session not found: ${sessionName}`);
672
+ }
673
+ openAttachTab(sessionName);
674
+ }
675
+
676
+ function captureSession(sessionName, lines = 30) {
677
+ requirePsmux();
678
+ if (!psmuxSessionExists(sessionName)) {
679
+ throw new Error(`psmux session not found: ${sessionName}`);
680
+ }
681
+ return capturePsmuxPane(`${sessionName}:0.0`, lines);
682
+ }
683
+
684
+ async function waitForClaudeReady(sessionName, timeoutSec = 60) {
685
+ requirePsmux();
686
+ if (!psmuxSessionExists(sessionName)) {
687
+ throw new Error(`psmux session not found: ${sessionName}`);
688
+ }
689
+ const paneId = `${sessionName}:0.0`;
690
+ const readyPattern = /(\u276f|\u2795|>\s*$|bypass permissions)/;
691
+ const deadline = Date.now() + timeoutSec * 1000;
692
+
693
+ while (Date.now() <= deadline) {
694
+ const snapshot = capturePsmuxPane(paneId, 5);
695
+ const lastLine = snapshot.split(/\r?\n/).filter((l) => l.trim()).at(-1) || "";
696
+ if (readyPattern.test(lastLine)) {
697
+ return true;
698
+ }
699
+ sleepMs(1000);
700
+ }
701
+ throw new Error(`claude ready wait timed out after ${timeoutSec}s for ${sessionName}`);
702
+ }
703
+
704
+ async function main() {
196
705
  const args = parseArgs(process.argv);
197
706
 
198
- if (!args.local && !args.host) {
199
- console.log(`Usage:
200
- remote-spawn --local [--dir <path>] [--prompt "task"] [--handoff <file>]
201
- remote-spawn --host <ssh-host> [--dir <path>] [--prompt "task"] [--handoff <file>]
707
+ if (args.command === "list") {
708
+ console.log(listSpawnSessions().join("\n"));
709
+ return;
710
+ }
202
711
 
203
- Options:
204
- --local 로컬 WT 탭에서 Claude 실행
205
- --host <name> SSH 호스트로 원격 Claude 실행
206
- --dir <path> 작업 디렉토리 (기본: 현재 디렉토리 / ~)
207
- --prompt "..." Claude에 전달할 첫 메시지
208
- --handoff <file> 핸드오프 파일 경로 (prompt와 결합 가능)`);
209
- process.exit(0);
712
+ if (args.command === "attach") {
713
+ if (!args.sessionName) {
714
+ console.error("--attach requires a session name");
715
+ process.exit(1);
716
+ }
717
+ attachSession(args.sessionName);
718
+ return;
719
+ }
720
+
721
+ if (args.command === "probe") {
722
+ if (!args.probeHost) {
723
+ console.error("--probe requires a host");
724
+ process.exit(1);
725
+ }
726
+ console.log(JSON.stringify(probeRemoteEnv(args.probeHost, { force: true }), null, 2));
727
+ return;
728
+ }
729
+
730
+ if (args.command === "capture") {
731
+ if (!args.sessionName) {
732
+ console.error("--capture requires a session name");
733
+ process.exit(1);
734
+ }
735
+ console.log(captureSession(args.sessionName));
736
+ return;
737
+ }
738
+
739
+ if (args.command === "wait") {
740
+ if (!args.sessionName) {
741
+ console.error("--wait requires a session name");
742
+ process.exit(1);
743
+ }
744
+ await waitForClaudeReady(args.sessionName);
745
+ console.log("ready");
746
+ return;
210
747
  }
211
748
 
212
749
  const prompt = buildPrompt(args);
213
- const claudePath = detectClaudePath();
750
+
751
+ if (args.command === "send") {
752
+ if (!args.sessionName) {
753
+ console.error("--send requires a session name");
754
+ process.exit(1);
755
+ }
756
+ if (!prompt) {
757
+ console.error("--send requires a prompt or --handoff");
758
+ process.exit(1);
759
+ }
760
+ sendPromptToSession(args.sessionName, prompt);
761
+ return;
762
+ }
763
+
764
+ if (!args.local && !args.host) {
765
+ console.log(usageText());
766
+ return;
767
+ }
214
768
 
215
769
  if (args.local) {
216
- spawnLocal(args, claudePath, prompt);
217
- } else {
218
- spawnRemote(args, prompt);
770
+ spawnLocal(args, detectClaudePath(), prompt);
771
+ return;
219
772
  }
773
+
774
+ await spawnRemote(args, prompt);
220
775
  }
221
776
 
222
- main();
777
+ main().catch((error) => {
778
+ console.error(error?.message || String(error));
779
+ process.exit(1);
780
+ });
@@ -1,63 +1,205 @@
1
1
  ---
2
2
  name: remote-spawn
3
- description: 로컬/원격 머신에 Claude 세션을 WT 탭으로 spawn합니다. 핸드오프 전달 지원.
3
+ description: >
4
+ 원격/로컬 머신에 Claude 세션을 psmux 기반으로 spawn하고 관리합니다.
5
+ 자동 핸드오프, 추가 프롬프트 전송, 세션 재부착, 원격 환경 자동 감지를 지원합니다.
6
+ 이 스킬은 다음 상황에서 반드시 사용하세요:
7
+ 원격 실행, 세션 spawn, 다른 머신에서 작업, 원격 Claude, 세션 전달, 핸드오프 전달,
8
+ 원격 세션에 프롬프트 보내기, 세션 목록, 세션 재부착.
9
+ 로컬 호스트 별칭이 references/hosts.json에 등록되어 있으면 호스트명 언급만으로도 트리거됩니다.
4
10
  triggers:
5
11
  - remote-spawn
6
- argument-hint: "[--host <ssh-host>] [--dir <path>] <prompt>"
12
+ argument-hint: "[--host <name>] [--send <session> <prompt>] [--list] [--attach] <prompt or natural language>"
7
13
  ---
8
14
 
9
- # remote-spawn — 원격/로컬 Claude 세션 Spawn
15
+ # remote-spawn — 원격/로컬 Claude 세션 관리
10
16
 
11
- > WT 탭에서 Claude 세션을 시작하고, 선택적으로 핸드오프 컨텍스트를 전달합니다.
17
+ > psmux 세션 기반으로 Claude 원격/로컬에서 실행하고 관리합니다.
18
+ > 대화 컨텍스트를 자동으로 핸드오프하고, 자연어로 세션을 제어할 수 있습니다.
12
19
 
13
- ## 사용법
20
+ ## 입력 해석
14
21
 
22
+ 사용자 입력을 아래 순서로 매칭한다. 매칭되면 해당 동작 실행.
23
+
24
+ ```
25
+ "ultra4에서 보안 리뷰 해" → spawn(host=ultra4, prompt="보안 리뷰 해")
26
+ "세션 목록 보여줘" → list
27
+ "ultra4 세션에 테스트도 해달라고 전달해" → send(session=auto-detect, prompt="테스트도 해달라고")
28
+ "아까 그 세션 다시 열어" → attach(session=most-recent)
29
+ "로컬에서 리팩터링 이어서" → spawn(local, prompt="리팩터링 이어서")
30
+ ```
31
+
32
+ ### 호스트 감지
33
+
34
+ 1. `--host <name>` 명시 → 그대로 사용
35
+ 2. `references/hosts.json` 에 등록된 별칭/키워드 매칭 → 호스트 자동 해석
36
+ 3. "원격에서", "다른 머신에서" → hosts.json의 default 호스트 사용
37
+ 4. 매칭 없음 + --local 없음 → 사용자에게 "어떤 호스트에서 실행할까요?" 질문
38
+
39
+ `references/hosts.json`이 없으면 --host를 명시적으로 요구한다.
40
+
41
+ ### 동작 분류
42
+
43
+ | 패턴 | 동작 | 설명 |
44
+ |------|------|------|
45
+ | 호스트 + 프롬프트 | **spawn** | 원격 Claude 세션 생성 |
46
+ | --local / "로컬에서" | **spawn local** | 로컬 Claude 세션 생성 |
47
+ | "전달해", "보내줘", --send | **send** | 기존 세션에 프롬프트 전송 |
48
+ | "목록", "세션 리스트", --list | **list** | 활성 세션 목록 |
49
+ | "다시 열어", "재부착", --attach | **attach** | WT 탭에 세션 재부착 |
50
+ | "환경 확인", --probe | **probe** | 원격 환경 프로브 (강제 갱신) |
51
+
52
+ ## 실행 워크플로우
53
+
54
+ ### spawn — 세션 생성 (핵심)
55
+
56
+ spawn은 3단계로 동작한다:
57
+
58
+ **1단계: 핸드오프 생성 (자동)**
59
+
60
+ 사용자가 프롬프트만 준 경우, 현재 대화 맥락에서 핸드오프를 자동 생성한다.
61
+ 핸드오프는 원격 Claude가 작업을 이해하는 데 필요한 최소 컨텍스트다.
62
+
63
+ 핸드오프 구조:
15
64
  ```
16
- /remote-spawn 리팩터링 작업 이어서 해줘
17
- /remote-spawn --host ultra4 보안 리뷰 진행해
18
- /remote-spawn --host ultra4 --dir ~/Desktop/Projects/gamma API 점검
65
+ ## 작업 컨텍스트
66
+ - 현재 프로젝트: {프로젝트 경로}
67
+ - 작업 중인 파일: {최근 수정 파일 목록}
68
+ - 진행 상황: {현재 대화에서 완료한 것}
69
+
70
+ ## 태스크
71
+ {사용자 프롬프트 또는 추출된 작업 지시}
72
+
73
+ ## 참고
74
+ - {관련 결정 사항이나 제약}
19
75
  ```
20
76
 
21
- ## 동작
77
+ 핸드오프 생성 후 임시 파일에 저장: `.omc/handoff-{uuid8}.md`
22
78
 
23
- 1. **인자 파싱**: `--host`, `--dir`, 나머지는 prompt
24
- 2. **핸드오프 생성** (선택): 현재 세션 컨텍스트에서 최소 핸드오프 생성
25
- 3. **세션 Spawn**: `scripts/remote-spawn.mjs` 호출
79
+ 사용자가 명시적으로 `--handoff <file>` 준 경우, 자동 생성 대신 해당 파일 사용.
80
+ 사용자가 `/mp`로 생성한 핸드오프가 있으면 그것을 우선 사용.
26
81
 
27
- ## 실행 규칙
82
+ **2단계: 환경 확인**
28
83
 
29
- ### 로컬 (--host 미지정)
84
+ 원격 호스트의 프로브 결과를 확인한다. 캐시가 있으면 캐시 사용.
30
85
 
31
86
  ```bash
32
- node scripts/remote-spawn.mjs --local --dir "${DIR}" --prompt "${PROMPT}"
87
+ node scripts/remote-spawn.mjs --probe {host}
88
+ ```
89
+
90
+ 프로브 결과에 따른 분기:
91
+
92
+ | claudePath | 동작 |
93
+ |------------|------|
94
+ | 유효한 경로 | 정상 진행 |
95
+ | null | 설치 안내 출력 후 사용자 확인 대기 |
96
+
97
+ **claude 미설치 시 안내 메시지:**
98
+
99
+ ```
100
+ {host}에 Claude Code가 설치되어 있지 않습니다.
101
+
102
+ 설치 방법:
103
+ macOS/Linux: npm install -g @anthropic-ai/claude-code
104
+ Windows: winget install Anthropic.ClaudeCode
105
+
106
+ 설치 후 `--probe {host}` 로 환경을 갱신하세요.
107
+ 또는 이 호스트에서 다른 CLI(codex, gemini)로 작업하시겠습니까?
33
108
  ```
34
- - 새 WT 탭에서 Claude 실행
35
- - `--dir` 미지정 시 현재 디렉토리
36
109
 
37
- ### 원격 (--host 지정)
110
+ **3단계: 세션 실행**
38
111
 
39
112
  ```bash
40
- node scripts/remote-spawn.mjs --host "${HOST}" --dir "${DIR}" --prompt "${PROMPT}"
113
+ node scripts/remote-spawn.mjs --host {host} --dir {dir} --prompt {prompt} --handoff {handoff_file}
41
114
  ```
42
- - WT 탭에서 SSH 세션 열고 원격 Claude 실행
43
- - `--dir` 미지정 시 `~`
44
- - 원격 Claude는 자기 환경(CLAUDE.md, 훅, MCP)을 이미 알고 있으므로 태스크만 전달
45
115
 
46
- ### 핸드오프 모드
116
+ 실행 세션 이름을 사용자에게 알려준다:
117
+ ```
118
+ spawned: tfx-spawn-ultra4-a1b2c3d4
119
+ WT 탭에서 Claude@ultra4 세션이 열립니다.
120
+ 추가 프롬프트: /remote-spawn --send tfx-spawn-ultra4-a1b2c3d4 "다음 작업"
121
+ ```
122
+
123
+ ### send — 프롬프트 전송
47
124
 
48
- 현재 세션에서 핸드오프를 생성한 전달하려면:
125
+ 사용자가 세션 이름을 명시하지 않으면, 해당 호스트의 가장 최근 세션을 자동 감지한다.
49
126
 
50
- 1. `/mp`로 핸드오프 파일 생성
51
- 2. `/remote-spawn --host ultra4 --handoff .omc/handoff-xxx.md`
127
+ ```bash
128
+ # 세션 이름 명시
129
+ node scripts/remote-spawn.mjs --send tfx-spawn-ultra4-a1b2c3d4 --prompt "{prompt}"
130
+
131
+ # 호스트명으로 자동 감지
132
+ node scripts/remote-spawn.mjs --list # tfx-spawn-ultra4-* 필터링
133
+ # → 가장 최근 세션에 전송
134
+ node scripts/remote-spawn.mjs --send {detected_session} --prompt "{prompt}"
135
+ ```
52
136
 
53
- 또는 Claude가 직접 핸드오프를 인라인으로 구성:
137
+ ### list 세션 목록
54
138
 
55
139
  ```bash
56
- node scripts/remote-spawn.mjs --host ultra4 --prompt "이전 세션에서 JWT 미들웨어 구현 완료. 남은 작업: 테스트 커버리지 80% 달성"
140
+ node scripts/remote-spawn.mjs --list
141
+ ```
142
+
143
+ 결과를 표 형태로 정리해서 보여준다:
144
+ ```
145
+ | 세션 | 호스트 | 상태 |
146
+ |------|--------|------|
147
+ | tfx-spawn-ultra4-a1b2c3d4 | ultra4 | active |
148
+ | tfx-spawn-m2-e5f6g7h8 | m2 | active |
149
+ ```
150
+
151
+ ### attach — 재부착
152
+
153
+ ```bash
154
+ node scripts/remote-spawn.mjs --attach {session_name}
155
+ ```
156
+
157
+ 세션 이름 미지정 시 가장 최근 세션을 사용한다.
158
+
159
+ ### probe — 환경 확인
160
+
161
+ ```bash
162
+ node scripts/remote-spawn.mjs --probe {host}
163
+ ```
164
+
165
+ 결과를 사람이 읽기 좋게 정리:
166
+ ```
167
+ ultra4 환경:
168
+ OS: Windows (win32)
169
+ Shell: pwsh
170
+ Home: C:\Users\SSAFY
171
+ Claude: C:\Users\SSAFY\.local\bin\claude.exe
57
172
  ```
58
173
 
59
174
  ## 전제 조건
60
175
 
61
- - `remoteControlAtStartup: true` settings.json에 설정됨 (`triflux setup`으로 자동)
62
- - 원격 호스트: SSH config에 등록 + Claude Code 설치
63
- - 로컬: Windows Terminal 설치
176
+ - **psmux** 설치 (권장, 전체 기능). 미설치 기존 WT+SSH fallback.
177
+ - `remoteControlAtStartup: true` 설정 (`triflux setup` 자동)
178
+ - 원격 호스트: SSH config 등록 + Claude Code 설치
179
+ - 로컬: Windows Terminal
180
+
181
+ ## 호스트 설정
182
+
183
+ `references/hosts.json`에 개인 호스트 별칭을 등록한다.
184
+ 이 파일은 프로젝트에 커밋하지 않고 로컬에서만 관리한다 (.gitignore 추가).
185
+
186
+ ```json
187
+ {
188
+ "hosts": {
189
+ "ultra4": {
190
+ "description": "Windows 데스크탑",
191
+ "aliases": ["울트라", "데스크탑"],
192
+ "default_dir": "~/Desktop/Projects"
193
+ },
194
+ "m2": {
195
+ "description": "MacBook Pro",
196
+ "aliases": ["맥북", "맥"],
197
+ "default_dir": "~/projects"
198
+ }
199
+ },
200
+ "default_host": "ultra4",
201
+ "triggers": ["원격에서", "다른 머신에서", "다른 컴퓨터에서"]
202
+ }
203
+ ```
204
+
205
+ 이 파일이 없으면 호스트 자동 감지가 비활성화되고, --host를 명시해야 한다.
@@ -0,0 +1,16 @@
1
+ {
2
+ "hosts": {
3
+ "ultra4": {
4
+ "description": "Windows 데스크탑 (SSAFY)",
5
+ "aliases": ["울트라", "데스크탑"],
6
+ "default_dir": "~/Desktop/Projects"
7
+ },
8
+ "m2": {
9
+ "description": "MacBook Pro",
10
+ "aliases": ["맥북", "맥"],
11
+ "default_dir": "~/projects"
12
+ }
13
+ },
14
+ "default_host": "ultra4",
15
+ "triggers": ["원격에서", "다른 머신에서", "다른 컴퓨터에서"]
16
+ }