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.
- package/bin/triflux.mjs +40 -1
- package/hub/lib/process-utils.mjs +86 -0
- package/hub/server.mjs +48 -1
- package/hub/team/ansi.mjs +161 -19
- package/hub/team/backend.mjs +1 -1
- package/hub/team/cli/commands/start/index.mjs +3 -2
- package/hub/team/cli/commands/start/parse-args.mjs +9 -0
- package/hub/team/cli/commands/start/start-headless.mjs +6 -3
- package/hub/team/cli/help.mjs +2 -0
- package/hub/team/dashboard-layout.mjs +31 -0
- package/hub/team/headless.mjs +146 -33
- package/hub/team/psmux.mjs +181 -7
- package/hub/team/tui-viewer.mjs +354 -90
- package/hub/team/tui.mjs +856 -67
- package/package.json +1 -1
- package/scripts/remote-spawn.mjs +723 -85
- package/scripts/tfx-route.sh +17 -8
- package/skills/remote-spawn/SKILL.md +173 -31
- package/skills/remote-spawn/references/hosts.json +16 -0
package/scripts/remote-spawn.mjs
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
37
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
const candidates = [];
|
|
222
|
+
|
|
64
223
|
const wingetPath = join(homedir(), "AppData", "Local", "Microsoft", "WinGet", "Links", "claude.exe");
|
|
65
|
-
if (existsSync(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))
|
|
227
|
+
if (existsSync(npmPath)) candidates.push(npmPath);
|
|
70
228
|
|
|
71
|
-
// 3. PATH에서 찾기
|
|
72
229
|
try {
|
|
73
|
-
const
|
|
74
|
-
const result = execFileSync(
|
|
75
|
-
if (result)
|
|
76
|
-
|
|
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";
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
138
|
-
const psQuoted = "'" + prompt.replace(/'/g, "''") + "'";
|
|
306
|
+
const psQuoted = `'${prompt.replace(/'/g, "''")}'`;
|
|
139
307
|
wtArgs.push(
|
|
140
|
-
"pwsh",
|
|
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 (
|
|
151
|
-
console.error("wt.exe spawn failed:",
|
|
320
|
+
} catch (error) {
|
|
321
|
+
console.error("wt.exe spawn failed:", error.message);
|
|
152
322
|
process.exit(1);
|
|
153
323
|
}
|
|
154
324
|
}
|
|
155
325
|
|
|
156
|
-
|
|
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
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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",
|
|
176
|
-
"
|
|
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 (
|
|
183
|
-
console.error("wt.exe spawn failed:",
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
199
|
-
console.log(
|
|
200
|
-
|
|
201
|
-
|
|
787
|
+
if (args.command === "list") {
|
|
788
|
+
console.log(listSpawnSessions().join("\n"));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
202
791
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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,
|
|
217
|
-
|
|
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
|
+
});
|