@triflux/remote 10.0.0-alpha.1 → 10.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.
@@ -0,0 +1,276 @@
1
+ // hub/team/remote-probe.mjs — SSH 경유 원격 세션 health probe
2
+ // health-probe.mjs와 동일 인터페이스: { start, stop, probe, getStatus }
3
+ // child process PID 대신 SSH capture-pane 폴링으로 상태 추적.
4
+ //
5
+ // L0: SSH 연결 + psmux 세션 존재 확인
6
+ // L1: capture-pane 출력 변화 감지 (advancing)
7
+ // L1.5: INPUT_WAIT 패턴 감지 (detectInputWait 재사용)
8
+ // L3: 완료 토큰 감지 (__TRIFLUX_DONE__ 또는 프롬프트 idle)
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { detectInputWait, PROBE_DEFAULTS } from './health-probe.mjs';
12
+
13
+ /** 완료 토큰 패턴 */
14
+ const COMPLETION_TOKEN_RE = /__TRIFLUX_DONE__/;
15
+
16
+ /** 프롬프트 idle 패턴 (Claude Code 프롬프트 복귀) */
17
+ const PROMPT_IDLE_RE = /(\u276f|\u2795|>\s*$)/;
18
+
19
+ /**
20
+ * SSH 경유로 원격 psmux capture-pane 실행.
21
+ * @param {string} host — SSH 호스트
22
+ * @param {string} paneTarget — psmux pane target (e.g. "session:0.0")
23
+ * @param {number} lines — 캡처할 줄 수
24
+ * @param {object} [deps] — 테스트용 의존성 주입
25
+ * @returns {string|null} 캡처 텍스트 또는 null (실패)
26
+ */
27
+ export function sshCapturePane(host, paneTarget, lines = 20, deps = {}) {
28
+ const execFn = deps.execFileSync || execFileSync;
29
+ try {
30
+ const output = execFn('ssh', [
31
+ '-o', 'ConnectTimeout=5',
32
+ '-o', 'BatchMode=yes',
33
+ host,
34
+ `psmux capture-pane -t ${paneTarget} -p -S -`,
35
+ ], {
36
+ encoding: 'utf8',
37
+ timeout: 10_000,
38
+ windowsHide: true,
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ });
41
+ const nonEmpty = output.split('\n').filter((line) => line.trim() !== '');
42
+ return nonEmpty.slice(-lines).join('\n');
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * SSH 경유로 원격 psmux 세션 존재 여부 확인.
50
+ * @param {string} host
51
+ * @param {string} sessionName
52
+ * @param {object} [deps]
53
+ * @returns {boolean}
54
+ */
55
+ export function sshSessionExists(host, sessionName, deps = {}) {
56
+ const execFn = deps.execFileSync || execFileSync;
57
+ try {
58
+ execFn('ssh', [
59
+ '-o', 'ConnectTimeout=5',
60
+ '-o', 'BatchMode=yes',
61
+ host,
62
+ `psmux has-session -t ${sessionName}`,
63
+ ], {
64
+ timeout: 10_000,
65
+ windowsHide: true,
66
+ stdio: ['ignore', 'ignore', 'ignore'],
67
+ });
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Remote health probe 팩토리.
76
+ * health-probe.mjs의 createHealthProbe와 동일 인터페이스를 제공하되,
77
+ * SSH capture-pane 폴링 기반으로 동작한다.
78
+ *
79
+ * @param {object} session — probe 대상 세션 정보
80
+ * @param {string} session.host — SSH 호스트
81
+ * @param {string} session.paneTarget — psmux pane target
82
+ * @param {string} session.sessionName — psmux 세션 이름
83
+ * @param {object} [opts] — PROBE_DEFAULTS 오버라이드
84
+ * @param {function} [opts.onProbe] — (result) => void 콜백
85
+ * @param {object} [opts.deps] — 테스트용 의존성 주입
86
+ * @returns {{ start, stop, probe, getStatus, started }}
87
+ */
88
+ export function createRemoteProbe(session, opts = {}) {
89
+ const config = { ...PROBE_DEFAULTS, ...opts };
90
+ const deps = opts.deps || {};
91
+ let timer = null;
92
+ let started = false;
93
+
94
+ // L1 tracking — 출력 변화 감지
95
+ let lastCaptureHash = '';
96
+ let lastOutputChangeAt = Date.now();
97
+
98
+ // L3 tracking — 완료 토큰 / prompt idle
99
+ let promptAcked = false;
100
+ let spawnedAt = Date.now();
101
+
102
+ const status = {
103
+ l0: null,
104
+ l1: null,
105
+ l2: 'skip', // 원격은 MCP L2 미지원
106
+ l3: null,
107
+ lastProbeAt: null,
108
+ inputWaitPattern: null,
109
+ };
110
+
111
+ /**
112
+ * L0: SSH 연결 + psmux 세션 존재 확인.
113
+ */
114
+ function probeL0() {
115
+ const exists = sshSessionExists(session.host, session.sessionName, deps);
116
+ status.l0 = exists ? 'ok' : 'fail';
117
+ return status.l0;
118
+ }
119
+
120
+ /**
121
+ * L1 + L1.5: capture-pane 출력 변화 + INPUT_WAIT 감지.
122
+ * @param {string|null} captured — 이미 캡처된 텍스트 (L0에서 재사용)
123
+ */
124
+ function probeL1(captured) {
125
+ const now = Date.now();
126
+
127
+ if (captured == null) {
128
+ // 캡처 실패 — 이전 상태 유지
129
+ return status.l1 || null;
130
+ }
131
+
132
+ // 단순 해시: 길이 + 처음/끝 문자 조합 (crypto 불필요)
133
+ const hash = `${captured.length}:${captured.slice(0, 32)}:${captured.slice(-32)}`;
134
+
135
+ if (hash !== lastCaptureHash) {
136
+ lastCaptureHash = hash;
137
+ lastOutputChangeAt = now;
138
+ status.l1 = 'ok';
139
+ status.inputWaitPattern = null;
140
+ return 'ok';
141
+ }
142
+
143
+ const silenceMs = now - lastOutputChangeAt;
144
+
145
+ if (silenceMs >= config.l1ThresholdMs) {
146
+ // L1.5: INPUT_WAIT 패턴 감지
147
+ const inputWait = detectInputWait(captured);
148
+
149
+ if (inputWait.detected) {
150
+ status.l1 = 'input_wait';
151
+ status.inputWaitPattern = inputWait.pattern;
152
+ return 'input_wait';
153
+ }
154
+
155
+ status.l1 = 'stall';
156
+ status.inputWaitPattern = null;
157
+ return 'stall';
158
+ }
159
+
160
+ status.l1 = 'ok';
161
+ return 'ok';
162
+ }
163
+
164
+ /**
165
+ * L3: 완료 토큰 또는 프롬프트 idle 감지.
166
+ * @param {string|null} captured
167
+ */
168
+ function probeL3(captured) {
169
+ if (promptAcked) {
170
+ status.l3 = 'ok';
171
+ return 'ok';
172
+ }
173
+
174
+ if (captured != null && captured.length > 0) {
175
+ // 완료 토큰 감지 → 즉시 ok
176
+ if (COMPLETION_TOKEN_RE.test(captured)) {
177
+ promptAcked = true;
178
+ status.l3 = 'completed';
179
+ return 'completed';
180
+ }
181
+
182
+ // 출력이 있으면 prompt acknowledged
183
+ promptAcked = true;
184
+ status.l3 = 'ok';
185
+ return 'ok';
186
+ }
187
+
188
+ const elapsed = Date.now() - spawnedAt;
189
+ if (elapsed >= config.l3ThresholdMs) {
190
+ status.l3 = 'timeout';
191
+ return 'timeout';
192
+ }
193
+
194
+ status.l3 = null;
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * 전체 probe 실행 (L0 → capture → L1 → L3).
200
+ * @returns {Promise<object>} probe 결과
201
+ */
202
+ async function probe() {
203
+ const l0 = probeL0();
204
+
205
+ // L0 실패 시 나머지 probe 스킵
206
+ let captured = null;
207
+ if (l0 === 'ok') {
208
+ captured = sshCapturePane(session.host, session.paneTarget, 20, deps);
209
+ }
210
+
211
+ const l1 = probeL1(captured);
212
+ const l3 = probeL3(captured);
213
+
214
+ const result = {
215
+ l0,
216
+ l1,
217
+ l2: 'skip',
218
+ l3,
219
+ inputWaitPattern: status.inputWaitPattern,
220
+ ts: Date.now(),
221
+ };
222
+ status.lastProbeAt = result.ts;
223
+
224
+ if (typeof config.onProbe === 'function') {
225
+ config.onProbe(result);
226
+ }
227
+
228
+ return result;
229
+ }
230
+
231
+ function start() {
232
+ if (started) return;
233
+ started = true;
234
+ spawnedAt = Date.now();
235
+ lastOutputChangeAt = Date.now();
236
+ lastCaptureHash = '';
237
+ promptAcked = false;
238
+
239
+ timer = setInterval(() => { void probe(); }, config.intervalMs);
240
+ timer.unref?.();
241
+
242
+ // 즉시 첫 probe 실행
243
+ void probe();
244
+ }
245
+
246
+ function stop() {
247
+ if (!started) return;
248
+ started = false;
249
+ if (timer) {
250
+ clearInterval(timer);
251
+ timer = null;
252
+ }
253
+ }
254
+
255
+ /** tracking 리셋 (restart 후 호출) */
256
+ function resetTracking() {
257
+ lastCaptureHash = '';
258
+ lastOutputChangeAt = Date.now();
259
+ promptAcked = false;
260
+ spawnedAt = Date.now();
261
+ status.l0 = null;
262
+ status.l1 = null;
263
+ status.l2 = 'skip';
264
+ status.l3 = null;
265
+ status.inputWaitPattern = null;
266
+ }
267
+
268
+ return Object.freeze({
269
+ start,
270
+ stop,
271
+ probe,
272
+ resetTracking,
273
+ getStatus: () => ({ ...status }),
274
+ get started() { return started; },
275
+ });
276
+ }
@@ -0,0 +1,296 @@
1
+ // hub/team/remote-session.mjs — Remote session primitives for swarm integration
2
+ // Extracted from scripts/remote-spawn.mjs for reuse by swarm-hypervisor.
3
+ // Pure functions + SSH operations. No psmux, no WT, no CLI arg parsing.
4
+
5
+ import { execFileSync } from 'node:child_process';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { basename, join, posix as posixPath, win32 as win32Path } from 'node:path';
8
+
9
+ const REMOTE_ENV_TTL_MS = 86_400_000; // 24h
10
+ const REMOTE_STAGE_ROOT = 'tfx-remote';
11
+ const SAFE_HOST_RE = /^[a-zA-Z0-9._-]+$/;
12
+
13
+ // ── Shell quoting utilities ─────────────────────────────────────
14
+
15
+ export function shellQuote(value) {
16
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
17
+ }
18
+
19
+ export function escapePwshSingleQuoted(value) {
20
+ return String(value).replace(/'/g, "''");
21
+ }
22
+
23
+ export function escapePwshDoubleQuoted(value) {
24
+ return String(value).replace(/`/g, '``').replace(/"/g, '`"');
25
+ }
26
+
27
+ function normalizeCommandPath(value) {
28
+ return String(value).replace(/\\/g, '/');
29
+ }
30
+
31
+ // ── Validation ──────────────────────────────────────────────────
32
+
33
+ export function validateHost(host) {
34
+ if (!host || !SAFE_HOST_RE.test(host)) {
35
+ throw new Error(`invalid host name: ${host}`);
36
+ }
37
+ return host;
38
+ }
39
+
40
+ // ── Remote environment probe ────────────────────────────────────
41
+
42
+ function parseProbeLines(text) {
43
+ return Object.fromEntries(
44
+ text
45
+ .split(/\r?\n/u)
46
+ .map((line) => line.trim())
47
+ .filter(Boolean)
48
+ .map((line) => {
49
+ const idx = line.indexOf('=');
50
+ return idx === -1 ? null : [line.slice(0, idx), line.slice(idx + 1)];
51
+ })
52
+ .filter(Boolean),
53
+ );
54
+ }
55
+
56
+ function normalizePwshProbeEnv(parsed) {
57
+ if (parsed.shell !== 'pwsh' || parsed.os !== 'win32') return null;
58
+ if (!parsed.home) return null;
59
+ return Object.freeze({
60
+ claudePath: (!parsed.claude || parsed.claude === 'notfound') ? null : parsed.claude,
61
+ home: parsed.home,
62
+ os: 'win32',
63
+ shell: 'pwsh',
64
+ });
65
+ }
66
+
67
+ function normalizePosixProbeEnv(parsed) {
68
+ const os = parsed.os === 'darwin' ? 'darwin' : parsed.os === 'linux' ? 'linux' : null;
69
+ if (!os || !parsed.home) return null;
70
+ return Object.freeze({
71
+ claudePath: (!parsed.claude || parsed.claude === 'notfound') ? null : parsed.claude,
72
+ home: parsed.home,
73
+ os,
74
+ shell: parsed.shell === 'zsh' ? 'zsh' : 'bash',
75
+ });
76
+ }
77
+
78
+ function probeRemoteEnvViaPwsh(host) {
79
+ const command = [
80
+ "Write-Output 'shell=pwsh'",
81
+ 'Write-Output "home=$env:USERPROFILE"',
82
+ '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\' }',
83
+ 'Write-Output "os=$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows) ? \'win32\' : \'other\')"',
84
+ ].join('; ');
85
+
86
+ try {
87
+ const output = execFileSync('ssh', [host, 'pwsh', '-NoProfile', '-Command', command], {
88
+ encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
89
+ });
90
+ return normalizePwshProbeEnv(parseProbeLines(output));
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function probeRemoteEnvViaPosix(host) {
97
+ const script = [
98
+ 'echo shell=$(basename $SHELL)',
99
+ 'echo home=$HOME',
100
+ 'command -v claude >/dev/null 2>&1 && echo claude=$(command -v claude) || echo claude=notfound',
101
+ 'echo os=$(uname -s | tr A-Z a-z)',
102
+ ].join('\n');
103
+
104
+ try {
105
+ const output = execFileSync('ssh', [host, 'sh'], {
106
+ encoding: 'utf8', timeout: 15000, input: script,
107
+ });
108
+ return normalizePosixProbeEnv(parseProbeLines(output));
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ // ── Cache ───────────────────────────────────────────────────────
115
+
116
+ function getEnvCachePath(host, cacheDir) {
117
+ return join(cacheDir, `${host}.json`);
118
+ }
119
+
120
+ function readEnvCache(host, cacheDir) {
121
+ const cachePath = getEnvCachePath(host, cacheDir);
122
+ if (!existsSync(cachePath)) return null;
123
+ try {
124
+ const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
125
+ return parsed && typeof parsed === 'object' ? parsed : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function isEnvCacheFresh(entry) {
132
+ return Boolean(
133
+ entry
134
+ && typeof entry.cachedAt === 'number'
135
+ && entry.env
136
+ && (Date.now() - entry.cachedAt) < REMOTE_ENV_TTL_MS,
137
+ );
138
+ }
139
+
140
+ function writeEnvCache(host, env, cacheDir) {
141
+ mkdirSync(cacheDir, { recursive: true });
142
+ writeFileSync(getEnvCachePath(host, cacheDir), JSON.stringify({ cachedAt: Date.now(), env }, null, 2), 'utf8');
143
+ }
144
+
145
+ /**
146
+ * Probe remote host environment (OS, shell, Claude path, home dir).
147
+ * Results are cached for 24h.
148
+ *
149
+ * @param {string} host — SSH host
150
+ * @param {object} [opts]
151
+ * @param {boolean} [opts.force=false] — bypass cache
152
+ * @param {string} [opts.cacheDir] — cache directory (default: .omc/state/remote-env)
153
+ * @returns {Readonly<RemoteEnv>}
154
+ */
155
+ export function probeRemoteEnv(host, opts = {}) {
156
+ validateHost(host);
157
+ const force = opts.force === true;
158
+ const cacheDir = opts.cacheDir || join('.omc', 'state', 'remote-env');
159
+
160
+ if (!force) {
161
+ const cached = readEnvCache(host, cacheDir);
162
+ if (isEnvCacheFresh(cached)) return cached.env;
163
+ }
164
+
165
+ const pwshEnv = probeRemoteEnvViaPwsh(host);
166
+ if (pwshEnv) { writeEnvCache(host, pwshEnv, cacheDir); return pwshEnv; }
167
+
168
+ const posixEnv = probeRemoteEnvViaPosix(host);
169
+ if (posixEnv) { writeEnvCache(host, posixEnv, cacheDir); return posixEnv; }
170
+
171
+ throw new Error(`remote probe failed for ${host}`);
172
+ }
173
+
174
+ // ── Remote directory resolution ─────────────────────────────────
175
+
176
+ function isWindowsAbsolutePath(value) {
177
+ return /^[a-zA-Z]:[\\/]/u.test(value) || value.startsWith('\\\\');
178
+ }
179
+
180
+ /**
181
+ * Resolve a directory path on a remote host.
182
+ * Handles ~ expansion and OS-specific path normalization.
183
+ *
184
+ * @param {string} dir — requested directory (or empty for home)
185
+ * @param {RemoteEnv} env
186
+ * @returns {string}
187
+ */
188
+ export function resolveRemoteDir(dir, env) {
189
+ const requestedDir = dir || env.home;
190
+
191
+ if (env.os === 'win32') {
192
+ const winDir = requestedDir.replace(/\//g, '\\');
193
+ if (winDir === '~') return env.home;
194
+ if (/^~[\\/]/u.test(winDir)) return win32Path.join(env.home, winDir.slice(2));
195
+ if (isWindowsAbsolutePath(winDir)) return winDir;
196
+ return win32Path.join(env.home, winDir);
197
+ }
198
+
199
+ if (requestedDir === '~') return env.home;
200
+ if (requestedDir.startsWith('~/')) return posixPath.join(env.home, requestedDir.slice(2));
201
+ if (requestedDir.startsWith('/')) return requestedDir;
202
+ return posixPath.join(env.home, requestedDir);
203
+ }
204
+
205
+ // ── Remote file staging ─────────────────────────────────────────
206
+
207
+ /**
208
+ * Resolve the remote staging directory path.
209
+ * @param {RemoteEnv} env
210
+ * @param {string} stageId
211
+ * @returns {string}
212
+ */
213
+ export function resolveRemoteStageDir(env, stageId) {
214
+ return `${normalizeCommandPath(env.home)}/${REMOTE_STAGE_ROOT}/${stageId}`;
215
+ }
216
+
217
+ /**
218
+ * Ensure the remote staging directory exists via SSH.
219
+ * @param {string} host
220
+ * @param {RemoteEnv} env
221
+ * @param {string} remoteStageDir
222
+ */
223
+ export function ensureRemoteStageDir(host, env, remoteStageDir) {
224
+ if (env.os === 'win32') {
225
+ const safePath = escapePwshSingleQuoted(remoteStageDir);
226
+ execFileSync('ssh', [host, 'pwsh', '-NoProfile', '-Command', `New-Item -ItemType Directory -Path '${safePath}' -Force | Out-Null`], { timeout: 10000, stdio: 'pipe' });
227
+ return;
228
+ }
229
+ execFileSync('ssh', [host, 'sh', '-lc', `mkdir -p ${shellQuote(remoteStageDir)}`], { timeout: 10000, stdio: 'pipe' });
230
+ }
231
+
232
+ /**
233
+ * Upload a file to remote host via scp.
234
+ * @param {string} host
235
+ * @param {string} localPath
236
+ * @param {string} remotePath
237
+ */
238
+ export function uploadFileToRemote(host, localPath, remotePath) {
239
+ execFileSync('scp', [localPath, `${host}:${remotePath}`], { timeout: 15000, stdio: 'pipe' });
240
+ }
241
+
242
+ /**
243
+ * Stage local files on a remote host for prompt delivery.
244
+ *
245
+ * @param {string} host
246
+ * @param {RemoteEnv} env
247
+ * @param {Array<{ localPath: string }>} transferCandidates
248
+ * @param {string} stageId
249
+ * @returns {{ remoteStageDir: string|null, stagedFiles: Array<{ localPath: string, remotePath: string }> }}
250
+ */
251
+ export function stageRemotePromptFiles(host, env, transferCandidates, stageId) {
252
+ if (!transferCandidates || transferCandidates.length === 0) {
253
+ return { remoteStageDir: null, stagedFiles: [] };
254
+ }
255
+
256
+ const remoteStageDir = resolveRemoteStageDir(env, stageId);
257
+ ensureRemoteStageDir(host, env, remoteStageDir);
258
+
259
+ const basenameCounts = new Map();
260
+ const stagedFiles = transferCandidates.map((candidate) => {
261
+ const fileName = basename(candidate.localPath);
262
+ const count = (basenameCounts.get(fileName) || 0) + 1;
263
+ basenameCounts.set(fileName, count);
264
+ const stagedName = count === 1 ? fileName : `${count}-${fileName}`;
265
+ const remotePath = `${remoteStageDir}/${stagedName}`;
266
+ uploadFileToRemote(host, candidate.localPath, remotePath);
267
+ return { ...candidate, remotePath };
268
+ });
269
+
270
+ return { remoteStageDir, stagedFiles };
271
+ }
272
+
273
+ /**
274
+ * Execute a git command on a remote host via SSH.
275
+ *
276
+ * @param {string} host
277
+ * @param {RemoteEnv} env
278
+ * @param {string[]} gitArgs — git subcommand + args
279
+ * @param {string} cwd — remote working directory
280
+ * @returns {string} stdout
281
+ */
282
+ export function remoteGit(host, env, gitArgs, cwd) {
283
+ const gitCmd = ['git', ...gitArgs].map((a) => shellQuote(a)).join(' ');
284
+
285
+ if (env.os === 'win32') {
286
+ const cdPath = escapePwshSingleQuoted(cwd);
287
+ const command = `Set-Location '${cdPath}'; ${gitCmd}`;
288
+ return execFileSync('ssh', [host, 'pwsh', '-NoProfile', '-Command', command], {
289
+ encoding: 'utf8', timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'],
290
+ }).trim();
291
+ }
292
+
293
+ return execFileSync('ssh', [host, 'sh', '-lc', `cd ${shellQuote(cwd)} && ${gitCmd}`], {
294
+ encoding: 'utf8', timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'],
295
+ }).trim();
296
+ }