@triflux/remote 10.0.0-alpha.2 → 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.
package/hub/index.mjs CHANGED
@@ -14,8 +14,8 @@ export { createPsmuxSession, startCapture } from './team/psmux.mjs';
14
14
  export { createConductor } from './team/conductor.mjs';
15
15
 
16
16
  // Swarm
17
- export { createSwarmLocks } from './team/swarm-locks.mjs';
18
- export { parseShards, buildFileLeaseMap, buildMcpManifest, computeMergeOrder, planSwarm } from './team/swarm-planner.mjs';
19
- export { createSwarmHypervisor, SWARM_STATES } from './team/swarm-hypervisor.mjs';
20
- export { reconcile, buildRedundantIds, shouldRunRedundant } from './team/swarm-reconciler.mjs';
21
- export { ensureWorktree, prepareIntegrationBranch, rebaseShardOntoIntegration, pruneWorktree } from './team/worktree-lifecycle.mjs';
17
+ export { createSwarmLocks } from '../../../hub/team/swarm-locks.mjs';
18
+ export { parseShards, buildFileLeaseMap, buildMcpManifest, computeMergeOrder, planSwarm } from '../../../hub/team/swarm-planner.mjs';
19
+ export { createSwarmHypervisor, SWARM_STATES } from '../../../hub/team/swarm-hypervisor.mjs';
20
+ export { reconcile, buildRedundantIds, shouldRunRedundant } from '../../../hub/team/swarm-reconciler.mjs';
21
+ export { ensureWorktree, prepareIntegrationBranch, rebaseShardOntoIntegration, pruneWorktree } from '../../../hub/team/worktree-lifecycle.mjs';
@@ -290,4 +290,4 @@ export function createNotifier(opts = {}) {
290
290
  });
291
291
 
292
292
  return createNotifierInstance(normalizeChannels(opts.channels, env), deps);
293
- }
293
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.0.0-alpha.2",
3
+ "version": "10.0.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",