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.
- package/hub/team/backend.mjs +1 -1
- package/hub/team/psmux.mjs +7 -0
- package/package.json +1 -1
- package/scripts/remote-spawn.mjs +640 -82
- package/skills/remote-spawn/SKILL.md +173 -31
- package/skills/remote-spawn/references/hosts.json +16 -0
package/hub/team/backend.mjs
CHANGED
|
@@ -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}
|
|
35
|
+
return `gemini --prompt ${prompt} --output text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
env() { return {}; }
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -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
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
|
+
// 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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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(
|
|
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
|
-
return args;
|
|
55
|
-
}
|
|
56
173
|
|
|
57
|
-
|
|
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
|
|
74
|
-
const result = execFileSync(
|
|
75
|
-
if (result) return result.split(
|
|
76
|
-
} catch {
|
|
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";
|
|
204
|
+
return "claude";
|
|
79
205
|
}
|
|
80
206
|
|
|
81
|
-
// ── 권한 플래그 ──
|
|
82
|
-
|
|
83
207
|
function getPermissionFlag() {
|
|
84
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
138
|
-
const psQuoted = "'" + prompt.replace(/'/g, "''") + "'";
|
|
254
|
+
const psQuoted = `'${prompt.replace(/'/g, "''")}'`;
|
|
139
255
|
wtArgs.push(
|
|
140
|
-
"pwsh",
|
|
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 (
|
|
151
|
-
console.error("wt.exe spawn failed:",
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error("wt.exe spawn failed:", error.message);
|
|
152
270
|
process.exit(1);
|
|
153
271
|
}
|
|
154
272
|
}
|
|
155
273
|
|
|
156
|
-
|
|
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
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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",
|
|
176
|
-
"
|
|
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 (
|
|
183
|
-
console.error("wt.exe spawn failed:",
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
199
|
-
console.log(
|
|
200
|
-
|
|
201
|
-
|
|
707
|
+
if (args.command === "list") {
|
|
708
|
+
console.log(listSpawnSessions().join("\n"));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
202
711
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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,
|
|
217
|
-
|
|
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:
|
|
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 <
|
|
12
|
+
argument-hint: "[--host <name>] [--send <session> <prompt>] [--list] [--attach] <prompt or natural language>"
|
|
7
13
|
---
|
|
8
14
|
|
|
9
|
-
# remote-spawn — 원격/로컬 Claude 세션
|
|
15
|
+
# remote-spawn — 원격/로컬 Claude 세션 관리
|
|
10
16
|
|
|
11
|
-
>
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
65
|
+
## 작업 컨텍스트
|
|
66
|
+
- 현재 프로젝트: {프로젝트 경로}
|
|
67
|
+
- 작업 중인 파일: {최근 수정 파일 목록}
|
|
68
|
+
- 진행 상황: {현재 대화에서 완료한 것}
|
|
69
|
+
|
|
70
|
+
## 태스크
|
|
71
|
+
{사용자 프롬프트 또는 추출된 작업 지시}
|
|
72
|
+
|
|
73
|
+
## 참고
|
|
74
|
+
- {관련 결정 사항이나 제약}
|
|
19
75
|
```
|
|
20
76
|
|
|
21
|
-
|
|
77
|
+
핸드오프 생성 후 임시 파일에 저장: `.omc/handoff-{uuid8}.md`
|
|
22
78
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
3. **세션 Spawn**: `scripts/remote-spawn.mjs` 호출
|
|
79
|
+
사용자가 명시적으로 `--handoff <file>` 을 준 경우, 자동 생성 대신 해당 파일 사용.
|
|
80
|
+
사용자가 `/mp`로 생성한 핸드오프가 있으면 그것을 우선 사용.
|
|
26
81
|
|
|
27
|
-
|
|
82
|
+
**2단계: 환경 확인**
|
|
28
83
|
|
|
29
|
-
|
|
84
|
+
원격 호스트의 프로브 결과를 확인한다. 캐시가 있으면 캐시 사용.
|
|
30
85
|
|
|
31
86
|
```bash
|
|
32
|
-
node scripts/remote-spawn.mjs --
|
|
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
|
-
|
|
110
|
+
**3단계: 세션 실행**
|
|
38
111
|
|
|
39
112
|
```bash
|
|
40
|
-
node scripts/remote-spawn.mjs --host
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
137
|
+
### list — 세션 목록
|
|
54
138
|
|
|
55
139
|
```bash
|
|
56
|
-
node scripts/remote-spawn.mjs --
|
|
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
|
-
-
|
|
62
|
-
-
|
|
63
|
-
-
|
|
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
|
+
}
|