@@ -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
0 commit comments