triflux 7.5.1 → 8.2.1

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.
@@ -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
-
8
- 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";
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>
11
+
12
+ import { randomUUID } from "crypto";
13
+ import { execFileSync, execSync, spawn } from "child_process";
14
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, 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,59 +52,213 @@ 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;
173
+
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
+ }
186
+
187
+ function parseVersion(versionStr) {
188
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(versionStr);
189
+ if (!match) return null;
190
+ return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
55
191
  }
56
192
 
57
- // ── Claude 실행 경로 감지 ──
193
+ function compareVersions(a, b) {
194
+ for (let i = 0; i < 3; i++) {
195
+ if (a[i] !== b[i]) return a[i] - b[i];
196
+ }
197
+ return 0;
198
+ }
199
+
200
+ function probeVersion(binPath) {
201
+ try {
202
+ if (/\.(cmd|bat)$/iu.test(binPath)) {
203
+ // .cmd/.bat → execSync로 shell 경유 (execFileSync EINVAL 회피)
204
+ const out = execSync(`"${binPath}" --version`, {
205
+ encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
206
+ });
207
+ return parseVersion(out);
208
+ }
209
+ const out = execFileSync(binPath, ["--version"], {
210
+ encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
211
+ });
212
+ return parseVersion(out);
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
58
217
 
59
218
  function detectClaudePath() {
60
- // 1. 환경변수 오버라이드
61
219
  if (process.env.CLAUDE_BIN_PATH) return process.env.CLAUDE_BIN_PATH;
62
220
 
63
- // 2. WinGet Links
221
+ const candidates = [];
222
+
64
223
  const wingetPath = join(homedir(), "AppData", "Local", "Microsoft", "WinGet", "Links", "claude.exe");
65
- if (existsSync(wingetPath)) return wingetPath;
224
+ if (existsSync(wingetPath)) candidates.push(wingetPath);
66
225
 
67
- // 3. npm global
68
226
  const npmPath = join(process.env.APPDATA || "", "npm", "claude.cmd");
69
- if (existsSync(npmPath)) return npmPath;
227
+ if (existsSync(npmPath)) candidates.push(npmPath);
70
228
 
71
- // 3. PATH에서 찾기
72
229
  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 */ }
230
+ const command = IS_WINDOWS_LOCAL ? "where" : "which";
231
+ const result = execFileSync(command, ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
232
+ if (result) {
233
+ for (const line of result.split(/\r?\n/u)) {
234
+ const p = line.trim();
235
+ if (p && !candidates.includes(p)) candidates.push(p);
236
+ }
237
+ }
238
+ } catch {
239
+ // not found
240
+ }
77
241
 
78
- return "claude"; // fallback — PATH에 있다고 가정
79
- }
242
+ if (candidates.length === 0) return "claude";
80
243
 
81
- // ── 권한 플래그 ──
244
+ let bestPath = candidates[0];
245
+ let bestVersion = probeVersion(candidates[0]);
82
246
 
83
- function getPermissionFlag() {
84
- if (process.env.TFX_CLAUDE_SAFE_MODE === "1") return [];
85
- return ["--PLACEHOLDER_PERM_FLAG"];
247
+ for (const candidate of candidates.slice(1)) {
248
+ const ver = probeVersion(candidate);
249
+ if (ver === null) continue;
250
+ if (bestVersion === null || compareVersions(ver, bestVersion) > 0) {
251
+ bestVersion = ver;
252
+ bestPath = candidate;
253
+ }
254
+ }
255
+
256
+ return bestPath;
86
257
  }
87
258
 
88
- // ── 핸드오프 컨텐츠 생성 ──
259
+ function getPermissionFlag() {
260
+ return process.env.TFX_CLAUDE_SAFE_MODE === "1" ? [] : ["--dangerously-skip-permissions"];
261
+ }
89
262
 
90
263
  function buildPrompt(args) {
91
264
  let content = "";
@@ -111,13 +284,10 @@ function buildPrompt(args) {
111
284
  return content;
112
285
  }
113
286
 
114
- // ── 로컬 Spawn (WT 탭) ──
115
-
116
- function spawnLocal(args, claudePath, prompt) {
287
+ function spawnLocalFallback(args, claudePath, prompt) {
117
288
  const dir = args.dir ? resolve(args.dir) : process.cwd();
118
289
 
119
- if (platform() !== "win32") {
120
- // Linux/macOS: 직접 실행
290
+ if (!IS_WINDOWS_LOCAL) {
121
291
  const cliArgs = [...getPermissionFlag()];
122
292
  if (prompt) cliArgs.push(prompt);
123
293
 
@@ -129,15 +299,15 @@ function spawnLocal(args, claudePath, prompt) {
129
299
  return;
130
300
  }
131
301
 
132
- // Windows: wt.exe new-tab
133
302
  const wtArgs = ["new-tab", "-d", dir, "--"];
134
303
  const claudeForward = claudePath.replace(/\\/g, "/");
135
304
 
136
305
  if (prompt) {
137
- // pwsh single-quote: 내부 ' '' 이스케이프
138
- const psQuoted = "'" + prompt.replace(/'/g, "''") + "'";
306
+ const psQuoted = `'${prompt.replace(/'/g, "''")}'`;
139
307
  wtArgs.push(
140
- "pwsh", "-NoProfile", "-Command",
308
+ "pwsh",
309
+ "-NoProfile",
310
+ "-Command",
141
311
  `& '${claudeForward}' ${getPermissionFlag().join(" ")} ${psQuoted}`,
142
312
  );
143
313
  } else {
@@ -147,15 +317,13 @@ function spawnLocal(args, claudePath, prompt) {
147
317
  try {
148
318
  spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false }).unref();
149
319
  console.log(`spawned local Claude in WT tab → ${dir}`);
150
- } catch (err) {
151
- console.error("wt.exe spawn failed:", err.message);
320
+ } catch (error) {
321
+ console.error("wt.exe spawn failed:", error.message);
152
322
  process.exit(1);
153
323
  }
154
324
  }
155
325
 
156
- // ── 원격 Spawn (SSH) ──
157
-
158
- function spawnRemote(args, prompt) {
326
+ function spawnRemoteFallback(args, prompt) {
159
327
  const { host } = args;
160
328
  if (!host) {
161
329
  console.error("--host required for remote spawn");
@@ -163,60 +331,530 @@ function spawnRemote(args, prompt) {
163
331
  }
164
332
 
165
333
  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 세션 열기
334
+ const permFlags = getPermissionFlag();
335
+ const scriptLines = [
336
+ `cd '${dir.replace(/'/g, "''")}'`,
337
+ ];
338
+
339
+ if (prompt) {
340
+ const safePrompt = prompt.replace(/'/g, "''");
341
+ scriptLines.push(`& "$env:USERPROFILE\\.local\\bin\\claude.exe" ${permFlags.join(" ")} '${safePrompt}'`);
342
+ } else {
343
+ scriptLines.push(`& "$env:USERPROFILE\\.local\\bin\\claude.exe" ${permFlags.join(" ")}`);
344
+ }
345
+
346
+ const scriptContent = scriptLines.join("\n");
347
+ const localScript = join(tmpdir(), "tfx-remote-spawn.ps1");
348
+ writeFileSync(localScript, scriptContent, "utf8");
349
+
350
+ try {
351
+ execFileSync("scp", [localScript, `${host}:tfx-remote-spawn.ps1`], { timeout: 10000, stdio: "pipe" });
352
+ } catch (error) {
353
+ console.error("failed to copy script to remote:", error.message);
354
+ process.exit(1);
355
+ }
356
+
357
+ let remoteHome;
358
+ try {
359
+ remoteHome = execFileSync("ssh", [host, "echo", "$env:USERPROFILE"], { encoding: "utf8", timeout: 5000 }).trim();
360
+ } catch {
361
+ remoteHome = `C:\\Users\\${host}`;
362
+ }
363
+
364
+ const remoteScript = `${remoteHome.replace(/\\/g, "/")}/tfx-remote-spawn.ps1`;
365
+ const remoteCmd = `pwsh -NoExit -File ${remoteScript}`;
366
+
367
+ if (IS_WINDOWS_LOCAL) {
174
368
  const wtArgs = [
175
- "new-tab", "--title", `Claude@${host}`, "--",
176
- "ssh", "-t", "--", host, remoteCmd,
369
+ "new-tab",
370
+ "--title",
371
+ `Claude@${host}`,
372
+ "--",
373
+ "ssh",
374
+ "-t",
375
+ "--",
376
+ host,
377
+ remoteCmd,
177
378
  ];
178
-
179
379
  try {
180
380
  spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false }).unref();
181
381
  console.log(`spawned remote Claude → ${host}:${dir}`);
182
- } catch (err) {
183
- console.error("wt.exe spawn failed:", err.message);
382
+ } catch (error) {
383
+ console.error("wt.exe spawn failed:", error.message);
184
384
  process.exit(1);
185
385
  }
186
386
  } else {
187
- // Linux/macOS: 직접 SSH
188
387
  const child = spawn("ssh", ["-t", "--", host, remoteCmd], { stdio: "inherit" });
189
388
  child.on("exit", (code) => process.exit(code || 0));
190
389
  }
191
390
  }
192
391
 
193
- // ── main ──
392
+ function shouldUsePsmux() {
393
+ return IS_WINDOWS_LOCAL && hasPsmux();
394
+ }
395
+
396
+ function requirePsmux() {
397
+ if (!hasPsmux()) {
398
+ throw new Error("psmux is required for this command");
399
+ }
400
+ }
401
+
402
+ function parseProbeLines(text) {
403
+ return Object.fromEntries(
404
+ text
405
+ .split(/\r?\n/u)
406
+ .map((line) => line.trim())
407
+ .filter(Boolean)
408
+ .map((line) => {
409
+ const separatorIndex = line.indexOf("=");
410
+ return separatorIndex === -1
411
+ ? null
412
+ : [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)];
413
+ })
414
+ .filter(Boolean),
415
+ );
416
+ }
417
+
418
+ function normalizePwshProbeEnv(host, parsed) {
419
+ if (parsed.shell !== "pwsh" || parsed.os !== "win32") {
420
+ return null;
421
+ }
422
+
423
+ if (!parsed.home) {
424
+ return null;
425
+ }
426
+
427
+ return Object.freeze({
428
+ claudePath: (!parsed.claude || parsed.claude === "notfound") ? null : parsed.claude,
429
+ home: parsed.home,
430
+ os: "win32",
431
+ shell: "pwsh",
432
+ });
433
+ }
434
+
435
+ function normalizePosixProbeEnv(host, parsed) {
436
+ const os = parsed.os === "darwin" ? "darwin" : parsed.os === "linux" ? "linux" : null;
437
+ if (!os || !parsed.home) {
438
+ return null;
439
+ }
440
+
441
+ return Object.freeze({
442
+ claudePath: (!parsed.claude || parsed.claude === "notfound") ? null : parsed.claude,
443
+ home: parsed.home,
444
+ os,
445
+ shell: parsed.shell === "zsh" ? "zsh" : "bash",
446
+ });
447
+ }
448
+
449
+ function getRemoteEnvCachePath(host) {
450
+ return join(REMOTE_ENV_CACHE_DIR, `${host}.json`);
451
+ }
452
+
453
+ function readRemoteEnvCache(host) {
454
+ const cachePath = getRemoteEnvCachePath(host);
455
+ if (!existsSync(cachePath)) {
456
+ return null;
457
+ }
458
+
459
+ try {
460
+ const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
461
+ return parsed && typeof parsed === "object" ? parsed : null;
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+
467
+ function isRemoteEnvCacheFresh(cacheEntry) {
468
+ return Boolean(
469
+ cacheEntry
470
+ && typeof cacheEntry.cachedAt === "number"
471
+ && cacheEntry.env
472
+ && (Date.now() - cacheEntry.cachedAt) < REMOTE_ENV_TTL_MS,
473
+ );
474
+ }
475
+
476
+ function writeRemoteEnvCache(host, env) {
477
+ mkdirSync(REMOTE_ENV_CACHE_DIR, { recursive: true });
478
+ writeFileSync(
479
+ getRemoteEnvCachePath(host),
480
+ JSON.stringify({ cachedAt: Date.now(), env }, null, 2),
481
+ "utf8",
482
+ );
483
+ }
484
+
485
+ function probeRemoteEnvViaPwsh(host) {
486
+ const command = [
487
+ "Write-Output 'shell=pwsh'",
488
+ 'Write-Output "home=$env:USERPROFILE"',
489
+ '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\' }',
490
+ 'Write-Output "os=$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows) ? \'win32\' : \'other\')"',
491
+ ].join("; ");
492
+
493
+ let output;
494
+ try {
495
+ output = execFileSync(
496
+ "ssh",
497
+ [host, "pwsh", "-NoProfile", "-Command", command],
498
+ { encoding: "utf8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"] },
499
+ );
500
+ } catch {
501
+ return null;
502
+ }
503
+
504
+ return normalizePwshProbeEnv(host, parseProbeLines(output));
505
+ }
506
+
507
+ function probeRemoteEnvViaPosix(host) {
508
+ const script = [
509
+ "echo shell=$(basename $SHELL)",
510
+ "echo home=$HOME",
511
+ "command -v claude >/dev/null 2>&1 && echo claude=$(command -v claude) || echo claude=notfound",
512
+ "echo os=$(uname -s | tr A-Z a-z)",
513
+ ].join("\n");
514
+
515
+ let output;
516
+ try {
517
+ output = execFileSync("ssh", [host, "sh"], {
518
+ encoding: "utf8",
519
+ timeout: 15000,
520
+ input: script,
521
+ });
522
+ } catch {
523
+ return null;
524
+ }
525
+
526
+ return normalizePosixProbeEnv(host, parseProbeLines(output));
527
+ }
528
+
529
+ function probeRemoteEnv(host, opts = {}) {
530
+ const force = opts.force === true;
531
+
532
+ if (!force) {
533
+ const cached = readRemoteEnvCache(host);
534
+ if (isRemoteEnvCacheFresh(cached)) {
535
+ return cached.env;
536
+ }
537
+ }
538
+
539
+ const pwshEnv = probeRemoteEnvViaPwsh(host);
540
+ if (pwshEnv) {
541
+ writeRemoteEnvCache(host, pwshEnv);
542
+ return pwshEnv;
543
+ }
544
+
545
+ const posixEnv = probeRemoteEnvViaPosix(host);
546
+ if (posixEnv) {
547
+ writeRemoteEnvCache(host, posixEnv);
548
+ return posixEnv;
549
+ }
550
+
551
+ throw new Error(`remote probe failed for ${host}`);
552
+ }
553
+
554
+ function isWindowsAbsolutePath(value) {
555
+ return /^[a-zA-Z]:[\\/]/u.test(value) || value.startsWith("\\\\");
556
+ }
557
+
558
+ function resolveRemoteDir(dir, env) {
559
+ const requestedDir = dir || env.home;
560
+
561
+ if (env.os === "win32") {
562
+ const winDir = requestedDir.replace(/\//g, "\\");
563
+ if (winDir === "~") return env.home;
564
+ if (/^~[\\/]/u.test(winDir)) return win32Path.join(env.home, winDir.slice(2));
565
+ if (isWindowsAbsolutePath(winDir)) return winDir;
566
+ return win32Path.join(env.home, winDir);
567
+ }
568
+
569
+ if (requestedDir === "~") return env.home;
570
+ if (requestedDir.startsWith("~/")) return posixPath.join(env.home, requestedDir.slice(2));
571
+ if (requestedDir.startsWith("/")) return requestedDir;
572
+ return posixPath.join(env.home, requestedDir);
573
+ }
574
+
575
+ function listSessionNamesFromRawOutput(output) {
576
+ return output
577
+ .split(/\r?\n/u)
578
+ .map((line) => line.trim())
579
+ .filter(Boolean)
580
+ .map((line) => line.split(":")[0]?.trim())
581
+ .filter(Boolean);
582
+ }
583
+
584
+ function listSpawnSessions() {
585
+ const helperSessions = listPsmuxSessions().filter((name) => name.startsWith("tfx-spawn-"));
586
+ if (helperSessions.length > 0) {
587
+ return helperSessions;
588
+ }
589
+
590
+ try {
591
+ return listSessionNamesFromRawOutput(psmuxExec(["list-sessions"]))
592
+ .filter((name) => name.startsWith("tfx-spawn-"));
593
+ } catch {
594
+ return [];
595
+ }
596
+ }
597
+
598
+ function openAttachTab(sessionName, title = null) {
599
+ if (IS_WINDOWS_LOCAL) {
600
+ const wtArgs = title
601
+ ? ["new-tab", "--title", title, "--", "psmux", "attach", "-t", sessionName]
602
+ : ["new-tab", "--", "psmux", "attach", "-t", sessionName];
603
+ spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false }).unref();
604
+ return;
605
+ }
606
+
607
+ attachPsmuxSession(sessionName);
608
+ }
609
+
610
+ function getLastNonEmptyLine(text) {
611
+ const lines = String(text)
612
+ .split(/\r?\n/u)
613
+ .map((line) => line.trimEnd())
614
+ .filter((line) => line.trim().length > 0);
615
+ return lines.at(-1) || "";
616
+ }
617
+
618
+ async function waitForRemotePrompt(sessionName, paneId) {
619
+ const baseline = capturePsmuxPane(paneId, 20);
620
+ const capture = startCapture(sessionName, paneId);
621
+ const deadline = Date.now() + 15_000;
622
+
623
+ while (Date.now() <= deadline) {
624
+ const remainingMs = Math.max(250, deadline - Date.now());
625
+ await waitForPattern(
626
+ sessionName,
627
+ paneId,
628
+ SSH_PROMPT_PATTERN,
629
+ Math.min(1, remainingMs / 1000),
630
+ { logPath: capture.logPath },
631
+ );
632
+
633
+ const tail = capturePsmuxPane(paneId, 20);
634
+ const lastLine = getLastNonEmptyLine(tail);
635
+ if (tail !== baseline && SSH_PROMPT_PATTERN.test(lastLine)) {
636
+ return;
637
+ }
638
+ }
639
+
640
+ throw new Error(`ssh prompt wait timed out for ${sessionName}: ${capturePsmuxPane(paneId, 20)}`);
641
+ }
642
+
643
+ function spawnLocal(args, claudePath, prompt) {
644
+ if (!shouldUsePsmux()) {
645
+ spawnLocalFallback(args, claudePath, prompt);
646
+ return;
647
+ }
648
+
649
+ const dir = args.dir ? resolve(args.dir) : process.cwd();
650
+ const sessionName = `tfx-spawn-${randomUUID().slice(0, 8)}`;
651
+ const paneId = `${sessionName}:0.0`;
652
+ const permissionFlags = getPermissionFlag().join(" ");
653
+ const claudePathNorm = normalizeCommandPath(claudePath);
654
+
655
+ // 임시파일 생성 (프롬프트가 있을 때만)
656
+ // 정리는 pwsh 스크립트 내부에서 수행 (Node exit 시 삭제하면 pane 실행 전 사라짐)
657
+ let tmpFile = null;
658
+ if (prompt) {
659
+ tmpFile = join(tmpdir(), `tfx-prompt-${randomUUID().slice(0, 8)}.md`);
660
+ writeFileSync(tmpFile, prompt, { encoding: "utf8" });
661
+ }
662
+
663
+ createPsmuxSession(sessionName, { layout: "1xN", paneCount: 1 });
664
+ sendKeysToPane(paneId, `cd '${escapePwshSingleQuoted(dir)}'`);
665
+ sleepMs(300);
666
+
667
+ if (prompt && tmpFile) {
668
+ // pwsh -File 패턴: 인라인 쿼팅 문제 회피 (피드백: -Command 금지)
669
+ // 1단계: 프롬프트를 Get-Content -Raw → claude -p (one-shot), 세션 ID 추출
670
+ // 2단계: --resume으로 인터랙티브 세션 이어붙이기
671
+ const tmpFileNorm = normalizeCommandPath(tmpFile);
672
+ const flags = getPermissionFlag().map((f) => `'${escapePwshSingleQuoted(f)}'`).join(", ");
673
+ const scriptContent = [
674
+ `$ErrorActionPreference = 'SilentlyContinue'`,
675
+ `$t = '${escapePwshSingleQuoted(tmpFileNorm)}'`,
676
+ `$c = '${escapePwshSingleQuoted(claudePathNorm)}'`,
677
+ `$f = @(${flags})`,
678
+ `$raw = Get-Content -Raw $t`,
679
+ `Remove-Item -ErrorAction SilentlyContinue $t`,
680
+ `Remove-Item -ErrorAction SilentlyContinue $MyInvocation.MyCommand.Definition`,
681
+ `& $c @f $raw`,
682
+ ].join("\n");
683
+ const scriptFile = join(tmpdir(), `tfx-spawn-${randomUUID().slice(0, 8)}.ps1`);
684
+ writeFileSync(scriptFile, scriptContent, { encoding: "utf8" });
685
+ sendKeysToPane(paneId, `pwsh -NoProfile -NoExit -File '${escapePwshSingleQuoted(normalizeCommandPath(scriptFile))}'`);
686
+ } else {
687
+ const command = `& '${escapePwshSingleQuoted(claudePathNorm)}'${permissionFlags ? ` ${permissionFlags}` : ""}`;
688
+ sendKeysToPane(paneId, command);
689
+ }
690
+
691
+ openAttachTab(sessionName, "Claude@local");
692
+ console.log(sessionName);
693
+ }
694
+
695
+ async function spawnRemote(args, prompt) {
696
+ const { host } = args;
697
+ if (!host) {
698
+ console.error("--host required for remote spawn");
699
+ process.exit(1);
700
+ }
194
701
 
195
- function main() {
702
+ if (!shouldUsePsmux()) {
703
+ spawnRemoteFallback(args, prompt);
704
+ return;
705
+ }
706
+
707
+ const env = probeRemoteEnv(host);
708
+ if (!env.claudePath) {
709
+ console.error(`claude not found on ${host}. Install Claude Code on the remote host first.`);
710
+ process.exit(1);
711
+ }
712
+ const resolvedDir = resolveRemoteDir(args.dir, env);
713
+ const sessionName = `tfx-spawn-${host}-${randomUUID().slice(0, 8)}`;
714
+ const paneId = `${sessionName}:0.0`;
715
+ const permissionFlags = getPermissionFlag().join(" ");
716
+
717
+ createPsmuxSession(sessionName, { layout: "1xN", paneCount: 1 });
718
+ sendKeysToPane(paneId, `ssh -t ${host}`);
719
+ await waitForRemotePrompt(sessionName, paneId);
720
+
721
+ if (env.shell === "pwsh") {
722
+ const claudeCommand = `& "${escapePwshDoubleQuoted(env.claudePath)}"${permissionFlags ? ` ${permissionFlags}` : ""}`;
723
+ sendKeysToPane(paneId, `cd '${escapePwshSingleQuoted(resolvedDir)}'`);
724
+ sendKeysToPane(paneId, claudeCommand);
725
+ } else {
726
+ const claudeCommand = `${shellQuote(env.claudePath)}${permissionFlags ? ` ${permissionFlags}` : ""}`;
727
+ sendKeysToPane(paneId, `cd ${shellQuote(resolvedDir)}`);
728
+ sendKeysToPane(paneId, claudeCommand);
729
+ }
730
+
731
+ if (prompt) {
732
+ sleepMs(2000);
733
+ sendKeysToPane(paneId, prompt);
734
+ }
735
+
736
+ openAttachTab(sessionName, `Claude@${host}`);
737
+ console.log(sessionName);
738
+ }
739
+
740
+ function sendPromptToSession(sessionName, prompt) {
741
+ requirePsmux();
742
+ if (!psmuxSessionExists(sessionName)) {
743
+ throw new Error(`psmux session not found: ${sessionName}`);
744
+ }
745
+ sendKeysToPane(`${sessionName}:0.0`, prompt);
746
+ }
747
+
748
+ function attachSession(sessionName) {
749
+ requirePsmux();
750
+ if (!psmuxSessionExists(sessionName)) {
751
+ throw new Error(`psmux session not found: ${sessionName}`);
752
+ }
753
+ openAttachTab(sessionName);
754
+ }
755
+
756
+ function captureSession(sessionName, lines = 30) {
757
+ requirePsmux();
758
+ if (!psmuxSessionExists(sessionName)) {
759
+ throw new Error(`psmux session not found: ${sessionName}`);
760
+ }
761
+ return capturePsmuxPane(`${sessionName}:0.0`, lines);
762
+ }
763
+
764
+ async function waitForClaudeReady(sessionName, timeoutSec = 60) {
765
+ requirePsmux();
766
+ if (!psmuxSessionExists(sessionName)) {
767
+ throw new Error(`psmux session not found: ${sessionName}`);
768
+ }
769
+ const paneId = `${sessionName}:0.0`;
770
+ const readyPattern = /(\u276f|\u2795|>\s*$|bypass permissions)/;
771
+ const deadline = Date.now() + timeoutSec * 1000;
772
+
773
+ while (Date.now() <= deadline) {
774
+ const snapshot = capturePsmuxPane(paneId, 5);
775
+ const lastLine = snapshot.split(/\r?\n/).filter((l) => l.trim()).at(-1) || "";
776
+ if (readyPattern.test(lastLine)) {
777
+ return true;
778
+ }
779
+ sleepMs(1000);
780
+ }
781
+ throw new Error(`claude ready wait timed out after ${timeoutSec}s for ${sessionName}`);
782
+ }
783
+
784
+ async function main() {
196
785
  const args = parseArgs(process.argv);
197
786
 
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>]
787
+ if (args.command === "list") {
788
+ console.log(listSpawnSessions().join("\n"));
789
+ return;
790
+ }
202
791
 
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);
792
+ if (args.command === "attach") {
793
+ if (!args.sessionName) {
794
+ console.error("--attach requires a session name");
795
+ process.exit(1);
796
+ }
797
+ attachSession(args.sessionName);
798
+ return;
799
+ }
800
+
801
+ if (args.command === "probe") {
802
+ if (!args.probeHost) {
803
+ console.error("--probe requires a host");
804
+ process.exit(1);
805
+ }
806
+ console.log(JSON.stringify(probeRemoteEnv(args.probeHost, { force: true }), null, 2));
807
+ return;
808
+ }
809
+
810
+ if (args.command === "capture") {
811
+ if (!args.sessionName) {
812
+ console.error("--capture requires a session name");
813
+ process.exit(1);
814
+ }
815
+ console.log(captureSession(args.sessionName));
816
+ return;
817
+ }
818
+
819
+ if (args.command === "wait") {
820
+ if (!args.sessionName) {
821
+ console.error("--wait requires a session name");
822
+ process.exit(1);
823
+ }
824
+ await waitForClaudeReady(args.sessionName);
825
+ console.log("ready");
826
+ return;
210
827
  }
211
828
 
212
829
  const prompt = buildPrompt(args);
213
- const claudePath = detectClaudePath();
830
+
831
+ if (args.command === "send") {
832
+ if (!args.sessionName) {
833
+ console.error("--send requires a session name");
834
+ process.exit(1);
835
+ }
836
+ if (!prompt) {
837
+ console.error("--send requires a prompt or --handoff");
838
+ process.exit(1);
839
+ }
840
+ sendPromptToSession(args.sessionName, prompt);
841
+ return;
842
+ }
843
+
844
+ if (!args.local && !args.host) {
845
+ console.log(usageText());
846
+ return;
847
+ }
214
848
 
215
849
  if (args.local) {
216
- spawnLocal(args, claudePath, prompt);
217
- } else {
218
- spawnRemote(args, prompt);
850
+ spawnLocal(args, detectClaudePath(), prompt);
851
+ return;
219
852
  }
853
+
854
+ await spawnRemote(args, prompt);
220
855
  }
221
856
 
222
- main();
857
+ main().catch((error) => {
858
+ console.error(error?.message || String(error));
859
+ process.exit(1);
860
+ });