@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 +5 -5
- package/hub/team/notify.mjs +1 -1
- package/hub/team/remote-session.mjs +296 -0
- package/package.json +1 -1
- package/hub/team/swarm-hypervisor.mjs +0 -554
- package/hub/team/swarm-locks.mjs +0 -204
- package/hub/team/swarm-planner.mjs +0 -256
- package/hub/team/swarm-reconciler.mjs +0 -137
- package/hub/team/worktree-lifecycle.mjs +0 -172
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 '
|
|
18
|
-
export { parseShards, buildFileLeaseMap, buildMcpManifest, computeMergeOrder, planSwarm } from '
|
|
19
|
-
export { createSwarmHypervisor, SWARM_STATES } from '
|
|
20
|
-
export { reconcile, buildRedundantIds, shouldRunRedundant } from '
|
|
21
|
-
export { ensureWorktree, prepareIntegrationBranch, rebaseShardOntoIntegration, pruneWorktree } from '
|
|
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';
|
package/hub/team/notify.mjs
CHANGED
|
@@ -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
|
+
}
|