Skip to content

Commit 71d521d

Browse files
authored
Merge pull request #62 from AgentWorkforce/relayfile-async-mount
Make CLI launch spinners actually animate
2 parents 7a4ba1f + e2ff76b commit 71d521d

3 files changed

Lines changed: 126 additions & 53 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"@agentworkforce/harness-kit": "workspace:*",
1414
"@agentworkforce/workload-router": "workspace:*",
1515
"@relayburn/sdk": "^2.3.0",
16-
"@relayfile/local-mount": "^0.6.15",
16+
"@relayfile/local-mount": "^0.7.0",
1717
"ora": "^9.4.0"
1818
},
1919
"repository": {

packages/cli/src/cli.ts

Lines changed: 120 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -421,27 +421,41 @@ function subprocessExitCode(res: ReturnType<typeof spawnSync>): number {
421421
* `label` (which includes target paths and skill ids) is shown on
422422
* success/failure so the verbose detail is still discoverable in logs.
423423
*/
424-
function runInstallWithSpinner(
424+
async function runInstallWithSpinner(
425425
command: readonly string[],
426426
label: string,
427427
cwd: string | undefined
428-
): { code: number; output: string } {
428+
): Promise<{ code: number; output: string }> {
429429
const [bin, ...args] = command;
430430
if (!bin) return { code: 0, output: '' };
431431
const spinner = ora({ text: 'Installing skills…', stream: process.stderr }).start();
432-
const res = spawnSync(bin, args, {
433-
stdio: ['ignore', 'pipe', 'pipe'],
434-
shell: false,
435-
encoding: 'utf8',
436-
// Default is 1 MiB; verbose `npx prpm install` / `npx skills add` runs
437-
// can blow past it, which would have spawnSync kill the child with
438-
// ENOBUFS and report a spurious failure. 100 MiB is well past anything
439-
// these installers print in practice.
440-
maxBuffer: 100 * 1024 * 1024,
441-
...(cwd ? { cwd } : {})
432+
// Async spawn (not spawnSync) so ora's frame timer can fire during the
433+
// install — spawnSync blocks the event loop and freezes the spinner on
434+
// its first frame.
435+
const { code, output } = await new Promise<{ code: number; output: string }>((resolve) => {
436+
const child = spawn(bin, args, {
437+
stdio: ['ignore', 'pipe', 'pipe'],
438+
shell: false,
439+
...(cwd ? { cwd } : {})
440+
});
441+
let buffered = '';
442+
child.stdout?.setEncoding('utf8');
443+
child.stderr?.setEncoding('utf8');
444+
child.stdout?.on('data', (chunk: string) => {
445+
buffered += chunk;
446+
});
447+
child.stderr?.on('data', (chunk: string) => {
448+
buffered += chunk;
449+
});
450+
child.on('error', (err) => {
451+
resolve({ code: 1, output: `${buffered}${err.message}\n` });
452+
});
453+
child.on('close', (status, signal) => {
454+
const exit =
455+
typeof status === 'number' ? status : signal ? signalExitCode(signal) : 1;
456+
resolve({ code: exit, output: buffered });
457+
});
442458
});
443-
const output = `${res.stdout ?? ''}${res.stderr ?? ''}`;
444-
const code = subprocessExitCode(res);
445459
if (code === 0) {
446460
spinner.succeed(label);
447461
} else {
@@ -451,12 +465,12 @@ function runInstallWithSpinner(
451465
return { code, output };
452466
}
453467

454-
function runInstall(command: readonly string[], label: string, cwd?: string): void {
468+
async function runInstall(command: readonly string[], label: string, cwd?: string): Promise<void> {
455469
const [bin] = command;
456470
if (!bin) return;
457471
// runInstallWithSpinner already prints the failure line via spinner.fail;
458472
// the previous extra "${label} failed … Aborting." write would duplicate it.
459-
const { code } = runInstallWithSpinner(command, label, cwd);
473+
const { code } = await runInstallWithSpinner(command, label, cwd);
460474
if (code !== 0) process.exit(code);
461475
}
462476

@@ -479,10 +493,14 @@ class InstallCommandError extends Error {
479493
* Used inside the mount branch's onBeforeLaunch step so mount teardown runs
480494
* before the error surfaces.
481495
*/
482-
function runInstallOrThrow(command: readonly string[], label: string, cwd: string): void {
496+
async function runInstallOrThrow(
497+
command: readonly string[],
498+
label: string,
499+
cwd: string
500+
): Promise<void> {
483501
const [bin] = command;
484502
if (!bin) return;
485-
const { code } = runInstallWithSpinner(command, label, cwd);
503+
const { code } = await runInstallWithSpinner(command, label, cwd);
486504
if (code !== 0) {
487505
throw new InstallCommandError(label, code);
488506
}
@@ -1146,7 +1164,7 @@ async function runInteractive(
11461164
const deferInstallToMount =
11471165
useClean && runtime.harness !== 'claude' && install.commandString !== ':';
11481166
if (install.commandString !== ':' && !deferInstallToMount) {
1149-
runInstall(install.command, installLabel);
1167+
await runInstall(install.command, installLabel);
11501168
}
11511169

11521170
const spec = buildInteractiveSpec({
@@ -1239,31 +1257,59 @@ async function runInteractive(
12391257
mount: effectiveSelection.mount,
12401258
configFilePaths: spec.configFiles.map((file) => file.path)
12411259
});
1242-
process.stderr.write(`• sandbox mount → ${mountDir}\n`);
1260+
// Setup spinner covers createMount + git-config + (optional) in-mount
1261+
// install + config-file writes + autosync start, so the multi-second
1262+
// pause before the harness child appears is visibly live. createMount
1263+
// is async in @relayfile/local-mount ≥0.7.0, which yields between
1264+
// directory entries — so this spinner actually animates instead of
1265+
// freezing on its first frame.
1266+
let setupSpinner: Ora | undefined = ora({
1267+
text: `Setting up sandbox mount → ${mountDir}…`,
1268+
stream: process.stderr
1269+
}).start();
12431270
// Inline mount lifecycle (formerly delegated to launchOnMount) so we can
12441271
// surface a spinner the moment the child exits — not just when the user
12451272
// presses Ctrl-C. The sync-back walks both trees and can take several
12461273
// seconds on a large repo; without an indicator, exiting the persona via
12471274
// /exit looked like a hang.
12481275
//
1249-
// SIGINT semantics:
1250-
// • While the child is running: Ctrl-C reaches the harness directly via
1251-
// the controlling TTY's foreground process group (the child is
1252-
// spawned with `stdio: 'inherit'` and inherits the parent's pgid). We
1253-
// register a no-op handler here purely to suppress Node's default
1254-
// exit-on-SIGINT — forwarding via child.kill('SIGINT') would deliver
1255-
// a *second* SIGINT and break harnesses that escalate on repeated
1256-
// interrupts (e.g. claude treats 1st = cancel, 2nd = quit).
1257-
// • While syncing: 1st press aborts the shutdownSignal (relayfile then
1258-
// skips autosync's draining reconcile and returns the partial count
1259-
// from the final syncBack). 2nd press hard-exits and rms the session
1260-
// dir so no mount is left behind.
1276+
// SIGINT semantics — three phases:
1277+
// • Pre-launch (setup): tear down the setup spinner, rm the session
1278+
// dir, and exit(130). We must handle this ourselves because
1279+
// registering any 'SIGINT' listener suppresses Node's default
1280+
// exit-on-SIGINT, and createMount is now async (relayfile 0.7+) so
1281+
// the handler actually fires during mount setup.
1282+
// • Child running: Ctrl-C reaches the harness directly via the
1283+
// controlling TTY's foreground process group (the child is spawned
1284+
// with `stdio: 'inherit'` and inherits the parent's pgid). We
1285+
// no-op purely to suppress Node's default exit — forwarding via
1286+
// child.kill('SIGINT') would deliver a *second* SIGINT and break
1287+
// harnesses that escalate on repeated interrupts (e.g. claude
1288+
// treats 1st = cancel, 2nd = quit).
1289+
// • Syncing (post-child): 1st press aborts the shutdownSignal
1290+
// (relayfile then skips autosync's draining reconcile and returns
1291+
// the partial count from the final syncBack). 2nd press hard-exits
1292+
// and rms the session dir so no mount is left behind.
12611293
const shutdownController = new AbortController();
12621294
let syncSpinner: Ora | undefined;
12631295
let isSyncing = false;
1296+
let childSpawned = false;
12641297
let abortPresses = 0;
12651298
const sigintHandler = () => {
1266-
if (!isSyncing) return;
1299+
if (!isSyncing) {
1300+
if (childSpawned) return;
1301+
// Pre-launch teardown.
1302+
if (setupSpinner) {
1303+
setupSpinner.fail('Sandbox mount setup interrupted (Ctrl-C)');
1304+
setupSpinner = undefined;
1305+
}
1306+
try {
1307+
rmSync(sessionRoot, { recursive: true, force: true });
1308+
} catch {
1309+
/* swallow — we're exiting anyway */
1310+
}
1311+
process.exit(130);
1312+
}
12671313
abortPresses += 1;
12681314
if (abortPresses === 1) {
12691315
if (syncSpinner) {
@@ -1290,27 +1336,35 @@ async function runInteractive(
12901336
};
12911337
process.on('SIGINT', sigintHandler);
12921338

1293-
const handle = createMount(process.cwd(), mountDir, {
1294-
ignoredPatterns: [...ignoredPatterns],
1295-
readonlyPatterns: [...readonlyPatterns],
1296-
excludeDirs: [],
1297-
agentName: personaId,
1298-
// Pull `.git` into the mount so git commands work inside the sandbox.
1299-
// relayfile treats this as one-way project→mount: host-side `.git`
1300-
// changes flow in, mount-side commits/refs stay sandboxed and are
1301-
// discarded on cleanup. The agent must `git push` to persist work.
1302-
includeGit: true
1303-
});
1339+
let handle: Awaited<ReturnType<typeof createMount>> | undefined;
13041340
let autoSync: AutoSyncHandle | undefined;
13051341
let exitCode = 0;
13061342
try {
1343+
// createMount inside the try so its initial-mirror failures fall into
1344+
// the catch path and clean up the setup spinner.
1345+
handle = await createMount(process.cwd(), mountDir, {
1346+
ignoredPatterns: [...ignoredPatterns],
1347+
readonlyPatterns: [...readonlyPatterns],
1348+
excludeDirs: [],
1349+
agentName: personaId,
1350+
// Pull `.git` into the mount so git commands work inside the
1351+
// sandbox. relayfile treats this as one-way project→mount: host-side
1352+
// `.git` changes flow in, mount-side commits/refs stay sandboxed and
1353+
// are discarded on cleanup. The agent must `git push` to persist
1354+
// work.
1355+
includeGit: true
1356+
});
13071357
// Run before install / configFile writes so the freshly written files
13081358
// (e.g. `.opencode/`, `opencode.json`) aren't yet present when we run
13091359
// `git ls-files` to pick skip-worktree candidates — we don't need them
13101360
// flagged in the index, just hidden via the `.git/info/exclude` block.
13111361
configureGitForMount(handle.mountDir, ignoredPatterns);
13121362
if (deferInstallToMount) {
1313-
runInstallOrThrow(install.command, installLabel, handle.mountDir);
1363+
// Hand the line off to the install spinner so the two don't fight
1364+
// for the same stream, then resume the setup spinner afterwards.
1365+
setupSpinner?.stop();
1366+
await runInstallOrThrow(install.command, installLabel, handle.mountDir);
1367+
setupSpinner?.start();
13141368
}
13151369
for (const file of spec.configFiles) {
13161370
assertSafeRelativePath(file.path);
@@ -1326,10 +1380,21 @@ async function runInteractive(
13261380

13271381
autoSync = handle.startAutoSync();
13281382

1383+
// Stop the setup spinner before spawning the child — the child
1384+
// inherits stdio and would otherwise interleave its output with
1385+
// spinner frames.
1386+
setupSpinner?.succeed(`Sandbox mount ready → ${mountDir}`);
1387+
setupSpinner = undefined;
1388+
13291389
const childEnv = resolvedEnv ? { ...process.env, ...resolvedEnv } : process.env;
1390+
const childCwd = handle.mountDir;
1391+
// Flip the SIGINT phase flag before spawn so a Ctrl-C arriving during
1392+
// the child's lifetime is treated as "child has the TTY" (no-op),
1393+
// not as pre-launch teardown.
1394+
childSpawned = true;
13301395
exitCode = await new Promise<number>((resolve, reject) => {
13311396
const child = spawn(spec.bin, finalArgs, {
1332-
cwd: handle.mountDir,
1397+
cwd: childCwd,
13331398
stdio: 'inherit',
13341399
env: childEnv
13351400
});
@@ -1371,6 +1436,10 @@ async function runInteractive(
13711436
syncSpinner = undefined;
13721437
return exitCode;
13731438
} catch (err) {
1439+
if (setupSpinner) {
1440+
setupSpinner.fail('Sandbox mount setup failed');
1441+
setupSpinner = undefined;
1442+
}
13741443
if (syncSpinner) {
13751444
syncSpinner.fail('Sync did not complete');
13761445
syncSpinner = undefined;
@@ -1393,6 +1462,10 @@ async function runInteractive(
13931462
process.stderr.write(`Failed to launch sandbox mount: ${e.message}\n`);
13941463
return 1;
13951464
} finally {
1465+
if (setupSpinner) {
1466+
setupSpinner.stop();
1467+
setupSpinner = undefined;
1468+
}
13961469
if (syncSpinner) {
13971470
syncSpinner.stop();
13981471
syncSpinner = undefined;
@@ -1406,7 +1479,7 @@ async function runInteractive(
14061479
/* ignore — we're tearing down anyway */
14071480
}
14081481
}
1409-
handle.cleanup();
1482+
handle?.cleanup();
14101483
await launchMetadata?.stop();
14111484
process.removeListener('SIGINT', sigintHandler);
14121485
// When the install ran inside the mount, its cleanup paths are

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)