@triflux/remote 10.0.0-alpha.2 → 10.0.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.
@@ -0,0 +1,299 @@
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
+ import { execSshWithRetry } from '@triflux/core/hub/lib/ssh-retry.mjs';
9
+
10
+ const REMOTE_ENV_TTL_MS = 86_400_000; // 24h
11
+ const REMOTE_STAGE_ROOT = 'tfx-remote';
12
+ const SAFE_HOST_RE = /^[a-zA-Z0-9._-]+$/;
13
+
14
+ // ── Shell quoting utilities ─────────────────────────────────────
15
+
16
+ export function shellQuote(value) {
17
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
18
+ }
19
+
20
+ export function escapePwshSingleQuoted(value) {
21
+ return String(value).replace(/'/g, "''");
22
+ }
23
+
24
+ export function escapePwshDoubleQuoted(value) {
25
+ return String(value).replace(/`/g, '``').replace(/"/g, '`"');
26
+ }
27
+
28
+ function normalizeCommandPath(value) {
29
+ return String(value).replace(/\\/g, '/');
30
+ }
31
+
32
+ // ── Validation ──────────────────────────────────────────────────
33
+
34
+ export function validateHost(host) {
35
+ if (!host || !SAFE_HOST_RE.test(host)) {
36
+ throw new Error(`invalid host name: ${host}`);
37
+ }
38
+ return host;
39
+ }
40
+
41
+ // ── Remote environment probe ────────────────────────────────────
42
+
43
+ function parseProbeLines(text) {
44
+ return Object.fromEntries(
45
+ text
46
+ .split(/\r?\n/u)
47
+ .map((line) => line.trim())
48
+ .filter(Boolean)
49
+ .map((line) => {
50
+ const idx = line.indexOf('=');
51
+ return idx === -1 ? null : [line.slice(0, idx), line.slice(idx + 1)];
52
+ })
53
+ .filter(Boolean),
54
+ );
55
+ }
56
+
57
+ function normalizePwshProbeEnv(parsed) {
58
+ if (parsed.shell !== 'pwsh' || parsed.os !== 'win32') return null;
59
+ if (!parsed.home) return null;
60
+ return Object.freeze({
61
+ claudePath: (!parsed.claude || parsed.claude === 'notfound') ? null : parsed.claude,
62
+ home: parsed.home,
63
+ os: 'win32',
64
+ shell: 'pwsh',
65
+ });
66
+ }
67
+
68
+ function normalizePosixProbeEnv(parsed) {
69
+ const os = parsed.os === 'darwin' ? 'darwin' : parsed.os === 'linux' ? 'linux' : null;
70
+ if (!os || !parsed.home) return null;
71
+ return Object.freeze({
72
+ claudePath: (!parsed.claude || parsed.claude === 'notfound') ? null : parsed.claude,
73
+ home: parsed.home,
74
+ os,
75
+ shell: parsed.shell === 'zsh' ? 'zsh' : 'bash',
76
+ });
77
+ }
78
+
79
+ function probeRemoteEnvViaPwsh(host) {
80
+ const command = [
81
+ "Write-Output 'shell=pwsh'",
82
+ 'Write-Output "home=$env:USERPROFILE"',
83
+ '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\' }',
84
+ 'Write-Output "os=$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows) ? \'win32\' : \'other\')"',
85
+ ].join('; ');
86
+
87
+ try {
88
+ const output = execSshWithRetry([host, 'pwsh', '-NoProfile', '-Command', command], {
89
+ encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
90
+ maxRetries: 2, baseDelayMs: 1000,
91
+ });
92
+ return normalizePwshProbeEnv(parseProbeLines(output));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function probeRemoteEnvViaPosix(host) {
99
+ const script = [
100
+ 'echo shell=$(basename $SHELL)',
101
+ 'echo home=$HOME',
102
+ 'command -v claude >/dev/null 2>&1 && echo claude=$(command -v claude) || echo claude=notfound',
103
+ 'echo os=$(uname -s | tr A-Z a-z)',
104
+ ].join('\n');
105
+
106
+ try {
107
+ const output = execSshWithRetry([host, 'sh'], {
108
+ encoding: 'utf8', timeout: 15000, input: script,
109
+ maxRetries: 2, baseDelayMs: 1000,
110
+ });
111
+ return normalizePosixProbeEnv(parseProbeLines(output));
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ // ── Cache ───────────────────────────────────────────────────────
118
+
119
+ function getEnvCachePath(host, cacheDir) {
120
+ return join(cacheDir, `${host}.json`);
121
+ }
122
+
123
+ function readEnvCache(host, cacheDir) {
124
+ const cachePath = getEnvCachePath(host, cacheDir);
125
+ if (!existsSync(cachePath)) return null;
126
+ try {
127
+ const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
128
+ return parsed && typeof parsed === 'object' ? parsed : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ function isEnvCacheFresh(entry) {
135
+ return Boolean(
136
+ entry
137
+ && typeof entry.cachedAt === 'number'
138
+ && entry.env
139
+ && (Date.now() - entry.cachedAt) < REMOTE_ENV_TTL_MS,
140
+ );
141
+ }
142
+
143
+ function writeEnvCache(host, env, cacheDir) {
144
+ mkdirSync(cacheDir, { recursive: true });
145
+ writeFileSync(getEnvCachePath(host, cacheDir), JSON.stringify({ cachedAt: Date.now(), env }, null, 2), 'utf8');
146
+ }
147
+
148
+ /**
149
+ * Probe remote host environment (OS, shell, Claude path, home dir).
150
+ * Results are cached for 24h.
151
+ *
152
+ * @param {string} host — SSH host
153
+ * @param {object} [opts]
154
+ * @param {boolean} [opts.force=false] — bypass cache
155
+ * @param {string} [opts.cacheDir] — cache directory (default: .omc/state/remote-env)
156
+ * @returns {Readonly<RemoteEnv>}
157
+ */
158
+ export function probeRemoteEnv(host, opts = {}) {
159
+ validateHost(host);
160
+ const force = opts.force === true;
161
+ const cacheDir = opts.cacheDir || join('.omc', 'state', 'remote-env');
162
+
163
+ if (!force) {
164
+ const cached = readEnvCache(host, cacheDir);
165
+ if (isEnvCacheFresh(cached)) return cached.env;
166
+ }
167
+
168
+ const pwshEnv = probeRemoteEnvViaPwsh(host);
169
+ if (pwshEnv) { writeEnvCache(host, pwshEnv, cacheDir); return pwshEnv; }
170
+
171
+ const posixEnv = probeRemoteEnvViaPosix(host);
172
+ if (posixEnv) { writeEnvCache(host, posixEnv, cacheDir); return posixEnv; }
173
+
174
+ throw new Error(`remote probe failed for ${host}`);
175
+ }
176
+
177
+ // ── Remote directory resolution ─────────────────────────────────
178
+
179
+ function isWindowsAbsolutePath(value) {
180
+ return /^[a-zA-Z]:[\\/]/u.test(value) || value.startsWith('\\\\');
181
+ }
182
+
183
+ /**
184
+ * Resolve a directory path on a remote host.
185
+ * Handles ~ expansion and OS-specific path normalization.
186
+ *
187
+ * @param {string} dir — requested directory (or empty for home)
188
+ * @param {RemoteEnv} env
189
+ * @returns {string}
190
+ */
191
+ export function resolveRemoteDir(dir, env) {
192
+ const requestedDir = dir || env.home;
193
+
194
+ if (env.os === 'win32') {
195
+ const winDir = requestedDir.replace(/\//g, '\\');
196
+ if (winDir === '~') return env.home;
197
+ if (/^~[\\/]/u.test(winDir)) return win32Path.join(env.home, winDir.slice(2));
198
+ if (isWindowsAbsolutePath(winDir)) return winDir;
199
+ return win32Path.join(env.home, winDir);
200
+ }
201
+
202
+ if (requestedDir === '~') return env.home;
203
+ if (requestedDir.startsWith('~/')) return posixPath.join(env.home, requestedDir.slice(2));
204
+ if (requestedDir.startsWith('/')) return requestedDir;
205
+ return posixPath.join(env.home, requestedDir);
206
+ }
207
+
208
+ // ── Remote file staging ─────────────────────────────────────────
209
+
210
+ /**
211
+ * Resolve the remote staging directory path.
212
+ * @param {RemoteEnv} env
213
+ * @param {string} stageId
214
+ * @returns {string}
215
+ */
216
+ export function resolveRemoteStageDir(env, stageId) {
217
+ return `${normalizeCommandPath(env.home)}/${REMOTE_STAGE_ROOT}/${stageId}`;
218
+ }
219
+
220
+ /**
221
+ * Ensure the remote staging directory exists via SSH.
222
+ * @param {string} host
223
+ * @param {RemoteEnv} env
224
+ * @param {string} remoteStageDir
225
+ */
226
+ export function ensureRemoteStageDir(host, env, remoteStageDir) {
227
+ if (env.os === 'win32') {
228
+ const safePath = escapePwshSingleQuoted(remoteStageDir);
229
+ execFileSync('ssh', [host, 'pwsh', '-NoProfile', '-Command', `New-Item -ItemType Directory -Path '${safePath}' -Force | Out-Null`], { timeout: 10000, stdio: 'pipe' });
230
+ return;
231
+ }
232
+ execFileSync('ssh', [host, 'sh', '-lc', `mkdir -p ${shellQuote(remoteStageDir)}`], { timeout: 10000, stdio: 'pipe' });
233
+ }
234
+
235
+ /**
236
+ * Upload a file to remote host via scp.
237
+ * @param {string} host
238
+ * @param {string} localPath
239
+ * @param {string} remotePath
240
+ */
241
+ export function uploadFileToRemote(host, localPath, remotePath) {
242
+ execFileSync('scp', [localPath, `${host}:${remotePath}`], { timeout: 15000, stdio: 'pipe' });
243
+ }
244
+
245
+ /**
246
+ * Stage local files on a remote host for prompt delivery.
247
+ *
248
+ * @param {string} host
249
+ * @param {RemoteEnv} env
250
+ * @param {Array<{ localPath: string }>} transferCandidates
251
+ * @param {string} stageId
252
+ * @returns {{ remoteStageDir: string|null, stagedFiles: Array<{ localPath: string, remotePath: string }> }}
253
+ */
254
+ export function stageRemotePromptFiles(host, env, transferCandidates, stageId) {
255
+ if (!transferCandidates || transferCandidates.length === 0) {
256
+ return { remoteStageDir: null, stagedFiles: [] };
257
+ }
258
+
259
+ const remoteStageDir = resolveRemoteStageDir(env, stageId);
260
+ ensureRemoteStageDir(host, env, remoteStageDir);
261
+
262
+ const basenameCounts = new Map();
263
+ const stagedFiles = transferCandidates.map((candidate) => {
264
+ const fileName = basename(candidate.localPath);
265
+ const count = (basenameCounts.get(fileName) || 0) + 1;
266
+ basenameCounts.set(fileName, count);
267
+ const stagedName = count === 1 ? fileName : `${count}-${fileName}`;
268
+ const remotePath = `${remoteStageDir}/${stagedName}`;
269
+ uploadFileToRemote(host, candidate.localPath, remotePath);
270
+ return { ...candidate, remotePath };
271
+ });
272
+
273
+ return { remoteStageDir, stagedFiles };
274
+ }
275
+
276
+ /**
277
+ * Execute a git command on a remote host via SSH.
278
+ *
279
+ * @param {string} host
280
+ * @param {RemoteEnv} env
281
+ * @param {string[]} gitArgs — git subcommand + args
282
+ * @param {string} cwd — remote working directory
283
+ * @returns {string} stdout
284
+ */
285
+ export function remoteGit(host, env, gitArgs, cwd) {
286
+ const gitCmd = ['git', ...gitArgs].map((a) => shellQuote(a)).join(' ');
287
+
288
+ if (env.os === 'win32') {
289
+ const cdPath = escapePwshSingleQuoted(cwd);
290
+ const command = `Set-Location '${cdPath}'; ${gitCmd}`;
291
+ return execFileSync('ssh', [host, 'pwsh', '-NoProfile', '-Command', command], {
292
+ encoding: 'utf8', timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'],
293
+ }).trim();
294
+ }
295
+
296
+ return execFileSync('ssh', [host, 'sh', '-lc', `cd ${shellQuote(cwd)} && ${gitCmd}`], {
297
+ encoding: 'utf8', timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'],
298
+ }).trim();
299
+ }
@@ -18,6 +18,9 @@ import { execSync } from 'node:child_process';
18
18
  import { createConductor, STATES } from './conductor.mjs';
19
19
  import { createSwarmLocks } from './swarm-locks.mjs';
20
20
  import { createEventLog } from './event-log.mjs';
21
+ import { probeRemoteEnv, resolveRemoteDir } from './remote-session.mjs';
22
+ import { fetchRemoteShard } from './worktree-lifecycle.mjs';
23
+ import { getHostConfig } from '@triflux/core/hub/lib/ssh-command.mjs';
21
24
 
22
25
  // ── Swarm states ──────────────────────────────────────────────
23
26
 
@@ -103,19 +106,51 @@ export function createSwarmHypervisor(opts) {
103
106
  // ── Worker lifecycle ────────────────────────────────────────
104
107
 
105
108
  function buildSessionConfig(shard) {
106
- return {
109
+ const config = {
107
110
  id: `swarm-${shard.name}-${Date.now()}`,
108
111
  agent: shard.agent,
109
112
  prompt: shard.prompt,
110
113
  workdir,
111
114
  mcpServers: shard.mcp,
112
115
  };
116
+
117
+ // Remote shard: add conductor remote fields
118
+ if (shard.host && shard._remoteEnv) {
119
+ const remoteDir = resolveRemoteDir(workdir, shard._remoteEnv);
120
+ return {
121
+ ...config,
122
+ remote: true,
123
+ host: shard.host,
124
+ sessionName: `swarm-${shard.name}-${Date.now()}`,
125
+ paneTarget: `swarm-${shard.name}-${Date.now()}:0.0`,
126
+ workdir: remoteDir,
127
+ };
128
+ }
129
+
130
+ return config;
113
131
  }
114
132
 
115
133
  function launchShard(shard, isRedundant = false) {
116
134
  const shardLogsDir = join(logsDir, isRedundant ? `${shard.name}-redundant` : shard.name);
117
135
  mkdirSync(shardLogsDir, { recursive: true });
118
136
 
137
+ // Remote shard: probe environment before conductor creation
138
+ if (shard.host && !shard._remoteEnv) {
139
+ try {
140
+ shard._remoteEnv = probeRemoteEnv(shard.host);
141
+ if (!shard._remoteEnv.claudePath) {
142
+ eventLog.append('remote_probe_no_claude', { shard: shard.name, host: shard.host });
143
+ failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `claude not found on ${shard.host}` });
144
+ return null;
145
+ }
146
+ eventLog.append('remote_probe_ok', { shard: shard.name, host: shard.host, env: shard._remoteEnv });
147
+ } catch (err) {
148
+ eventLog.append('remote_probe_failed', { shard: shard.name, host: shard.host, error: err.message });
149
+ failures.set(shard.name, { mode: FAILURE_MODES.F1_CRASH, reason: `remote probe failed: ${err.message}` });
150
+ return null;
151
+ }
152
+ }
153
+
119
154
  const conductor = createConductor({
120
155
  logsDir: shardLogsDir,
121
156
  maxRestarts,
@@ -150,6 +185,8 @@ export function createSwarmHypervisor(opts) {
150
185
  sessionId: sessionConfig.id,
151
186
  isRedundant,
152
187
  files: shard.files,
188
+ remote: Boolean(shard.host),
189
+ host: shard.host || null,
153
190
  });
154
191
 
155
192
  const entry = { conductor, shardConfig: shard, sessionConfig, startedAt: Date.now() };
@@ -302,6 +339,28 @@ export function createSwarmHypervisor(opts) {
302
339
  const worker = workers.get(shardName);
303
340
  if (!worker) continue;
304
341
 
342
+ // Fetch remote shard branch to local (push-blocked hosts like Ultra4)
343
+ const shard = plan.shards.find((s) => s.name === shardName);
344
+ if (shard?.host && shard._remoteEnv) {
345
+ const hostConfig = getHostConfig(shard.host, config.rootDir);
346
+ const sshUser = hostConfig?.ssh_user || shard.host;
347
+ const remoteRepoPath = resolveRemoteDir(config.rootDir || process.cwd(), shard._remoteEnv);
348
+ const fetchResult = await fetchRemoteShard({
349
+ host: shard.host,
350
+ sshUser,
351
+ remoteRepoPath,
352
+ branchName: worker.branchName || `swarm/${config.runId}/${shardName}`,
353
+ rootDir: config.rootDir || process.cwd(),
354
+ });
355
+
356
+ if (!fetchResult.ok) {
357
+ eventLog.append('remote_fetch_failed', { shard: shardName, error: fetchResult.error });
358
+ integrationFailures.push(shardName);
359
+ continue;
360
+ }
361
+ eventLog.append('remote_fetch_ok', { shard: shardName, headCommit: fetchResult.headCommit });
362
+ }
363
+
305
364
  // Read shard output log for changed files
306
365
  const changedFiles = detectChangedFiles(shardName, worker);
307
366
 
@@ -24,6 +24,7 @@ const SHARD_DEFAULTS = Object.freeze({
24
24
  depends: [],
25
25
  critical: false,
26
26
  prompt: '',
27
+ host: '',
27
28
  });
28
29
 
29
30
  /**
@@ -105,6 +106,9 @@ export function parseShards(content) {
105
106
  case 'critical':
106
107
  current.critical = /^(true|yes|1)$/i.test(value);
107
108
  break;
109
+ case 'host':
110
+ current.host = value;
111
+ break;
108
112
  case 'prompt':
109
113
  if (value && !value.startsWith('|')) {
110
114
  current.prompt = value;
@@ -1,10 +1,12 @@
1
1
  // hub/team/worktree-lifecycle.mjs — Git worktree lifecycle management
2
2
  // Replaces shell commands in tfx-codex-swarm Step 5 + merge-worktree Phase 6.
3
3
  // Convention: .codex-swarm/wt-{slug} paths, swarm/{runId}/{slug} branches.
4
+ // Remote support: host option → SSH-based git operations via remote-session.mjs.
4
5
 
5
6
  import { execFile } from 'node:child_process';
6
7
  import { resolve, normalize } from 'node:path';
7
8
  import { mkdir, rm, access } from 'node:fs/promises';
9
+ import { remoteGit, validateHost } from './remote-session.mjs';
8
10
 
9
11
  const SWARM_ROOT = '.codex-swarm';
10
12
  const SLEEP_MS = 2000; // WT race-guard (MEMORY.md: wt-attach-spacing)
@@ -39,39 +41,58 @@ function normPath(p) {
39
41
  * @param {string} opts.runId — swarm run ID
40
42
  * @param {string} [opts.rootDir=process.cwd()] — repo root
41
43
  * @param {string} [opts.baseBranch='main'] — base branch to branch from
42
- * @returns {Promise<{ worktreePath: string, branchName: string }>}
44
+ * @param {string} [opts.host] — SSH host for remote worktree creation
45
+ * @param {object} [opts.remoteEnv] — remote environment from probeRemoteEnv()
46
+ * @returns {Promise<{ worktreePath: string, branchName: string, remote: boolean }>}
43
47
  */
44
- export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), baseBranch = 'main' }) {
45
- const wtDir = resolve(rootDir, SWARM_ROOT, `wt-${slug}`);
48
+ export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), baseBranch = 'main', host, remoteEnv }) {
46
49
  const branchName = `swarm/${runId}/${slug}`;
47
50
 
51
+ // ── Remote path: SSH-based worktree creation ──
52
+ if (host && remoteEnv) {
53
+ const remoteRoot = rootDir.replace(/\\/g, '/');
54
+ const remoteWtDir = `${remoteRoot}/${SWARM_ROOT}/wt-${slug}`;
55
+
56
+ try { remoteGit(host, remoteEnv, ['worktree', 'prune'], remoteRoot); } catch { /* best-effort */ }
57
+
58
+ try {
59
+ remoteGit(host, remoteEnv, ['worktree', 'add', remoteWtDir, '-b', branchName, baseBranch], remoteRoot);
60
+ } catch {
61
+ try {
62
+ remoteGit(host, remoteEnv, ['worktree', 'add', remoteWtDir, branchName], remoteRoot);
63
+ } catch { /* already exists — acceptable */ }
64
+ }
65
+
66
+ return { worktreePath: remoteWtDir, branchName, remote: true };
67
+ }
68
+
69
+ // ── Local path (existing logic) ──
70
+ const wtDir = resolve(rootDir, SWARM_ROOT, `wt-${slug}`);
71
+
48
72
  await mkdir(resolve(rootDir, SWARM_ROOT), { recursive: true });
49
73
 
50
74
  // Check if worktree already exists
51
75
  try {
52
76
  await access(wtDir);
53
- // Already exists — verify it's a valid worktree
54
77
  await git(['rev-parse', '--is-inside-work-tree'], wtDir);
55
- return { worktreePath: normPath(wtDir), branchName };
78
+ return { worktreePath: normPath(wtDir), branchName, remote: false };
56
79
  } catch {
57
80
  // Doesn't exist or invalid — create fresh
58
81
  }
59
82
 
60
- // Clean up stale references
61
83
  try {
62
84
  await git(['worktree', 'prune'], rootDir);
63
85
  } catch { /* best-effort */ }
64
86
 
65
87
  await sleep(SLEEP_MS);
66
88
 
67
- // Try create with new branch, fallback to existing branch
68
89
  try {
69
90
  await git(['worktree', 'add', wtDir, '-b', branchName, baseBranch], rootDir);
70
91
  } catch {
71
92
  await git(['worktree', 'add', wtDir, branchName], rootDir);
72
93
  }
73
94
 
74
- return { worktreePath: normPath(wtDir), branchName };
95
+ return { worktreePath: normPath(wtDir), branchName, remote: false };
75
96
  }
76
97
 
77
98
  /**
@@ -170,3 +191,39 @@ export async function pruneWorktree({ worktreePath, branchName, rootDir = proces
170
191
  try { await git(['branch', '-D', branchName], rootDir); } catch { /* may not exist */ }
171
192
  }
172
193
  }
194
+
195
+ /**
196
+ * Fetch a remote shard's branch to the local repo via SSH.
197
+ * Workaround for hosts that cannot push to GitHub (e.g. Ultra4).
198
+ *
199
+ * Flow: add temp remote → fetch branch → remove remote.
200
+ *
201
+ * @param {object} opts
202
+ * @param {string} opts.host — SSH host (e.g. "ultra4")
203
+ * @param {string} opts.sshUser — SSH user (e.g. "SSAFY")
204
+ * @param {string} opts.remoteRepoPath — absolute path on remote (e.g. "/c/Users/SSAFY/Desktop/Projects/cli/triflux")
205
+ * @param {string} opts.branchName — branch to fetch (e.g. "swarm/run123/auth")
206
+ * @param {string} [opts.rootDir=process.cwd()] — local repo root
207
+ * @returns {Promise<{ ok: boolean, localRef?: string, error?: string }>}
208
+ */
209
+ export async function fetchRemoteShard({ host, sshUser, remoteRepoPath, branchName, rootDir = process.cwd() }) {
210
+ validateHost(host);
211
+
212
+ const remoteName = `_swarm-${host}-${Date.now()}`;
213
+ const sshUrl = `ssh://${sshUser}@${host}${remoteRepoPath}`;
214
+
215
+ try {
216
+ await git(['remote', 'add', remoteName, sshUrl], rootDir);
217
+
218
+ await git(['fetch', remoteName, branchName, '--no-tags'], rootDir);
219
+
220
+ const localRef = `${remoteName}/${branchName}`;
221
+ const headCommit = await git(['rev-parse', `FETCH_HEAD`], rootDir);
222
+
223
+ return { ok: true, localRef, headCommit };
224
+ } catch (err) {
225
+ return { ok: false, error: err.message };
226
+ } finally {
227
+ try { await git(['remote', 'remove', remoteName], rootDir); } catch { /* cleanup */ }
228
+ }
229
+ }