Hot-trigger corveil skill install when picker value changes (CROW-490)#497
Conversation
Previously, picking a corveil binary in Settings persisted the path but did not re-run `corveil skill install` — the user had to quit and relaunch Crow before `/query-corveil` reflected the new binary. Now SettingsView fires a new `onCorveilReinstall` closure on the two commit sites (Browse confirm, TextField Enter), and AppDelegate dispatches just `Scaffolder.installCorveilSkill` off the main thread, routing the result through the existing `AppState.corveilSkillInstallWarning` banner. - Promote `Scaffolder.installCorveilSkill` from private to internal so AppDelegate can call it directly without re-running the full scaffold pass. - Add `onCorveilReinstall: ((String?) -> Void)?` to `SettingsView.init` and call it (with the new path, or `nil` when the field was cleared) alongside `save()` on Browse confirm and `onSubmit`. Verify stays `--version` only — no install side-effect there. - Wire the closure in `AppDelegate.showSettings` to run the install in `Task.detached` (bounded by the existing 5s `corveilInstallTimeout`) and write the result on the main actor. Always-assign behavior mirrors the adjacent `onRescaffold` flow so a cleared field clears any stale warning. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 14B927FC-A2CB-43A9-B5C4-318BE4578E2B
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Tight, well-scoped change that closes the "must restart to pick up a new corveil binary" gap. The inline documentation (AppDelegate.swift:1103-1119, Scaffolder.swift:231-234) is genuinely good — it explains the off-main-thread rationale, the always-assign banner semantics, and the devRoot capture. Reusing installCorveilSkill + the existing corveilSkillInstallWarning surface instead of re-running the whole Scaffolder.scaffold(...) pass is the right call.
Critical Issues
None.
Security Review
Strengths:
- No new attack surface. The corveil path is user-supplied via the same Settings field that already drove launch-time install;
installCorveilSkillre-validatesisExecutableFileand runs the binary with a fixed argv (skill install --path <target>) — no shell, no interpolation, no injection vector. - Subprocess is bounded by the existing 5s
corveilInstallTimeoutwatchdog (SIGTERM), stdout routed to/dev/null, stderr drained after exit — the deadlock-safety story is preserved on the new code path. - Moving the install off the main thread via
Task.detachedis correct and avoids freezing the Settings window for up to 5s.
Concerns:
- None security-specific.
Code Quality
Yellow — unsynchronized concurrent installs race on the output file and the banner (AppDelegate.swift:1120-1126)
Each commit (Browse confirm or Enter) fires onCorveilReinstall, which spawns a fresh Task.detached with no serialization, no cancellation of a prior in-flight install, and no staleness guard. Two commits within the install window (~hundreds of ms) produce two problems:
- Stale banner: completion order is not guaranteed. Browse to a broken binary (task A → warning), then immediately Browse/Enter to a good binary (task B →
nil); if A finishes after B, the banner shows a failure for the superseded path while the current path is actually fine. The warning no longer reliably reflects the committed path. - Concurrent writes to the same
query-corveil.md: twocorveil skill installprocesses writing the same--pathtarget at once can interleave/corrupt the file unless corveil writes atomically (which we can't assume).
The launch-time path is single-shot and serialized; this new path removes that guarantee. Likelihood is low (requires two commits within the install window), but the fix is small — e.g. a monotonically-increasing generation counter captured into the task, applying the result only if it's still the latest, and/or routing installs through a serial actor/queue so they don't overlap. Worth landing in this round trip.
Considerations (Green)
SettingsView.swift:297passes the rawurl.pathwhile the stored binding (corveilBinding, line 619) trims whitespace;installCorveilSkilltrims internally so this is harmless, but passingcorveilBinding.wrappedValuewould keep the two commit paths consistent.- The
onSubmitand Browse handlers duplicate the "commit → reinstall" logic; a tinycommitCorveil(_:)helper would DRY the two call sites and centralize thenil-on-empty rule.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Request Changes — driven by [0 Red, 1 Yellow, 2 Green] findings. The Yellow (unsynchronized concurrent installs racing on the banner and the output file) is a genuine should-fix; everything else is positive or optional.
…90 review) Addresses dhilgaertner's review on PR #497: - Yellow: two rapid picker commits could race on `query-corveil.md` (concurrent `corveil skill install` subprocesses) and on the `corveilSkillInstallWarning` banner (out-of-order completion). Adds `corveilInstallTail`/`enqueueCorveilInstall` mirroring the existing `reviewKickoffTail`/`enqueueReviewKickoff` serial-tail pattern (#266): each new commit chains a `Task { @mainactor }` that awaits its predecessor before running the install, so the last-committed path is also the last to write the banner. The blocking subprocess still runs via a nested `Task.detached`, bounded by `corveilInstallTimeout`. - Green: extract `commitCorveilPath()` in SettingsView so the Browse confirm and `onSubmit` handlers funnel through a single "persist → reinstall" path. Reads the picker via `corveilBinding.wrappedValue` rather than the raw `url.path`, so the binding's whitespace-trim normalization wins and the install closure sees the same string the next launch's scaffolder would see. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 14B927FC-A2CB-43A9-B5C4-318BE4578E2B
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Critical Issues
None.
Security Review
Strengths:
- No new attack surface. The picker only invokes a user-chosen local binary (
<path> skill install --path <devRoot>/.claude/commands/query-corveil.md) that the user must explicitly select viaNSOpenPanelor type — the same trust boundary as the pre-existing launch-time path. - Executable check is preserved:
installCorveilSkillstill gates onFileManager.isExecutableFileand surfaces the identical "missing or not executable" warning, so a non-executable pick can't be run. - No command-string interpolation —
Process.argumentsis an array, so the binary path can't inject extra args.stdoutis routed tonullDevice(deadlock-proof),stderris bounded and trimmed. - The 5s
corveilInstallTimeoutSIGTERM watchdog still bounds a hung binary, now off the main thread so it can't freeze the Settings window.
Concerns: None.
Code Quality
- Correct serialization:
enqueueCorveilInstallchains ontocorveilInstallTailexactly like the existingreviewKickoffTail(await predecessor → run → assign on main). Two rapid picks can't race onquery-corveil.mdor clobbercorveilSkillInstallWarningout of order, and the last-committed path is the last to write the banner. - Off-main execution is right: the blocking subprocess runs in a nested
Task.detachedover value types (Scaffolderstruct +StringdevRoot, noselfcapture), result assigned back on@MainActor.AppDelegateis@MainActor-isolated, so the closure/enqueueCorveilInstallisolation hops compile cleanly (consistent with the adjacentonRescaffoldclosure mutatingappStatedirectly). - "Always assign" banner semantics match the launch-time /
onRescaffoldpath: a successful ornil/empty path clears a stale warning; failures show the same diagnostic. Thenil-on-empty rule is funneled through the singlecommitCorveilPathsite for both Browse andonSubmit. - Whitespace normalization is handled —
commitCorveilPathreadscorveilBinding.wrappedValue(already trimmed by the binding's setter), so the install sees the same string the next launch's scaffolder would. installCorveilSkillvisibility was widened only fromprivatetointernal(notpublic) — the minimum needed for the same-moduleAppDelegatecaller. Good restraint.saveSettingsonly persists config (no scaffold/install), socommitCorveilPath'ssave()+onCorveilReinstallis a single install trigger — no accidental double-install.
Consider (non-blocking):
- The "Re-scaffold .claude/ directory" button (
onRescaffold) runs a full scaffold — including its owncorveil skill installwriting the samequery-corveil.mdandcorveilSkillInstallWarning— on a separate domain fromcorveilInstallTail. A user who commits a picker change (starting a detached install) and then clicks Re-scaffold within the 5s window could run twocorveil skill installsubprocesses against the same target concurrently. It's narrow (requires deliberate concurrent action), self-healing (the next install/launch rewrites the file), and carries no crash/security impact, so it's out of this PR's stated picker-vs-picker scope — but routingonRescaffold's corveil step through the same tail later would close it fully.
Notes
- Could not run
swift build/ tests in this review checkout: the vendoredFrameworks/GhosttyKit.xcframeworkhas no binary artifact here, so the app target won't link. This is an environment limitation, not a PR defect. Review is based on close reading; the change is small, type-safe Swift consistent with an existing in-repo pattern. The PR's test plan is manual (UI + concurrency glue that's impractical to unit-test).
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Approve — driven by [0 Red, 0 Yellow, 1 Green] findings.
After rebasing onto #497 (CROW-490 picker-change hot-trigger), CROW-491 had two parallel install hooks pointing at the same Scaffolder call: the new AppState.onReinstallCorveilSkill and #490's SettingsView.onCorveilReinstall. Drop the duplicate and route the Reinstall button through #490's existing infrastructure: - Widen `SettingsView.onCorveilReinstall` from `((String?) -> Void)?` → `((String?) async -> String?)?`. Picker commits fire-and-forget the return; the Reinstall button awaits it for inline ✓/✗ feedback. - Refactor `AppDelegate.enqueueCorveilInstall` to return the warning per call (each task reports its own result; serialization through `corveilInstallTail` is preserved). `corveilInstallTail` becomes `Task<String?, Never>?`. - Read `self.devRoot` live inside the `onCorveilReinstall` wiring closure — fixes the same stale-capture bug both #490 and the v1 CROW-491 implementation had (saveSettings mutates devRoot in the same Settings window). - Drop `AppState.onReinstallCorveilSkill` and its launch-time wiring. The Reinstall button now naturally serializes behind any in-flight picker-driven install, which the original AppState-callback path bypassed. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: F2C4CB9B-B99D-4C06-AD44-E04F5F5E11A4
Summary
corveil skill installthe moment a new path is committed (Browse confirm or Enter), so/query-corveilreflects the picked binary without an app restart.Scaffolder.installCorveilSkillfrom private to internal and added a newonCorveilReinstallclosure toSettingsViewso AppDelegate can dispatch just the install step (off the main thread, bounded by the existing 5scorveilInstallTimeout) instead of re-running the wholeScaffolder.scaffold(...)pass.AppState.corveilSkillInstallWarningbanner — successful or empty/cleared paths clear stale warnings; failures show the same diagnostic as the launch-time path. Verify stays--versiononly.Closes #490
Test plan
defaults.binaries.corveiland delete{devRoot}/.claude/commands/query-corveil.md. Open Settings → Browse to a working corveil binary. Without restarting Crow, confirmquery-corveil.mdis created.query-corveil.mdcontent updates to match<newPath> skill install --path /dev/stdout.query-corveil.mdstays in place; any prior banner clears.--versiononly: Click Verify with a stable path;query-corveil.mdmtime is unchanged.🤖 Generated with Claude Code