Skip to content

Show spinners during persona session install and exit sync#59

Merged
willwashburn merged 2 commits intomainfrom
fix-cli-launch-spinners
May 8, 2026
Merged

Show spinners during persona session install and exit sync#59
willwashburn merged 2 commits intomainfrom
fix-cli-launch-spinners

Conversation

@willwashburn
Copy link
Copy Markdown
Member

Summary

  • Wrap skill install (npx prpm install / npx skills add) in an "Installing skills…" ora spinner with stdio captured. Successful runs collapse to a single ✓ line; failures dump the buffered output after spinner.fail. Replaces the prior raw output that looked broken at session start.
  • Inline the mount lifecycle (formerly launchOnMount) so the sync-back spinner appears the moment the persona child exits — not just when the user presses Ctrl-C. Exiting via /exit no longer looks like a silent hang. Abort reduced from 3 presses to 2 (1st press during sync aborts immediately; 2nd force-quits and rms the session dir).
  • Bump @relayfile/local-mount from ^0.6.14 to ^0.6.15.
  • Isolate AGENT_WORKFORCE_HOME in the --version test so the load-time local-persona scan can't surface warnings from developer-local state.

Test plan

  • pnpm typecheck (after a fresh workspace pnpm build — workload-router's generated types must be up to date)
  • pnpm test from packages/cli — 126/126 pass
  • Manual: launch a persona, watch the "Installing skills…" spinner during start
  • Manual: /exit a persona session, confirm the "Syncing session changes back…" spinner appears immediately and Ctrl-C aborts on first press

🤖 Generated with Claude Code

Two visible UX fixes for the CLI persona launch lifecycle:

- Skill install now runs behind an "Installing skills…" ora spinner with
  stdio captured. On success it collapses to a single line; on failure the
  buffered output is dumped after spinner.fail so the user still sees what
  broke. Replaces the prior raw `npx prpm install` / `npx skills add`
  output that looked broken to the eye.
- Exiting a persona session via /exit no longer hangs silently while
  relayfile syncs back. The mount lifecycle (createMount → onBeforeLaunch →
  startAutoSync → spawn → autoSync.stop → syncBack → cleanup) is now
  inlined so we own the gap between child-exit and finalize, and the sync
  spinner now starts the moment the child closes — not just on Ctrl-C.
  Reduced abort to 2 presses (was 3): 1st press during sync = abort
  immediately; 2nd press = force-quit and rm session dir.

Also bumps @relayfile/local-mount from ^0.6.14 to ^0.6.15, and isolates
AGENT_WORKFORCE_HOME in the --version test so the load-time persona scan
can't surface unrelated warnings from the developer's own ~/.agentworkforce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 79393cb4-a520-400a-87ce-3036a485ac8a

📥 Commits

Reviewing files that changed from the base of the PR and between 6b651cc and a2bcee3.

📒 Files selected for processing (1)
  • packages/cli/src/cli.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli/src/cli.ts

📝 Walkthrough

Walkthrough

The CLI inlines the @relayfile/local-mount lifecycle (createMount replaces launchOnMount), reworks SIGINT and teardown behavior, introduces spinner-backed install helpers, isolates the --version test environment, and bumps the dependency to ^0.6.15.

Changes

Mount-mode execution and install UI refactor

Layer / File(s) Summary
Dependency update and import refactor
packages/cli/package.json, packages/cli/src/cli.ts
@relayfile/local-mount bumped ^0.6.14^0.6.15; import switched from launchOnMount to createMount with AutoSyncHandle.
Install-time UI spinner helpers
packages/cli/src/cli.ts
Added runInstallWithSpinner capturing subprocess stdout/stderr and showing an ora spinner; runInstall and runInstallOrThrow updated to use the spinner helper.
Mount-mode execution refactor with createMount
packages/cli/src/cli.ts
Mount-mode inlines createMount: configures git skip-worktree, optionally runs deferred installs inside the mount, materializes config/sidecar files, records launch metadata, starts autosync, spawns the harness in the mount, then stops autosync and performs explicit syncBack with a spinner; SIGINT forwards to child, aborts on first Ctrl-C during syncing, and forces exit on subsequent Ctrl-C.
Teardown updates
packages/cli/src/cli.ts
Teardown stops the syncing spinner, best-effort stops autosync on error, calls handle.cleanup(), stops launch-metadata, removes SIGINT listener, and avoids duplicate cleanup when installs ran inside mount.
Test isolation
packages/cli/src/cli.test.ts
--version test creates temporary isolated AGENT_WORKFORCE_HOME with personas/, passes it into runCliCapturingStderr, and removes the directory in a finally block.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant CLI
  participant createMount
  participant Installer
  participant Harness
  participant AutoSync
  participant SyncBack
  User->>CLI: run CLI in mount-mode
  CLI->>createMount: create mount
  createMount-->>CLI: mount handle
  alt deferred installs
    CLI->>Installer: run installs inside mount
    Installer-->>CLI: install results
  end
  CLI->>Harness: spawn child inside mount
  Harness-->>CLI: child exits
  CLI->>AutoSync: stop autosync (if present)
  CLI->>SyncBack: run syncBack with spinner
  SyncBack-->>CLI: sync results
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped into mount-mode, bright and spry,
Spinners spinning as installs flew by,
Signals forwarded, syncs that mend,
Tests tucked safe until the end,
A tiny leap — the CLI says hi!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly matches the main changes: adding spinners during persona session install and exit sync, which are the primary UX improvements in this PR.
Description check ✅ Passed The description comprehensively covers all major changes in the changeset, including skill install spinners, mount lifecycle inlining, dependency bumps, and test isolation, with clear context and rationale.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-cli-launch-spinners

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b651cc321

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/cli/src/cli.ts Outdated
Comment on lines +1250 to +1253
if (activeChild && !activeChild.killed && activeChild.exitCode === null) {
try {
activeChild.kill('SIGINT');
} catch {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid sending SIGINT to child twice during interactive runs

In the non-syncing path this handler always calls activeChild.kill('SIGINT'), but with spawn(..., { stdio: 'inherit' }) the child is already in the foreground process group and receives the terminal Ctrl-C directly. That means each Ctrl-C can deliver two SIGINTs to the harness, which can skip graceful first-interrupt behavior and force premature termination for CLIs that escalate on repeated SIGINT.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in a2bcee3. The handler is now a true no-op while the child runs (its only purpose is suppressing Node's default exit-on-SIGINT). Removed the activeChild.kill('SIGINT') forward and the activeChild ref. Updated the surrounding comment to call out why the no-op is required.

Comment thread packages/cli/src/cli.ts
Comment on lines +419 to +423
const res = spawnSync(bin, args, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
encoding: 'utf8',
...(cwd ? { cwd } : {})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Raise spawnSync maxBuffer when capturing installer output

This install path now captures stdout/stderr via spawnSync pipes but does not set maxBuffer, so Node applies its default per-stream buffer limit (about 1 MiB). Verbose install commands can exceed that limit, causing spawnSync to terminate the subprocess (ENOBUFS/SIGTERM) and report a non-zero exit even when the install would otherwise complete, which is a regression from the previous inherited-stdio behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a2bcee3spawnSync now sets maxBuffer: 100 * 1024 * 1024 (100 MiB), well past anything these installers print in practice. Comment in-place explains why.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/cli/src/cli.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate failure message on install error in runInstallOrThrow path

Same pattern as runInstall: runInstallWithSpinner already outputs spinner.fail(...) with the failure message at packages/cli/src/cli.ts:430. When runInstallOrThrow throws InstallCommandError, the catch block at line 1379 writes ${err.message}. Aborting.\n — and err.message is "${label} failed (exit ${exitCode})" (set at packages/cli/src/cli.ts:454), producing a second nearly identical error line on stderr.

(Refers to lines 1378-1380)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a2bcee3 — the InstallCommandError catch no longer re-prints err.message. The failure line is shown once via spinner.fail inside runInstallWithSpinner.

Comment thread packages/cli/src/cli.ts Outdated
Comment on lines 440 to 442
if (code !== 0) {
process.stderr.write(`${label} failed (exit ${code}). Aborting.\n`);
process.exit(code);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate failure message on install error in runInstall

runInstallWithSpinner already writes a failure message via spinner.fail(${label} failed (exit ${code})) at packages/cli/src/cli.ts:430, and dumps captured output at line 431. Then runInstall at line 441 writes a nearly identical message "${label} failed (exit ${code}). Aborting.\n" to stderr before exiting. On a failed install the user sees the same failure reported twice:

Example duplicated output

✗ Installing skills: foo → /path failed (exit 1)
<captured subprocess output>
Installing skills: foo → /path failed (exit 1). Aborting.

Suggested change
if (code !== 0) {
process.stderr.write(`${label} failed (exit ${code}). Aborting.\n`);
process.exit(code);
if (code !== 0) {
process.exit(code);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a2bcee3runInstall no longer writes the duplicate "Aborting" line; the failure message already comes from spinner.fail inside runInstallWithSpinner.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/cli/src/cli.ts (1)

1246-1283: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add SIGTERM handler to the inline mount path for consistent signal cleanup.

The inline mount lifecycle registers only SIGINT, while the other execution path handles both SIGINT and SIGTERM. Without a SIGTERM listener, Node uses its default behavior (immediate exit), bypassing the sync and cleanup logic entirely. A SIGTERM signal during a session would skip syncBack() and leave the session directory behind.

Suggested refactor
-    const sigintHandler = () => {
+    const handleShutdownSignal = (signal: NodeJS.Signals) => {
       if (!isSyncing) {
         // Defensive: forward to child if it somehow didn't get the TTY signal.
         // No spinner side effects while the harness owns the screen.
         if (activeChild && !activeChild.killed && activeChild.exitCode === null) {
           try {
-            activeChild.kill('SIGINT');
+            activeChild.kill(signal);
           } catch {
             /* ignore — child likely exited between checks */
           }
         }
         return;
       }
       abortPresses += 1;
       if (abortPresses === 1) {
         if (syncSpinner) {
           syncSpinner.text =
             'Aborting sync — partial changes will be propagated. (Ctrl-C again to force quit)';
         }
         shutdownController.abort();
         return;
       }
       ...
     };
-    process.on('SIGINT', sigintHandler);
+    const sigintHandler = () => handleShutdownSignal('SIGINT');
+    const sigtermHandler = () => handleShutdownSignal('SIGTERM');
+    process.on('SIGINT', sigintHandler);
+    process.on('SIGTERM', sigtermHandler);
...
       process.removeListener('SIGINT', sigintHandler);
+      process.removeListener('SIGTERM', sigtermHandler);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli.ts` around lines 1246 - 1283, The inline mount path only
registers sigintHandler for SIGINT so SIGTERM bypasses cleanup; add a SIGTERM
listener that invokes the same sigintHandler to ensure syncSpinner,
shutdownController.abort(), rmSync(sessionRoot, ...) and process.exit handling
run for both signals. Locate the sigintHandler function and replace or augment
the process.on('SIGINT', sigintHandler) registration to also register
process.on('SIGTERM', sigintHandler) (or otherwise wire SIGTERM to call
sigintHandler) so both signals trigger the same teardown logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/cli.ts`:
- Around line 411-433: In runInstallWithSpinner, increase spawnSync's
stdout/stderr buffer by adding maxBuffer: 16 * 1024 * 1024 to the options and
include any spawn error text by appending res.error?.message (if present) to the
computed output so buffer exhaustion or spawn errors are visible; keep using
subprocessExitCode(res) but ensure the final output string includes
res.error?.message (and is written to stderr on failure) so ENOBUFS or other
spawn errors are distinguishable from normal command exit codes.

---

Outside diff comments:
In `@packages/cli/src/cli.ts`:
- Around line 1246-1283: The inline mount path only registers sigintHandler for
SIGINT so SIGTERM bypasses cleanup; add a SIGTERM listener that invokes the same
sigintHandler to ensure syncSpinner, shutdownController.abort(),
rmSync(sessionRoot, ...) and process.exit handling run for both signals. Locate
the sigintHandler function and replace or augment the process.on('SIGINT',
sigintHandler) registration to also register process.on('SIGTERM',
sigintHandler) (or otherwise wire SIGTERM to call sigintHandler) so both signals
trigger the same teardown logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 22410145-6940-4768-9210-d880b3f5908b

📥 Commits

Reviewing files that changed from the base of the PR and between 3fee405 and 6b651cc.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • packages/cli/package.json
  • packages/cli/src/cli.test.ts
  • packages/cli/src/cli.ts

Comment thread packages/cli/src/cli.ts
- Drop duplicate failure messages on install error: spinner.fail in
  runInstallWithSpinner already prints the failure line, so both runInstall
  and the InstallCommandError catch were re-printing nearly the same text.
- Stop double-delivering SIGINT to the harness: with stdio: 'inherit' the
  child shares the parent's process group, so Ctrl-C at the TTY already
  reaches it. Forwarding via child.kill('SIGINT') from our handler
  delivered a second SIGINT, which can break harnesses that escalate on
  repeated interrupts (e.g. claude treats 1st = cancel, 2nd = quit). The
  handler now stays a true no-op while the child runs, only there to
  suppress Node's default exit-on-SIGINT.
- Raise spawnSync maxBuffer to 100 MiB for the captured installer output.
  Default is 1 MiB; verbose `npx prpm install` / `npx skills add` runs
  can blow past it and have spawnSync kill the child with ENOBUFS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@willwashburn willwashburn merged commit cbb9f42 into main May 8, 2026
2 checks passed
@willwashburn willwashburn deleted the fix-cli-launch-spinners branch May 8, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant