Skip to content

Agent-first crash/update session recovery - #6751

Closed
mvanhorn wants to merge 53 commits into
manaflow-ai:mainfrom
mvanhorn:feat/crash-session-resume
Closed

mvanhorn wants to merge 53 commits into
manaflow-ai:mainfrom
mvanhorn:feat/crash-session-resume

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Agent-first crash/update session recovery

Recovers agent windows after a crash or an "Update & reload all windows" relaunch, with an agent-first recovery model: when a restored window's session can be verified it resumes its own work and the agent is handed its specific transcript; when it can't be verified the agent gets an honest, cwd-scoped recovery prompt instead of a confident wrong guess.

This is opt-in (crashRecovery.injectResumeBreadcrumb, default off) and deliberately additive. It layers on top of the existing native auto-resume and does not modify the binding store or the resume-cwd logic that #6741 and #6631 are reshaping, so it composes with that work rather than overlapping it.

Why

Across several force-quit / "what was this window doing, can you resume?" tests, restored windows failed the same way: a window came up fresh (or in the wrong cwd), wore another session's name, and when asked to recover it grepped every transcript and adopted a plausible but wrong one. A confident wrong recovery is worse than an honest "I couldn't verify this window's session."

The core binding-cwd correctness is being fixed in #6741 (pin auto-resume to the launch cwd). This PR adds the recovery decision and re-entry layer on top of a now-correct binding.

What's in it

  • Unclean-shutdown sentinel + launch classification (crash vs intentional update relaunch vs clean restart).
  • Opt-in settings (offerResumeAfterCrash, injectResumeBreadcrumb, resumeAgentsAfterUpdate) in cmux.json.
  • A verification gate: a restored binding is trusted only when its transcript exists at the window's own cwd. A transcript found only under a different cwd is treated as a mismatch (not adopted); none on disk is treated as missing.
  • Agent-first routing: verified -> resume + a breadcrumb naming the verified transcript path; unverified -> an honest, cwd-scoped prompt that names no session and tells the agent to reconstruct only if confident, else ask. No cross-session picker.
  • Restored-window name fidelity: a user-set title is always kept; an auto summary is re-applied only to a verified window; otherwise a neutral name (an unverified window never wears another session's name).
  • "Update & reload all windows" preservation so the update relaunch restores windows.
  • A tag-bound live re-validation harness (scripts/crash-recovery-e2e.sh) that force-quits only the isolated tagged build (hard guard against the main app).

Scope and safety

Testing

  • ~90 unit tests across the crash-recovery surface (verification matrix, routing, breadcrumb/honest-prompt sanitization, name fidelity, on-disk transcript presence over a temp tree, binding contract). Build green.
  • Live force-quit/relaunch acceptance via the included harness is the remaining validation step.

Coordination note

Happy to scope this down, rebase onto #6741/#6631 once they land, or split the foundation from the recovery layer — whatever composes best with the binding work in flight.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Recovers agent windows after a crash or an update relaunch with an agent‑first, verification‑gated flow. Verified sessions auto‑resume with a transcript‑anchored breadcrumb; unverified sessions get a safe, cwd‑scoped prompt.

  • New Features

    • Launch classification (crash vs clean vs intentional update) with opt‑in cmux.json settings: offerResumeAfterCrash, injectResumeBreadcrumb, resumeAgentsAfterUpdate (default off). Windows restore after updates; optional silent agent auto‑resume.
    • Verification gate checks for a transcript at the window’s cwd. Verified resumes inject a privacy‑safe breadcrumb; others get an honest prompt. Restored names keep user titles and only re‑apply auto summaries when verified.
    • “Resume Where We Left Off” command in the View menu/command palette; localized crash/update prompts; update popover adds reassurance (“Your windows reopen after updating.”).
  • Bug Fixes

    • Intentional relaunches aren’t treated as crashes; restore is forced on update relaunch (disable flags still respected); crash offers now route through the recovery gate.
    • Require explicit live binding state before treating an agent as live to avoid false resume paths; no duplicate or accidental prompts; breadcrumbs queue only after native resume starts; recovery input waits up to 60s for terminal readiness; removed fixed relaunch delay; per‑bundle crash/relaunch markers.
    • Fidelity gating across all resume paths (incl. Codex); verification cache bound to the session; clean exit marked after lifecycle handoff; cleared binding state returned; preserved expected resume state across cold and live restores; bound Codex restore verification to the restore context; refresh Codex verification on demand; cover schema and archived Codex sessions.

Written for commit b9fcfc9. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a Resume action in the workspace menu to continue where you left off.
    • Enhanced crash/update recovery with “pick up where we left off” prompts, optional resume breadcrumbs, and silent re-entry after updates.
    • Update popover now includes an additional reassurance message (“Your windows reopen after updating.”).
    • Expanded crash-recovery localizations (including Khmer) and updated related prompt/button text.
  • Bug Fixes

    • Intentional relaunches (such as updates) no longer trigger crash-style recovery.
    • Resume/recovery behavior is more reliable and guarded to avoid incorrect restores; restored titles preserve user titles and only apply auto summaries when verified.
  • Chores

    • Improved crash/update recovery reliability through added resiliency checks and expanded automated test coverage.
  • Documentation

    • Updated recovery UI strings and localized message content.

mvanhorn added 24 commits June 24, 2026 10:48
…uilder

U1: UncleanShutdownSentinel detects whether the prior run exited cleanly via a
sentinel file under the cmux state dir (XDG-aware), fail-safe to 'clean'.
U3: ResumeBreadcrumbBuilder builds the sanitized, single-line 'pick up where we
left off' prompt anchored on the workspace name; supports Claude Code + Codex.
Both fully unit-tested (14 tests) and wired into the cmuxTests target.
…(U4 core)

U2: CrashRecoverySettings (offerResumeAfterCrash, injectResumeBreadcrumb,
resumeAgentsAfterUpdate), all default off, wired into the cmux.json terminal
section alongside autoResumeAgentSessions.
U4: WorkspaceResumePlanner is the pure, shared decide(resume-or-skip + breadcrumb)
core consumed by the offer (U5) and manual action (U6); full skip-reason matrix
(unsupported agent, no/empty session, unproven binding). SkipReason made Hashable.
26 tests across 4 crash-recovery suites pass.
…tegration, U8 core)

RelaunchIntent: single-use clean+restore-intended marker for intentional relaunches.
CrashRecoveryLaunchState: captures crash vs clean vs update-relaunch once at launch,
arming this run's sentinel after reading prior markers; gates the offer on crash + opt-in.
AppDelegate: capture at didFinishLaunching (skipped under XCTest), markCleanExit on
willTerminate, markIntentionalRelaunch in updaterWillRelaunchApplication (Sparkle update
=> restore-intent, never misread as a crash). 11 tests; 37 total crash-recovery tests pass.
…ver (U8 R11)

Adds a secondary line under 'Update Available' so clicking Install & Relaunch no
longer feels like it will lose your windows. Localized en + ja.
…re guarantee)

shouldAttemptRestore gains a restoreIntended override: a Sparkle/Rosetta relaunch
restores all windows even when launched with arguments; CMUX_DISABLE_SESSION_RESTORE
and automated-test guards still win. Wired at the startup-snapshot gate via the
captured launch state. 3 tests.
… (U4 delivery core)

WorkspaceResumeCoordinator maps a live ResumableWorkspaceSurface into the pure
planner decision, then runs native resume (when the agent isn't live) and delivers
the breadcrumb. Outcome reported (resumed/skipped) so callers can surface reasons.
Unit-tested with a fake surface (7 tests): live->breadcrumb only, dead->resume+breadcrumb,
inject gating, skip paths never touch the surface.
… (U4 delivery bridge)

Thin same-file glue mapping the focused terminal panel's resume binding into the
coordinator: agent kind/command/proven from surfaceResumeBinding, live-agent via the
focused surface, breadcrumb delivery via the private sendInputWhenReady. Exposes
resumeWhereWeLeftOff()/canResumeWhereWeLeftOff(). Compiles against the full app.
WorkspaceResumeCommands shared command (cmux-shared-behavior) on the dispatcher;
View-menu entry that resumes the selected workspace's agent + injects the breadcrumb,
disabled when not resumable. Localized en + ja. Compiles against the full app.
CrashRecoveryOfferPresenter shows a Chrome-style 'resume where you left off?' NSAlert
after the first restore when the prior run crashed AND the user opted in, then resumes
all resumable workspaces on accept. Pure offer-text builder (CrashRecoveryOfferText)
tested; gate keeps normal + update-relaunch launches silent. Localized en + ja.
Ties launch classification -> opt-in gate -> planner partition -> coordinator
delivery over a mixed fleet (live Claude, dead Codex, no-session, unsupported,
unproven): resumes the 2 eligible, skips the 3 with reasons, dead agent gets native
resume, breadcrumbs name-anchored. Plus clean-quit/no-offer and update-relaunch/
restore-but-no-offer paths. App-host E2E validated by launching the tagged build.
…(U8 resumeAgentsAfterUpdate)

Wires the resumeAgentsAfterUpdate opt-in: after an intentional relaunch, resume agents
with no prompt (windows always restore regardless). Distinct from the crash offer.
…ted bindings

Live force-quit test revealed the offer skipped a restored Claude workspace: a
cold-restored agent populates restoredAgentSnapshotsByPanelId (kind/sessionId/
resumeCommand) + restoredAgentResumeStatesByPanelId, NOT surfaceResumeBindingsByPanelId
which the conformance read. Now prefers the restored-agent snapshot (proven, synchronous
during restore -> no async race), derives live-vs-cold from the resume state, and runs
the restored agent's resumeStartupInput on cold resume.
…agent-first recovery

Re-centers crash recovery on the real defect found in live testing: no durable
window↔session binding (only 3/20 windows had a session ID captured by hooks),
so names mis-attribute and restored agents come up fresh. Agent-first recovery,
deliberately differentiated from session-search/hibernation tooling. U9–U14.
Pure ResumeFidelityGate over a ResumeBindingFacts value type: a restored
window↔session binding is verified only when a binding exists, the agent is
supported, a session id + constructable resume command are present, and the
transcript exists at *this window's own cwd*. Transcript-at-cwd doubles as the
cwd-match check for cwd-namespaced agents; a transcript found only elsewhere is
.cwdMismatch (anti-Example-3), none on disk is .transcriptMissing. 13 tests
cover the full verified/unverified matrix and ordered precedence.
…U12 R15)

Extend ResumeBreadcrumbBuilder with VerifiedResumeAnchor + breadcrumb(forVerified:):
when a verified binding carries a transcript path, the breadcrumb names that
exact file ('your prior transcript is at <path> - review that file') so the
restored agent reconstructs from the right source instead of grepping every
transcript (Examples 2/3). No path -> summary-only nudge. breadcrumbIfVerified
gates on the BindingVerdict so an unverified window produces no breadcrumb (R15).
sanitizedPath keeps internal spaces, strips control chars/quotes, expands tilde,
bounds length. 9 new tests; v1 builder tests still green.
RecoveryRouter turns binding facts into a binary, agent-first outcome: a verified
binding resumes its exact session + the transcript-anchored breadcrumb; an
unverified one yields ResumeBreadcrumbBuilder.honestRecoveryPrompt — an honest,
cwd-scoped prompt that names no session, tells the agent to reconstruct only if
confident (else ask), and forbids adopting another window's session. There is no
third 'pick from a list' branch (R17). wouldAutoResume gates the silent path so
it never auto-resumes an unverified binding (R14).

WorkspaceResumeCoordinator gains a verification-gated recover() path alongside
the v1 proven-binding resume()/canResume() (U5 offer / U6 manual untouched). The
ResumableWorkspaceSurface protocol gains additive, defaulted verification facts
so existing conformers keep compiling and an unwired surface conservatively
routes to honest recovery rather than a blind resume. 8 router + 5 coordinator
recover() tests; all v1 coordinator tests still green.
…U14 R18)

scripts/crash-recovery-e2e.sh scripts the safe force-quit -> relaunch cycle and
asserts the R18 bar (a restored window recovers its own work). It acts ONLY on a
tagged, bundle-isolated Debug build: refuses without --tag, refuses any tag that
resolves to the main app or a non-debug bundle, and forcequit kills only PIDs
whose exec path is under this tag's DerivedData Debug dir (never killall/pkill,
never the main app). Commands: build/launch/bindings/snapshot/forcequit/relaunch/
verify/guard-selftest. guard-selftest passes (refuses main + non-debug ids).
U14-acceptance.md documents the procedure + the empirical items the live loop
must pin (U9 hook coverage, --resume rehydration, U13 name revert, Workspace
verification adapter).
…sts (U13 R16, U9 R12)

U13: RestoredNameResolver encodes the name-fidelity rule (KTD14) as a pure
decision — a .user title is always kept; an .auto summary is re-applied ONLY when
the binding verified; otherwise the window shows a neutral name and never wears
another session's summary (anti-Example-1). Absent provenance decodes as .user
(matches the snapshot decoder). 7 tests. The empirical 'reverts to Claude Code on
restore' wiring is a U14 live-loop item.

U9: WindowSessionBindingTests pin R12's consumption contract at the coordinator
seam — a panel's facts carry its OWN session/cwd/kind, two concurrent panels
produce distinct non-crossed bindings, and a no-agent pane yields hasBinding=false
(-> honest recovery, not a guess). The hook-capture coverage defect (only some
panes recording a session) is pinned by the U14 live loop.
…guard, honest docs

- sanitizedPath now strips Unicode line/paragraph separators U+2028/U+2029
  (category Zl/Zp, NOT in controlCharacters) which some agents treat as a line
  break and would submit the injected prompt early. sanitizedName got this free
  via its whitespace split; the path-preserving variant needed it explicit. +test.
- crash-recovery-e2e.sh: replace the buggy 'awk /txt/{next}' exec-path filter
  (a no-op that also substring-dropped any tag containing 'txt', silently
  no-op'ing the crash sim) with sed -n 's/^n//p' + grep -F via shared
  resolve_exec_path/is_tagged_exec_path helpers; forcequit now does a REAL
  per-PID exec-path re-check before kill -9 (the comment had claimed a guard that
  didn't exist), defending against PID recycling. Guard self-test still passes.
- Make doc comments honest about pending live wiring: the gate referenced a
  nonexistent ResumeBindingFactsResolver; the router/coordinator implied recover()
  is already on the live restore path. It is consumed only via tests until the
  U14 step wires it + the real Workspace supplies on-disk facts.
- U14-acceptance.md: document the adapter must-dos (feed the bare session id, not
  the resume command, into bindingFacts; transcript-existence helpers are private
  in RestorableAgentSession).
…ai#6741 + manaflow-ai#6631

CEO confirmed 0.64.17 fixes auto-resume; PR manaflow-ai#6741 (open) pins the Claude
auto-resume binding to the launch cwd and adds shared
ClaudeResumeWorkingDirectory.verifiedWorkingDirectory + ClaudeProjectDirEncoding,
and manaflow-ai#6631 (open) owns the authoritative session-tracking store. Both reshape the
exact APIs the live wiring would consume.

Re-scope this branch as the agent-first RECOVERY DECISION LAYER on top: keep the
verify-trust gate (U10), agent-first honest recovery (U11), transcript-anchored
breadcrumb (U12), and restored-name fidelity (U13) — none built by either PR.
Drop the binding cwd-fix / hook-coverage scope (now owned by manaflow-ai#6741/manaflow-ai#6631). The
live adapter will consume manaflow-ai#6741's verifiedWorkingDirectory instead of a hand-rolled
FS check. Committed work has zero file overlap with manaflow-ai#6741, so it won't conflict;
wire live after both land. See PIVOT-complement-6741-6631.md.
… (U11 live)

Layers the verification-gated re-entry message on top of cmux's existing native
auto-resume (whose cwd PR manaflow-ai#6741 fixes), without touching the binding store manaflow-ai#6741/
manaflow-ai#6631 reshape.

- ClaudeTranscriptPresenceResolver: on-disk check splitting transcript-at-window-cwd
  (verified) from transcript-elsewhere-only (cwd mismatch, anti-Example-3) vs absent,
  across the Claude config roots. Reuses encodeClaudeProjectDir + ClaudeConfigDirectoryPath;
  self-contained pre-manaflow-ai#6741. 8 tests over a temp tree.
- Workspace.scheduleCrashRecoveryReentry: per-panel (facts from the panel's OWN
  snapshot, bare session id + cwd — no focused-panel coupling, no token-as-id),
  fired at restore for each cold-restored agent panel, gated on a real crash /
  intentional-update relaunch AND the opt-in injectResumeBreadcrumb setting (default
  false). Verified binding -> transcript-anchored breadcrumb; unverified -> honest
  cwd-scoped prompt. Skips hibernation restores.

Opt-in + conservative under-detection (a missed transcript falls to honest recovery,
never a wrong resume), so default users are unaffected. 62 v2 tests green; build green.
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

@mvanhorn is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Crash-recovery launch classification, restore-intent markers, transcript verification, recovery routing, workspace re-entry wiring, project integration, and update popover reassurance text are added.

Changes

Crash recovery, workspace resume, and update reassurance

Layer / File(s) Summary
Launch intent and shutdown markers
Sources/CrashRecovery/*, Sources/AppDelegate.swift, Sources/SessionPersistence.swift, Resources/Localizable.xcstrings, cmuxTests/CrashRecovery*Tests.swift, cmuxTests/RelaunchIntentTests.swift, cmuxTests/UncleanShutdownSentinelTests.swift
Launch state capture, restore-intent markers, shutdown sentinels, crash-recovery settings, JSON-path wiring, and restore gating are added with tests and project wiring.
Transcript and fidelity models
Sources/CrashRecovery/ClaudeTranscriptPresence.swift, Sources/CrashRecovery/RestoredNameResolver.swift, Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift, Sources/CrashRecovery/ResumeFidelityGate.swift, cmuxTests/ClaudeTranscriptPresenceTests.swift, cmuxTests/RestoredWorkspaceNameFidelityTests.swift, cmuxTests/ResumeBreadcrumb*Tests.swift, cmuxTests/ResumeFidelityGateTests.swift, Resources/Localizable.xcstrings
Transcript lookup, binding verification, breadcrumb text building, and restored-name resolution are added with tests for transcript location, sanitization, and title provenance.
Recovery routing and crash-offer presentation
Sources/CrashRecovery/RecoveryRouter.swift, Sources/CrashRecovery/CrashRecoveryOffer.swift, Sources/AppDelegate.swift, cmuxTests/RecoveryRouterTests.swift, cmuxTests/CrashRecoveryOfferTests.swift, cmuxTests/CrashRecoveryFlowTests.swift
Verified resume and honest recovery are separated into distinct actions, the crash-offer path localizes the alert copy, and eligible workspaces are resumed after startup or intentional relaunch.
Workspace resume planning and coordinator wiring
Sources/CrashRecovery/WorkspaceResumePlanner.swift, Sources/CrashRecovery/WorkspaceResumeCoordinator.swift, Sources/Workspace.swift, Sources/Workspace+CrashRecovery.swift, Sources/Workspace+RestoredNameFidelity.swift, Sources/WorkspaceActionDispatcher.swift, Sources/cmuxApp.swift, Sources/CmuxSettingsJSONPathSupport.swift, Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Core/Values/WorkspacePendingTerminalInputReason.swift, Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Core/WorkspaceCoreValueTests.swift, cmuxTests/WindowSessionBindingTests.swift, cmuxTests/WorkspaceResumePlannerTests.swift, cmuxTests/WorkspaceResumeCoordinatorTests.swift, cmuxTests/WorkspaceTitleProvenanceTests.swift
Workspace surfaces now drive native resume, breadcrumb delivery, and re-entry, with selected-workspace commands, menu wiring, settings paths, terminal-input timeout, and title provenance restoration added around the flow.
Project wiring and e2e harness
cmux.xcodeproj/project.pbxproj, scripts/crash-recovery-e2e.sh
The new crash-recovery and resume sources/tests are added to the Xcode targets, and the shell harness adds tagged build, launch, snapshot, force-quit, relaunch, and verification commands.
Update popover reassurance
Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift, Resources/Localizable.xcstrings
The background update popover shows a new secondary reassurance line above the action buttons.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90 minutes

Possibly related PRs

  • manaflow-ai/cmux#3744: Both PRs modify Workspace+PanelLifecycle.swift’s shared discardClosedPanelLifecycleState(...) panel-teardown cleanup, so the changes are directly related at that teardown seam.
  • manaflow-ai/cmux#4237: Both PRs modify Workspace+PanelLifecycle.swift’s discardClosedPanelLifecycleState(...) to purge per-panel resume-related state.
  • manaflow-ai/cmux#4777: Both PRs touch the restored-agent startup path in Sources/Workspace.swift, including the resume-launch abstraction used for agent re-entry.

Suggested reviewers

  • lawrencecchen
  • austinywang

Poem

I thumped through launches, dark and spry,
then found the crumbs to guide the sky.
The windows woke, the updates gleamed,
and rabbit ears applauded dreams.
🐰 Hop! The sessions came back home.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error Production crash-recovery input now uses sendInputWhenReady with .recoveryInput, which adds a 60s DispatchQueue.main.asyncAfter fallback wait. Replace the 60s fallback with the existing terminal-ready notification/state transition (or make the delay test-only), and avoid delayed dispatch in shipped code.
Cmux Swift File And Package Boundaries ❌ Error FAIL: new 640-line Sources/Workspace+CrashRecovery.swift mixes verification, resume I/O, and async scheduling in app target; crash-recovery domain logic wasn’t extracted to a package. Extract the crash-recovery domain types/logic into a small SwiftPM package (e.g. CmuxCrashRecovery) and keep only Workspace/AppKit glue in the app target.
Cmux No Ambient Global State ❌ Error Sources/WorkspaceActionDispatcher.swift:175 adds WorkspaceResumeCommands, a caseless static-only namespace, which the rule forbids as ambient global API. Move the resume menu behavior onto an injected TabManager/dispatcher instance (or a Workspace method) and keep only file-private helpers at scope.
Docstring Coverage ⚠️ Warning Docstring coverage is 18.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (21 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately captures the main change: agent-first crash/update session recovery.
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.
Cmux Swift Actor Isolation ✅ Passed PASS: New value models are explicitly nonisolated, UI-facing coordinators/protocols are @MainActor, and detached work uses Sendable snapshots before hopping back to MainActor.
Cmux Browser Automation Off-Main ✅ Passed No browser socket automation changes are introduced in the PR; existing wait/callback browser commands are already on the socket worker with policy tests.
Cmux Expensive Synchronous Load ✅ Passed Expensive transcript scans only run in Task.detached/background verification; menu and restore actions call cached/async wrappers, not sync main-actor loads.
Cmux Cache Substitution Correctness ✅ Passed Cache use is fingerprint-checked, refreshed from disk in a detached task, and cold paths fall back conservatively or rescan.
Cmux No Hacky Sleeps ✅ Passed The new shell harness uses kqueue-based event waiting with a deadline and adds no fixed sleeps or polling delays in changed non-Swift runtime code.
Cmux Algorithmic Complexity ✅ Passed New production paths are linear/bounded: workspace batching uses single passes with Set dedupe, restore refresh is one pass per collection, and the Codex search has a fixed 370-day cap.
Cmux Swift Concurrency ✅ Passed New async work uses stored/cancelled Tasks or awaited Task.detached; no new Combine/completion-handler APIs or background DispatchQueue patterns in touched Swift files.
Cmux Swift @Concurrent ✅ Passed New async entry points are @MainActor UI orchestration; heavy verification is explicitly hopped to Task.detached, and no nonisolated async helper lacks @concurrent.
Cmux Swiftpm Lockfiles ✅ Passed Updater package version bumps include matching local Package.resolved diffs; Xcode Sparkle bump includes root Package.resolved; no cmux-owned .gitignore ignores Package.resolved.
Cmux Swift Logging ✅ Passed PASS: The modified runtime Swift files add no new print/debugPrint/dump/NSLog or Logger usage; new crash-recovery sources are logging-free.
Cmux User-Facing Error Privacy ✅ Passed Added alerts/recovery copy is generic and sanitized; no vendor names, session IDs, or raw upstream details appear in user-visible strings.
Cmux Full Internationalization ✅ Passed PASS: New Swift UI copy uses localized APIs, and the added catalog keys have translations for every locale supported by Resources/Localizable.xcstrings.
Cmux Swiftui State Layout ✅ Passed The only SwiftUI edit is a new reassurance Text in UpdatePopoverView, and the menu addition in cmuxApp uses an action button; no new state, GeometryReader, or render-time mutation.
Cmux Architecture Rethink ✅ Passed No prohibited sleeps, polling, locks, or duplicate lifecycle ownership were introduced; recovery state is owned explicitly by launch-state, coordinator, and workspace invariants.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The PR only adds a popover message and menu actions; no new or materially changed standalone NSWindow/NSPanel/Window/WindowGroup code was introduced without cmux.* registry ownership.
Cmux Source Artifacts ✅ Passed Changed paths are source, tests, config, localization, and one script; no logs, temp dirs, build output, downloads, or cache artifacts are present.
Cmux No Test Or Debug Seam In Production Source ✅ Passed Production callers use the widened accessors, and the touched Sources add production crash-recovery logic rather than test/debug-only seams.
Description check ✅ Passed The PR description covers summary, rationale, and testing, but it does not fully follow the template and omits the checklist and explicit demo-video section.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds agent-first crash/update session recovery to cmux: an unclean-shutdown sentinel classifies each launch as crash vs. intentional update vs. clean restart; restored windows run a verification gate before trusting their saved binding; verified sessions auto-resume with a transcript-anchored breadcrumb and unverified ones get an honest, cwd-scoped prompt instead of a confident wrong guess. The feature is opt-in (all three crashRecovery.* settings default to off) and is deliberately additive on top of the existing native auto-resume.

  • New Sources/CrashRecovery/ module adds the sentinel, relaunch intent marker, fidelity gate, recovery router, breadcrumb/honest-prompt builders, name-fidelity resolver, and resume coordinator — all pure value types with no app coupling, backed by ~90 unit tests.
  • Workspace+CrashRecovery.swift bridges the live workspace into the coordinator; off-main transcript scans (Task.detached) hop back to @MainActor only for input delivery, and the scheduleRestoredAgentVerificationRefresh background task refreshes verification and re-applies auto-titles after restore.
  • AppDelegate.swift adds a constructable CrashRecoveryLaunchState instance (not a singleton), wires captureAtLaunch idempotently, calls markIntentionalRelaunch only after the Sparkle update handoff succeeds, and gates the crash-offer task behind the XCTest guard.

Confidence Score: 4/5

Safe to merge with one targeted fix: CodexTranscriptPresenceResolver.resolve always returns .absent when called with searchElsewhere: false, so Codex workspace auto-titles are never restored by the background refresh task and a race can overwrite a correctly verified Codex result.

The crash/update recovery logic is well-structured and all previously flagged issues have been addressed. One remaining defect: CodexTranscriptPresenceResolver.resolve short-circuits to .absent when searchElsewhere: false (lines 224–226), unlike the Claude resolver which always checks at the window's own cwd first and uses the flag only to skip the broader elsewhere scan. Every call from scheduleRestoredAgentVerificationRefresh uses searchElsewhere: false, so Codex sessions always produce a false-absent result in that path, which both prevents auto-title restoration for valid Codex sessions and can overwrite a correctly verified result if the refresh task completes after scheduleCrashRecoveryReentry.

Sources/CrashRecovery/ClaudeTranscriptPresence.swift — the CodexTranscriptPresenceResolver.resolve guard block at lines 224–226 needs to be removed so the at-cwd check runs regardless of searchElsewhere.

Important Files Changed

Filename Overview
Sources/CrashRecovery/ClaudeTranscriptPresence.swift Adds Claude and Codex transcript presence resolvers. The Claude resolver correctly isolates the at-cwd check from the searchElsewhere scan; the Codex resolver incorrectly short-circuits to .absent when searchElsewhere: false, causing verification failures in the background refresh path.
Sources/CrashRecovery/CrashRecoveryLaunchState.swift New constructable class (no singleton) that captures crash vs. intentional-relaunch classification once per launch; correctly reads prior-run markers before arming the new sentinel.
Sources/CrashRecovery/ResumeFidelityGate.swift Pure value-type verification gate with no side effects; the five-step ordered check (binding → agent support → session id → resume command → transcript at window cwd) is correct and exhaustively unit-tested.
Sources/CrashRecovery/WorkspaceResumeCoordinator.swift Correctly separates planner (proven-binding path) from router (verification-gated path); protocol defaults are conservative (honest recovery), and the @MainActor gating and task boundaries are sound.
Sources/Workspace+CrashRecovery.swift Bridges Workspace into the crash-recovery surface; scheduleCrashRecoveryReentry is correctly off-main; Codex panels in scheduleRestoredAgentVerificationRefresh always produce an incorrect .absent result due to the CodexTranscriptPresenceResolver bug.
Sources/Workspace.swift Adds restore-path hooks for crash recovery gating and name fidelity; setSurfaceResumeBinding clears restoredAgentVerificationByPanelId but does not cancel an in-flight crashRecoveryReentryTask (covered by a previous review comment).
Sources/CrashRecovery/CrashRecoveryOffer.swift Thin AppKit glue for the crash-recovery offer; mutual exclusion with silent reentry path is handled by shouldDeliverSilentReentry; offer partitioning logic is straightforward.
Sources/AppDelegate.swift Adds crashRecoveryLaunchState as an instance property (not a singleton); captureAtLaunch is correctly idempotent; markIntentionalRelaunch is called only after the update handoff succeeds.
scripts/crash-recovery-e2e.sh End-to-end harness with kqueue-based process-exit synchronization (no sleep) and a hard guard against the main app bundle; tag-scoped force-quit is safe.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[App Launch] --> B[captureAtLaunch]
    B --> C{priorRunCrashed?}
    C -->|No sentinel| D[Clean launch — no recovery]
    C -->|restoreWasIntended| E[Update relaunch]
    C -->|Unclean, not intended| F[Crash detected]

    E --> G{resumeAgentsAfterUpdate?}
    G -->|off| D
    G -->|on| H[resumeAfterIntentionalRelaunchIfNeeded]

    F --> I{offerResumeAfterCrash?}
    I -->|off + injectResumeBreadcrumb| J[shouldDeliverSilentReentry]
    I -->|on| K[presentOfferIfNeeded alert]
    K -->|user accepts| L[WorkspaceResumeCoordinator.recover]
    J --> L

    H --> N[verifiedResumePlans]
    N --> O{ResumeFidelityGate.verify}
    L --> M[prepareCrashRecoveryRecoveryVerification\nTask.detached disk scan]
    M --> O
    O -->|verified| P[native resume + breadcrumb]
    O -->|unverified| Q[honest cwd-scoped prompt\nno auto-resume]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[App Launch] --> B[captureAtLaunch]
    B --> C{priorRunCrashed?}
    C -->|No sentinel| D[Clean launch — no recovery]
    C -->|restoreWasIntended| E[Update relaunch]
    C -->|Unclean, not intended| F[Crash detected]

    E --> G{resumeAgentsAfterUpdate?}
    G -->|off| D
    G -->|on| H[resumeAfterIntentionalRelaunchIfNeeded]

    F --> I{offerResumeAfterCrash?}
    I -->|off + injectResumeBreadcrumb| J[shouldDeliverSilentReentry]
    I -->|on| K[presentOfferIfNeeded alert]
    K -->|user accepts| L[WorkspaceResumeCoordinator.recover]
    J --> L

    H --> N[verifiedResumePlans]
    N --> O{ResumeFidelityGate.verify}
    L --> M[prepareCrashRecoveryRecoveryVerification\nTask.detached disk scan]
    M --> O
    O -->|verified| P[native resume + breadcrumb]
    O -->|unverified| Q[honest cwd-scoped prompt\nno auto-resume]
Loading

Reviews (12): Last reviewed commit: "fix(crash-recovery): cover schema and ar..." | Re-trigger Greptile

Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/CrashRecovery/CrashRecoveryLaunchState.swift Outdated
Comment thread scripts/crash-recovery-e2e.sh Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🤖 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 `@cmuxTests/ClaudeTranscriptPresenceTests.swift`:
- Around line 21-24: The temp-directory helper in ClaudeTranscriptPresenceTests
uses abs(hashValue), which can trap on Int.min and fail the suite
nondeterministically. Update the unique directory name generation in the
affected helper to use a guaranteed-safe unique value such as UUID().uuidString
or ProcessInfo.processInfo.globallyUniqueString, and keep the cleanup logic
around dir unchanged.

In `@scripts/crash-recovery-e2e.sh`:
- Around line 250-253: The fixed delay in cmd_relaunch is timing-dependent and
should be replaced with an owner/readiness check before calling cmd_launch.
Update the cmd_relaunch flow to wait for the prior instance/socket cleanup
condition to be satisfied after cmd_forcequit, using the existing harness
ownership checks or a deterministic readiness probe instead of sleep. Keep the
change localized to cmd_relaunch in the crash-recovery e2e shell script and
remove the wall-clock synchronization entirely.

In `@Sources/CmuxSettingsJSONPathSupport.swift`:
- Around line 172-186: The new crash-recovery JSON keys are added in
booleanSettings but not included in supportedSettingsJSONPaths, so the validator
still won’t recognize them. Update the supported settings path list in
CmuxSettingsJSONPathSupport to add terminal.offerResumeAfterCrash,
terminal.injectResumeBreadcrumb, and terminal.resumeAgentsAfterUpdate using the
same JSON path mapping pattern as the existing crash-recovery entries.

In `@Sources/CrashRecovery/CrashRecoveryLaunchState.swift`:
- Around line 88-92: Remove the test-only resetForTesting seam from
CrashRecoveryLaunchState and keep the production type free of …ForTesting APIs.
Delete the resetForTesting() method from CrashRecoveryLaunchState, and rely on
fresh CrashRecoveryLaunchState instances in the tests via `@testable` import
instead of exposing a production reset hook.

In `@Sources/CrashRecovery/CrashRecoveryOffer.swift`:
- Around line 20-26: Use ICU pluralization for the crash recovery offer text
instead of the hardcoded workspace(s) suffix. Update the localization entry
referenced by CrashRecoveryOffer’s message key crashRecovery.offer.message to
provide one/other variants in the string catalog, and change the
CrashRecoveryOffer message formatting to use the localized pluralized string
with resumableCount rather than building a single %lld workspace(s)? template.
Ensure the new wording is driven entirely by localization so it pluralizes
correctly across locales.

In `@Sources/CrashRecovery/RelaunchIntent.swift`:
- Around line 54-64: The consumeRestoreIntent flow currently trusts any existing
path and ignores deletion failures, so make it fail closed by verifying the
marker is a real file before treating it as intent and only returning true after
successfully removing it. Update RelaunchIntent.consumeRestoreIntent to inspect
the URL from markerURL with a reliable file-attribute check, treat directories
or unexpected paths as invalid, and if removeItem(at:) fails return false so
stale markers do not keep suppressing crash recovery.

In `@Sources/CrashRecovery/RestoredNameResolver.swift`:
- Around line 40-47: In RestoredNameResolver’s restore/title handling, stop
defaulting a missing provenance source to .user because source == nil must fail
closed instead of preserving an unverified legacy title. Update the logic around
the effectiveSource check so the “keep user title” path only applies when
provenance is explicitly stamped as .user, and treat nil source as .neutral
unless a prior migration has set a real source.

In `@Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift`:
- Around line 57-65: Localize all user-facing recovery copy in
ResumeBreadcrumbBuilder instead of leaving hard-coded English text. Update
breadcrumb(workspaceName:agent) and the other recovery prompt builders
referenced in this change set to use String(localized:defaultValue:) with stable
localization keys, and add matching Resources/Localizable.xcstrings entries for
every supported locale. Keep the existing behavior and interpolation, but move
every visible prompt string through localization so the crash-recovery flow
respects the app locale.

In `@Sources/CrashRecovery/ResumeFidelityGate.swift`:
- Around line 15-157: Mark the pure value types in ResumeFidelityGate.swift as
explicitly nonisolated so they do not inherit incidental `@MainActor` isolation in
Swift 6. Update ResumeBindingFacts, BindingVerdict, UnverifiedReason, and
ResumeFidelityGate themselves, and ensure the methods isSupported(_:),
verify(_:), and isVerified(_:) remain usable as cross-concurrency sendable
helpers without actor isolation.

In `@Sources/CrashRecovery/WorkspaceResumeCoordinator.swift`:
- Around line 141-153: The bindingFacts(for:) method is passing the raw
resumeSessionToken straight into ResumeBindingFacts.sessionId, which later
reaches ClaudeTranscriptPresenceResolver.resolve and breaks transcript lookup.
Update bindingFacts(for:) to extract a bare filename-safe session ID from the
token before assigning sessionId, while keeping hasToken detection based on the
full token presence. Use the existing symbols
ResumableWorkspaceSurface.resumeSessionToken, ResumeBindingFacts.sessionId, and
ClaudeTranscriptPresenceResolver.resolve to locate and align the fix.

In `@Sources/Workspace.swift`:
- Around line 12972-12988: The restore path in Workspace.swift is doing an
expensive synchronous transcript lookup by calling
ClaudeTranscriptPresenceResolver.resolve(...) for every restored agent panel,
which can block startup on large histories. Move this presence check off the hot
restore path by caching the result, precomputing it once per launch, or
resolving it asynchronously before restoration, and then reuse that value in the
agent.kind == .claude branch instead of rescanning projects/ repeatedly.
- Around line 1426-1430: Gate the crash-reentry injection in Workspace’s restore
flow on an actual agent resume, not just on `restoredHibernation == nil`. Update
the `scheduleCrashRecoveryReentry(panel:agent:)` call so it only runs when the
panel is on the true startup/resume path already identified by
`restoredAgentWillRunStartupCommand || restoredAgentWillRunStartupInput`,
preventing the breadcrumb/prompt from being injected into a plain restored
shell.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7eda42d2-ad94-4fdf-a2fc-7dce36ca4778

📥 Commits

Reviewing files that changed from the base of the PR and between da4e4bc and efe7bda.

📒 Files selected for processing (38)
  • Packages/macOS/CmuxUpdaterUI/Sources/CmuxUpdaterUI/UpdatePopoverView.swift
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CmuxSettingsJSONPathSupport.swift
  • Sources/CrashRecovery/ClaudeTranscriptPresence.swift
  • Sources/CrashRecovery/CrashRecoveryLaunchState.swift
  • Sources/CrashRecovery/CrashRecoveryOffer.swift
  • Sources/CrashRecovery/CrashRecoverySettings.swift
  • Sources/CrashRecovery/RecoveryRouter.swift
  • Sources/CrashRecovery/RelaunchIntent.swift
  • Sources/CrashRecovery/RestoredNameResolver.swift
  • Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift
  • Sources/CrashRecovery/ResumeFidelityGate.swift
  • Sources/CrashRecovery/UncleanShutdownSentinel.swift
  • Sources/CrashRecovery/WorkspaceResumeCoordinator.swift
  • Sources/CrashRecovery/WorkspaceResumePlanner.swift
  • Sources/SessionPersistence.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceActionDispatcher.swift
  • Sources/cmuxApp.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/ClaudeTranscriptPresenceTests.swift
  • cmuxTests/CrashRecoveryFlowTests.swift
  • cmuxTests/CrashRecoveryLaunchStateTests.swift
  • cmuxTests/CrashRecoveryOfferTests.swift
  • cmuxTests/CrashRecoveryRestoreGateTests.swift
  • cmuxTests/CrashRecoverySettingsTests.swift
  • cmuxTests/RecoveryRouterTests.swift
  • cmuxTests/RelaunchIntentTests.swift
  • cmuxTests/RestoredWorkspaceNameFidelityTests.swift
  • cmuxTests/ResumeBreadcrumbAnchorTests.swift
  • cmuxTests/ResumeBreadcrumbBuilderTests.swift
  • cmuxTests/ResumeFidelityGateTests.swift
  • cmuxTests/UncleanShutdownSentinelTests.swift
  • cmuxTests/WindowSessionBindingTests.swift
  • cmuxTests/WorkspaceResumeCoordinatorTests.swift
  • cmuxTests/WorkspaceResumePlannerTests.swift
  • scripts/crash-recovery-e2e.sh

Comment thread cmuxTests/ClaudeTranscriptPresenceTests.swift Outdated
Comment thread scripts/crash-recovery-e2e.sh
Comment thread Sources/CmuxSettingsJSONPathSupport.swift
Comment thread Sources/CrashRecovery/CrashRecoveryLaunchState.swift Outdated
Comment thread Sources/CrashRecovery/CrashRecoveryOffer.swift Outdated
Comment thread Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift Outdated
Comment thread Sources/CrashRecovery/ResumeFidelityGate.swift Outdated
Comment thread Sources/CrashRecovery/WorkspaceResumeCoordinator.swift
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (3)
Sources/Workspace+RestoredNameFidelity.swift (1)

104-112: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don’t mark unverified auto titles as user-owned.

For Claude/Codex, initial restore often has no cached filesystem verification, so .auto titles resolve to .neutral. These branches then set the source to .user, which blocks the async verification refresh from later applying the verified auto title (scheduleRestoredAgentVerificationRefresh checks the source is not .user). Preserve the original auto/unverified provenance instead of converting it to a user title.

Also applies to: 127-135

🤖 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 `@Sources/Workspace`+RestoredNameFidelity.swift around lines 104 - 112,
`applyRestoredWorkspaceName(_:)` is converting unverified auto-restored titles
into user-owned titles by calling `setCustomTitle(..., source: .user)` for both
`.keepUserTitle` and `.neutral`. Update this flow to preserve the original
provenance from `RestoredName` in `Workspace+RestoredNameFidelity` so
auto/unverified titles remain non-user sourced and can still be refreshed later
by `scheduleRestoredAgentVerificationRefresh`; keep verified auto titles as
`.auto`, and only use `.user` for actual user-authored titles.
Sources/Workspace.swift (1)

1224-1288: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Don’t block update relaunch auto-resume on the no-scan verifier.

On intended update relaunches, shouldGateAgentStartupForCrashRecovery becomes true when update auto-resume is enabled, but crashRecoveryVerificationWithoutFilesystemScan returns nil for Claude/Codex. That makes restorableAgentStartupAllowed false and there is no later silent re-entry repair because shouldDeliverSilentReentry returns false for intended restores. Default Claude/Codex update relaunches can therefore restore shells without resuming agents.

Also applies to: 1470-1478

🤖 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 `@Sources/Workspace.swift` around lines 1224 - 1288, The update relaunch
auto-resume path is being blocked by the crash-recovery no-scan verifier for
Claude/Codex, so restore intent is lost in Workspace’s startup flow. Adjust the
restore gating in the Workspace logic around
shouldGateAgentStartupForCrashRecovery, restorableAgentStartupVerification, and
restorableAgentStartupAllowed so intended update relaunches can still resume
agents even when crashRecoveryVerificationWithoutFilesystemScan returns nil.
Make the same fix in the matching restore path referenced by the later apply
location so both startup branches behave consistently.
Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift (1)

132-168: 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

These prompts are not shell-safe, despite being injected as terminal input.

The new sanitizers strip quotes/newlines, but they still preserve shell-active characters like $, backticks, ;, |, &, and redirects. The supplied crash-recovery path later injects this text with sendInputWhenReady(text + "\n", ...), and in the unverified path there may be no live agent yet, so a shell interprets the line first. A cwd or custom title containing $(...) or backticks will execute before the shell errors on the English sentence. Either stop sending recovery prose through a shell path, or neutralize shell metacharacters here before treating the result as safe terminal startup input.

Also applies to: 183-232

🤖 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 `@Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift` around lines 132 - 168,
The crash recovery prompt built by honestRecoveryPrompt is still unsafe as
terminal input because sanitizedName and sanitizedPath do not neutralize shell
metacharacters. Update honestRecoveryPrompt to either avoid routing this prose
through sendInputWhenReady or further escape/neutralize shell-active characters
before returning the string. Keep the fix localized to ResumeBreadcrumbBuilder
and its prompt-building helpers so the unverified recovery path cannot execute
injected cwd or title content.
🤖 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 `@Sources/CrashRecovery/CrashRecoverySettings.swift`:
- Around line 56-69: `shouldGateRestoredAgentStartup(...)` is gating startup too
early for the crash/update recovery flow, which prevents the fallback prompt
from reaching an agent. Update the crash-recovery path so
`Workspace.createPanel(...)` does not suppress agent startup before
`.honestRecovery` is delivered, or ensure
`Workspace+CrashRecovery.scheduleCrashRecoveryReentry(...)` starts a fresh agent
before calling `sendInputWhenReady(...)`. Keep the gate logic in
`CrashRecoverySettings.shouldGateRestoredAgentStartup` aligned with the
agent-first recovery contract.

In `@Sources/CrashRecovery/WorkspaceResumeCoordinator.swift`:
- Around line 154-162: The verified recovery resume delivery in
performVerifiedResumeDelivery currently relies only on surface.isAgentLive,
which can schedule a duplicate native resume during silent restore. Thread the
existing nativeResumeAlreadyScheduled state from the recovery path into
performVerifiedRecovery/performVerifiedResumeDelivery, or split the logic so it
only sends the breadcrumb when native resume was already scheduled, using the
ResumableWorkspaceSurface and recovery entry path to locate the change.

In `@Sources/Panels/BrowserNavigationDelegate.swift`:
- Line 42: Replace the remaining NSLog-based browser navigation callbacks in
BrowserNavigationDelegate with the repo’s unified Logger usage; update the
relevant delegate methods that log failures and URL-related events so they emit
through Logger instead of system log, and redact any URL-bearing fields or
sensitive error text before logging. Use the existing BrowserNavigationDelegate
callback methods as the fix points, and remove all direct NSLog calls in those
paths.

In `@Sources/Workspace`+CrashRecovery.swift:
- Around line 359-376: The crash recovery verification cache key is missing
CODEX_HOME, so cached results can be reused across different Codex homes. Update
both key निर्माणs in Workspace.crashRecoveryVerification paths for the agent and
binding jobs to include the CODEX_HOME value from the relevant environment
alongside CLAUDE_CONFIG_DIR, using the existing key-building logic in
Workspace+CrashRecovery and the cachedVerification call site so transcript
verification stays scoped to the correct home.

---

Outside diff comments:
In `@Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift`:
- Around line 132-168: The crash recovery prompt built by honestRecoveryPrompt
is still unsafe as terminal input because sanitizedName and sanitizedPath do not
neutralize shell metacharacters. Update honestRecoveryPrompt to either avoid
routing this prose through sendInputWhenReady or further escape/neutralize
shell-active characters before returning the string. Keep the fix localized to
ResumeBreadcrumbBuilder and its prompt-building helpers so the unverified
recovery path cannot execute injected cwd or title content.

In `@Sources/Workspace.swift`:
- Around line 1224-1288: The update relaunch auto-resume path is being blocked
by the crash-recovery no-scan verifier for Claude/Codex, so restore intent is
lost in Workspace’s startup flow. Adjust the restore gating in the Workspace
logic around shouldGateAgentStartupForCrashRecovery,
restorableAgentStartupVerification, and restorableAgentStartupAllowed so
intended update relaunches can still resume agents even when
crashRecoveryVerificationWithoutFilesystemScan returns nil. Make the same fix in
the matching restore path referenced by the later apply location so both startup
branches behave consistently.

In `@Sources/Workspace`+RestoredNameFidelity.swift:
- Around line 104-112: `applyRestoredWorkspaceName(_:)` is converting unverified
auto-restored titles into user-owned titles by calling `setCustomTitle(...,
source: .user)` for both `.keepUserTitle` and `.neutral`. Update this flow to
preserve the original provenance from `RestoredName` in
`Workspace+RestoredNameFidelity` so auto/unverified titles remain non-user
sourced and can still be refreshed later by
`scheduleRestoredAgentVerificationRefresh`; keep verified auto titles as
`.auto`, and only use `.user` for actual user-authored titles.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 48ad1072-1055-41c6-a2f3-0a86e58c4980

📥 Commits

Reviewing files that changed from the base of the PR and between 0f6f1d1 and 2906f44.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (18)
  • Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Core/Values/WorkspacePendingTerminalInputReason.swift
  • Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Core/WorkspaceCoreValueTests.swift
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CrashRecovery/ClaudeTranscriptPresence.swift
  • Sources/CrashRecovery/CrashRecoverySettings.swift
  • Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift
  • Sources/CrashRecovery/ResumeFidelityGate.swift
  • Sources/CrashRecovery/UncleanShutdownSentinel.swift
  • Sources/CrashRecovery/WorkspaceResumeCoordinator.swift
  • Sources/Panels/BrowserNavigationDelegate.swift
  • Sources/Workspace+CrashRecovery.swift
  • Sources/Workspace+PanelLifecycle.swift
  • Sources/Workspace+RestoredNameFidelity.swift
  • Sources/Workspace.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/ClaudeTranscriptPresenceTests.swift
  • cmuxTests/CrashRecoverySettingsTests.swift
💤 Files with no reviewable changes (1)
  • Resources/Localizable.xcstrings
🛑 Comments failed to post (4)
Sources/CrashRecovery/CrashRecoverySettings.swift (1)

56-69: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Gated restores can suppress the very agent that should receive the fallback prompt.

shouldGateRestoredAgentStartup(...) now returns true for the crash/update recovery paths, but the supplied downstream flow shows that Workspace.createPanel(...) suppresses Claude/Codex startup when this gate is on, while Workspace+CrashRecovery.scheduleCrashRecoveryReentry(...) delivers .honestRecovery by calling sendInputWhenReady(prompt + "\n", ...) without launching an agent first. In the unverified crash path, that leaves a plain shell window and types the recovery sentence there instead of to an agent, which breaks the agent-first recovery contract. Either start a fresh agent before delivering .honestRecovery, or avoid gating startup until that fallback has a real receiver.

🤖 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 `@Sources/CrashRecovery/CrashRecoverySettings.swift` around lines 56 - 69,
`shouldGateRestoredAgentStartup(...)` is gating startup too early for the
crash/update recovery flow, which prevents the fallback prompt from reaching an
agent. Update the crash-recovery path so `Workspace.createPanel(...)` does not
suppress agent startup before `.honestRecovery` is delivered, or ensure
`Workspace+CrashRecovery.scheduleCrashRecoveryReentry(...)` starts a fresh agent
before calling `sendInputWhenReady(...)`. Keep the gate logic in
`CrashRecoverySettings.shouldGateRestoredAgentStartup` aligned with the
agent-first recovery contract.
Sources/CrashRecovery/WorkspaceResumeCoordinator.swift (1)

154-162: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not key recovery resume delivery only off live-agent state.

recover(_:) is the silent restore path, but restored startup input can already be queued before the agent becomes live. The existing re-entry path carries nativeResumeAlreadyScheduled; this helper only checks !surface.isAgentLive, so verified recovery can enqueue a second native resume during crash/update relaunch. Thread the already-scheduled state through this recovery path, or split recovery delivery so it only sends the breadcrumb when native resume was already scheduled.

Also applies to: 245-249

🤖 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 `@Sources/CrashRecovery/WorkspaceResumeCoordinator.swift` around lines 154 -
162, The verified recovery resume delivery in performVerifiedResumeDelivery
currently relies only on surface.isAgentLive, which can schedule a duplicate
native resume during silent restore. Thread the existing
nativeResumeAlreadyScheduled state from the recovery path into
performVerifiedRecovery/performVerifiedResumeDelivery, or split the logic so it
only sends the breadcrumb when native resume was already scheduled, using the
ResumableWorkspaceSurface and recovery entry path to locate the change.
Sources/Panels/BrowserNavigationDelegate.swift (1)

42-42: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Replace these NSLog calls with unified logging.

These are production browser callbacks, and they currently write navigation URLs and error text directly to the system log. That violates the repo’s Swift logging rule and can leak browsing data; keep the messages in Logger and redact URL-bearing fields.

Suggested direction
- NSLog("BrowserPanel navigation failed: %@", error.localizedDescription)
+ logger.error("BrowserPanel navigation failed: \(error.localizedDescription, privacy: .public)")

As per coding guidelines, use Logger here instead of NSLog.

Also applies to: 51-51, 345-356, 371-380

🤖 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 `@Sources/Panels/BrowserNavigationDelegate.swift` at line 42, Replace the
remaining NSLog-based browser navigation callbacks in BrowserNavigationDelegate
with the repo’s unified Logger usage; update the relevant delegate methods that
log failures and URL-related events so they emit through Logger instead of
system log, and redact any URL-bearing fields or sensitive error text before
logging. Use the existing BrowserNavigationDelegate callback methods as the fix
points, and remove all direct NSLog calls in those paths.

Sources: Coding guidelines, Learnings

Sources/Workspace+CrashRecovery.swift (1)

359-376: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include CODEX_HOME in the verification cache key.

Codex transcript resolution depends on CODEX_HOME (Lines 229-233), but both cache keys only include CLAUDE_CONFIG_DIR. Two restored Codex panels with the same session/cwd but different Codex homes can reuse the wrong transcript verification result.

Suggested fix
                 let configDir = job.agent.launchCommand?.environment?["CLAUDE_CONFIG_DIR"] ?? ""
-                let key = "agent|\(job.agent.kind.rawValue)|\(job.agent.sessionId)|\(job.agent.workingDirectory ?? "")|\(configDir)"
+                let codexHome = job.agent.launchCommand?.environment?["CODEX_HOME"] ?? ""
+                let key = "agent|\(job.agent.kind.rawValue)|\(job.agent.sessionId)|\(job.agent.workingDirectory ?? "")|\(configDir)|\(codexHome)"
@@
                     job.binding.command,
                     job.binding.cwd ?? "",
                     job.binding.environment?["CLAUDE_CONFIG_DIR"] ?? "",
+                    job.binding.environment?["CODEX_HOME"] ?? "",
                 ].joined(separator: "|")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

                let configDir = job.agent.launchCommand?.environment?["CLAUDE_CONFIG_DIR"] ?? ""
                let codexHome = job.agent.launchCommand?.environment?["CODEX_HOME"] ?? ""
                let key = "agent|\(job.agent.kind.rawValue)|\(job.agent.sessionId)|\(job.agent.workingDirectory ?? "")|\(configDir)|\(codexHome)"
                let verification = cachedVerification(key: key) {
                    Workspace.crashRecoveryVerification(agent: job.agent)
                }
                agentResults.append((job.panelId, job.agent, verification))
            }

            for job in bindingJobs {
                guard !Task.isCancelled else { return }
                let key = [
                    "binding",
                    job.binding.kind ?? "",
                    job.binding.checkpointId ?? "",
                    job.binding.command,
                    job.binding.cwd ?? "",
                    job.binding.environment?["CLAUDE_CONFIG_DIR"] ?? "",
                    job.binding.environment?["CODEX_HOME"] ?? "",
                ].joined(separator: "|")
🤖 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 `@Sources/Workspace`+CrashRecovery.swift around lines 359 - 376, The crash
recovery verification cache key is missing CODEX_HOME, so cached results can be
reused across different Codex homes. Update both key निर्माणs in
Workspace.crashRecoveryVerification paths for the agent and binding jobs to
include the CODEX_HOME value from the relevant environment alongside
CLAUDE_CONFIG_DIR, using the existing key-building logic in
Workspace+CrashRecovery and the cachedVerification call site so transcript
verification stays scoped to the correct home.

@austinywang

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@austinywang

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@austinywang

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (2)
Sources/Workspace+CrashRecovery.swift (1)

592-613: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Revalidate the current restored agent before injecting reentry input.

The detached verification result is only checked against the captured agentKind/sessionId; if the panel is reused or its restored agent changes with the same session but different cwd/env, this can inject a breadcrumb or prompt into the wrong terminal. Gate delivery on the current restored-agent fingerprint, not just the captured values.

Suggested fix
             guard !Task.isCancelled else { return }
-            guard result.verification.facts.agentKind == agentKind,
-                  result.verification.facts.sessionId == sessionId,
-                  let self,
-                  let panel = self.panels[panelId] as? TerminalPanel else { return }
-            if self.restoredAgentSnapshotsByPanelId[panelId]?.kind == agentKind,
-               self.restoredAgentSnapshotsByPanelId[panelId]?.sessionId == sessionId {
-                self.restoredAgentVerificationByPanelId[panelId] = result.verification
-            }
+            guard let self,
+                  let panel = self.panels[panelId] as? TerminalPanel,
+                  let currentAgent = self.restoredAgentSnapshotsByPanelId[panelId],
+                  Self.crashRecoveryVerificationFingerprint(agent: currentAgent) == result.verification.fingerprint else {
+                return
+            }
+            self.restoredAgentVerificationByPanelId[panelId] = result.verification
             switch result.action {
🤖 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 `@Sources/Workspace`+CrashRecovery.swift around lines 592 - 613, The
restored-agent delivery path in Workspace+CrashRecovery should revalidate the
current agent state before sending recovery input, since the existing check in
the resumeVerified/honestRecovery flow only matches the captured agentKind and
sessionId. Update the guard around the result handling to also compare the
panel’s current restored-agent fingerprint/state (for example via
restoredAgentSnapshotsByPanelId and related verification data) before calling
sendInputWhenReady or deliverResumeBreadcrumb, so reused panels or changed
restored agents do not receive stale prompts or breadcrumbs.
Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift (1)

143-171: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use shell-neutral template wording before sanitization.

These templates contain I'd, but sanitizedTerminalStartupInputLine() strips ', so the delivered prompt becomes I d like to work on at runtime. Rephrase the localized source strings to apostrophe-free wording such as I would like to work on, and mirror that update in the matching string-catalog entries.

🤖 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 `@Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift` around lines 143 - 171,
The localized prompt templates in ResumeBreadcrumbBuilder’s honest recovery
cases use apostrophes that get stripped by sanitizedTerminalStartupInputLine(),
causing malformed runtime text. Update the source strings in the switch cases
for namedWithCwd, namedNoCwd, unnamedWithCwd, and unnamedNoCwd to use
shell-neutral wording without apostrophes (for example, replace “I’d” with “I
would”), and make the same wording change in the corresponding string-catalog
entries.
🤖 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 `@Sources/CrashRecovery/ClaudeTranscriptPresence.swift`:
- Around line 29-45: Record the actual fallback-scan execution in
ClaudeTranscriptPresence.searchedElsewhere instead of copying searchElsewhere,
so direct at-cwd hits correctly show that no sibling scan ran. Update the logic
in the resolver code that builds ClaudeTranscriptPresence to set
searchedElsewhere only when the historical-project scan actually occurs, and
include searchedElsewhere in ClaudeTranscriptPresence.== so comparisons and
caching can distinguish “not scanned” from “scanned and not found.”

In `@Sources/CrashRecovery/CrashRecoveryOffer.swift`:
- Around line 124-127: The crash recovery offer is using the total recoverable
workspace count instead of the verified resumable count, so the alert text can
overstate how many workspaces can actually resume. Keep
`recoverableWorkspaces(in:defaults:)` for gating, but change the
`CrashRecoveryOfferText.make(resumableCount:)` call in `CrashRecoveryOffer` to
use the verified resume set from `verifiedResumePlans` or `resumableWorkspaces`,
matching the behavior covered by `CrashRecoveryOfferTests`.

In `@Sources/Workspace.swift`:
- Around line 4643-4653: The binding-only resume state handling in
Workspace.swift leaves .observedAgentCommandRunning set after the shell reaches
.promptIdle, so canDeliverHonestRecoveryPrompt still thinks the panel is
promptable. Update the state transition logic in the
restoredAgentResumeStatesByPanelId / pendingResumeBreadcrumbsByPanelId switch to
clear the observed binding-only resume state when prompt idle is reached,
alongside the existing cleanup for .autoResumeCommandRunning, so the idle shell
is no longer treated as recoverable for injected text.
- Around line 4887-4895: When a resume binding is replaced or cleared in the
binding management logic around clearSurfaceResumeBinding, also remove any
pending recovery state tied to that panelId. Update the code that assigns
surfaceResumeBindingsByPanelId and the clearSurfaceResumeBinding method so they
also clear restoredAgentResumeStatesByPanelId and
pendingResumeBreadcrumbsByPanelId alongside restoredAgentVerificationByPanelId,
preventing stale breadcrumbs from being delivered after a new binding reports
.commandRunning.

In `@Sources/Workspace`+CrashRecovery.swift:
- Around line 16-21: `CrashRecoveryVerificationFingerprint` is missing
fact-affecting inputs used by `CrashRecoveryVerification(binding:)`, so stale
cached verification can be reused when process detection or command availability
changes. Update the fingerprint to include the binding state that drives
`hasBinding` and `resumeCommandConstructable` (from `binding.isProcessDetected`
and `binding.command`), and make sure the fingerprint is populated consistently
wherever `CrashRecoveryVerificationFingerprint` is built so cache freshness
reflects those facts.

---

Outside diff comments:
In `@Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift`:
- Around line 143-171: The localized prompt templates in
ResumeBreadcrumbBuilder’s honest recovery cases use apostrophes that get
stripped by sanitizedTerminalStartupInputLine(), causing malformed runtime text.
Update the source strings in the switch cases for namedWithCwd, namedNoCwd,
unnamedWithCwd, and unnamedNoCwd to use shell-neutral wording without
apostrophes (for example, replace “I’d” with “I would”), and make the same
wording change in the corresponding string-catalog entries.

In `@Sources/Workspace`+CrashRecovery.swift:
- Around line 592-613: The restored-agent delivery path in
Workspace+CrashRecovery should revalidate the current agent state before sending
recovery input, since the existing check in the resumeVerified/honestRecovery
flow only matches the captured agentKind and sessionId. Update the guard around
the result handling to also compare the panel’s current restored-agent
fingerprint/state (for example via restoredAgentSnapshotsByPanelId and related
verification data) before calling sendInputWhenReady or deliverResumeBreadcrumb,
so reused panels or changed restored agents do not receive stale prompts or
breadcrumbs.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f8841ff7-fc04-47bb-801b-af42041d7249

📥 Commits

Reviewing files that changed from the base of the PR and between 2906f44 and 1f95c6e.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (18)
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CrashRecovery/ClaudeTranscriptPresence.swift
  • Sources/CrashRecovery/CrashRecoveryOffer.swift
  • Sources/CrashRecovery/ResumeBreadcrumbBuilder.swift
  • Sources/CrashRecovery/WorkspaceResumeCoordinator.swift
  • Sources/Workspace+CrashRecovery.swift
  • Sources/Workspace+PanelLifecycle.swift
  • Sources/Workspace+RestoredNameFidelity.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceActionDispatcher.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/ClaudeTranscriptPresenceTests.swift
  • cmuxTests/CrashRecoveryOfferTests.swift
  • cmuxTests/ResumeBreadcrumbAnchorTests.swift
  • cmuxTests/ResumeBreadcrumbBuilderTests.swift
  • cmuxTests/WorkspaceResumeCoordinatorTests.swift
  • cmuxTests/WorkspaceTitleProvenanceTests.swift

Comment on lines +29 to +45
/// Whether the resolver performed the historical-project fallback scan.
/// Restore-time name verification leaves this false to avoid unbounded launch
/// filesystem work; explicit recovery can recompute with the scan enabled.
var searchedElsewhere: Bool = true

static let absent = ClaudeTranscriptPresence(
existsAtWindowCwd: false,
existsElsewhere: false,
resolvedPathAtWindowCwd: nil,
searchedElsewhere: false
)

static func == (lhs: ClaudeTranscriptPresence, rhs: ClaudeTranscriptPresence) -> Bool {
lhs.existsAtWindowCwd == rhs.existsAtWindowCwd
&& lhs.existsElsewhere == rhs.existsElsewhere
&& lhs.resolvedPathAtWindowCwd == rhs.resolvedPathAtWindowCwd
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Record actual fallback-scan execution in searchedElsewhere.

Line 116 currently just echoes searchElsewhere, so a direct at-cwd hit reports searchedElsewhere == true even though the sibling scan never ran. Because Lines 41-45 also omit this field from ==, callers comparing against .absent or caching the value cannot distinguish “not scanned” from “scanned and not found.”

Suggested fix
 static let absent = ClaudeTranscriptPresence(
     existsAtWindowCwd: false,
     existsElsewhere: false,
     resolvedPathAtWindowCwd: nil,
     searchedElsewhere: false
 )

 static func == (lhs: ClaudeTranscriptPresence, rhs: ClaudeTranscriptPresence) -> Bool {
     lhs.existsAtWindowCwd == rhs.existsAtWindowCwd
         && lhs.existsElsewhere == rhs.existsElsewhere
         && lhs.resolvedPathAtWindowCwd == rhs.resolvedPathAtWindowCwd
+        && lhs.searchedElsewhere == rhs.searchedElsewhere
 }
 ...
-        var existsElsewhere = false
+        var existsElsewhere = false
+        var didSearchElsewhere = false
 ...
             if searchElsewhere,
                resolvedAtCwd == nil,
                !existsElsewhere,
                let children = try? fileManager.contentsOfDirectory(atPath: projectsDir) {
+                didSearchElsewhere = true
                 for child in children where child != windowProjectDir {
                     let projectRoot = (projectsDir as NSString).appendingPathComponent(child)
                     if transcriptPath(inProjectRoot: projectRoot, sessionId: sessionId, fileManager: fileManager) != nil {
                         existsElsewhere = true
                         break
@@
         return ClaudeTranscriptPresence(
             existsAtWindowCwd: resolvedAtCwd != nil,
             existsElsewhere: existsElsewhere,
             resolvedPathAtWindowCwd: resolvedAtCwd,
-            searchedElsewhere: searchElsewhere
+            searchedElsewhere: didSearchElsewhere
         )

Also applies to: 98-116

🤖 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 `@Sources/CrashRecovery/ClaudeTranscriptPresence.swift` around lines 29 - 45,
Record the actual fallback-scan execution in
ClaudeTranscriptPresence.searchedElsewhere instead of copying searchElsewhere,
so direct at-cwd hits correctly show that no sibling scan ran. Update the logic
in the resolver code that builds ClaudeTranscriptPresence to set
searchedElsewhere only when the historical-project scan actually occurs, and
include searchedElsewhere in ClaudeTranscriptPresence.== so comparisons and
caching can distinguish “not scanned” from “scanned and not found.”

Comment on lines +124 to +127
let workspaces = await recoverableWorkspaces(in: managers, defaults: defaults)
guard !workspaces.isEmpty else { return }

let content = CrashRecoveryOfferText.make(resumableCount: workspaces.count)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Drive the alert count from verified resumes, not all recoverables.

recoverableWorkspaces intentionally includes prompt-only unverified bindings, but Line 127 passes that total into CrashRecoveryOfferText.make(resumableCount:). In the mixed case already covered by CrashRecoveryOfferTests (resumable == 1, recoverable == 2), the alert will advertise two resumable workspaces even though only one can actually resume. Keep recoverableWorkspaces for gating/execution, but feed the copy from verifiedResumePlans/resumableWorkspaces.

🤖 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 `@Sources/CrashRecovery/CrashRecoveryOffer.swift` around lines 124 - 127, The
crash recovery offer is using the total recoverable workspace count instead of
the verified resumable count, so the alert text can overstate how many
workspaces can actually resume. Keep `recoverableWorkspaces(in:defaults:)` for
gating, but change the `CrashRecoveryOfferText.make(resumableCount:)` call in
`CrashRecoveryOffer` to use the verified resume set from `verifiedResumePlans`
or `resumableWorkspaces`, matching the behavior covered by
`CrashRecoveryOfferTests`.

Comment thread Sources/Workspace.swift
Comment on lines +4643 to +4653
} else {
switch (restoredAgentResumeStatesByPanelId[panelId], state) {
case (.some(.awaitingAutoResumeCommand), .commandRunning):
restoredAgentResumeStatesByPanelId[panelId] = .autoResumeCommandRunning
deliverPendingResumeBreadcrumbIfReady(panelId: panelId)
case (.some(.autoResumeCommandRunning), .promptIdle):
restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId)
pendingResumeBreadcrumbsByPanelId.removeValue(forKey: panelId)
default:
break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear observed binding-only resume state on prompt idle.

For binding-only panels, .observedAgentCommandRunning remains set when the shell returns to .promptIdle; canDeliverHonestRecoveryPrompt then still treats the agent as promptable and can inject recovery text into an idle shell.

Suggested fix
-            case (.some(.autoResumeCommandRunning), .promptIdle):
+            case (.some(.autoResumeCommandRunning), .promptIdle),
+                 (.some(.observedAgentCommandRunning), .promptIdle):
                 restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId)
                 pendingResumeBreadcrumbsByPanelId.removeValue(forKey: panelId)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
switch (restoredAgentResumeStatesByPanelId[panelId], state) {
case (.some(.awaitingAutoResumeCommand), .commandRunning):
restoredAgentResumeStatesByPanelId[panelId] = .autoResumeCommandRunning
deliverPendingResumeBreadcrumbIfReady(panelId: panelId)
case (.some(.autoResumeCommandRunning), .promptIdle):
restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId)
pendingResumeBreadcrumbsByPanelId.removeValue(forKey: panelId)
default:
break
}
} else {
switch (restoredAgentResumeStatesByPanelId[panelId], state) {
case (.some(.awaitingAutoResumeCommand), .commandRunning):
restoredAgentResumeStatesByPanelId[panelId] = .autoResumeCommandRunning
deliverPendingResumeBreadcrumbIfReady(panelId: panelId)
case (.some(.autoResumeCommandRunning), .promptIdle),
(.some(.observedAgentCommandRunning), .promptIdle):
restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId)
pendingResumeBreadcrumbsByPanelId.removeValue(forKey: panelId)
default:
break
}
🤖 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 `@Sources/Workspace.swift` around lines 4643 - 4653, The binding-only resume
state handling in Workspace.swift leaves .observedAgentCommandRunning set after
the shell reaches .promptIdle, so canDeliverHonestRecoveryPrompt still thinks
the panel is promptable. Update the state transition logic in the
restoredAgentResumeStatesByPanelId / pendingResumeBreadcrumbsByPanelId switch to
clear the observed binding-only resume state when prompt idle is reached,
alongside the existing cleanup for .autoResumeCommandRunning, so the idle shell
is no longer treated as recoverable for injected text.

Comment thread Sources/Workspace.swift
Comment on lines +4887 to +4895
restoredAgentVerificationByPanelId.removeValue(forKey: panelId)
surfaceResumeBindingsByPanelId[panelId] = binding
return true
}

@discardableResult
func clearSurfaceResumeBinding(panelId: UUID) -> Bool {
surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) != nil
restoredAgentVerificationByPanelId.removeValue(forKey: panelId)
return surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) != nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clear pending recovery state when the resume binding changes.

Replacing or clearing a binding drops verification, but leaves restoredAgentResumeStatesByPanelId and pendingResumeBreadcrumbsByPanelId. A queued breadcrumb from the previous binding can be delivered when the new binding reports .commandRunning.

Suggested fix
         restoredAgentVerificationByPanelId.removeValue(forKey: panelId)
+        restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId)
+        pendingResumeBreadcrumbsByPanelId.removeValue(forKey: panelId)
         surfaceResumeBindingsByPanelId[panelId] = binding
         return true
@@
     func clearSurfaceResumeBinding(panelId: UUID) -> Bool {
         restoredAgentVerificationByPanelId.removeValue(forKey: panelId)
+        restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId)
+        pendingResumeBreadcrumbsByPanelId.removeValue(forKey: panelId)
         return surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) != nil
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
restoredAgentVerificationByPanelId.removeValue(forKey: panelId)
surfaceResumeBindingsByPanelId[panelId] = binding
return true
}
@discardableResult
func clearSurfaceResumeBinding(panelId: UUID) -> Bool {
surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) != nil
restoredAgentVerificationByPanelId.removeValue(forKey: panelId)
return surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) != nil
restoredAgentVerificationByPanelId.removeValue(forKey: panelId)
restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId)
pendingResumeBreadcrumbsByPanelId.removeValue(forKey: panelId)
surfaceResumeBindingsByPanelId[panelId] = binding
return true
}
`@discardableResult`
func clearSurfaceResumeBinding(panelId: UUID) -> Bool {
restoredAgentVerificationByPanelId.removeValue(forKey: panelId)
restoredAgentResumeStatesByPanelId.removeValue(forKey: panelId)
pendingResumeBreadcrumbsByPanelId.removeValue(forKey: panelId)
return surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) != nil
🤖 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 `@Sources/Workspace.swift` around lines 4887 - 4895, When a resume binding is
replaced or cleared in the binding management logic around
clearSurfaceResumeBinding, also remove any pending recovery state tied to that
panelId. Update the code that assigns surfaceResumeBindingsByPanelId and the
clearSurfaceResumeBinding method so they also clear
restoredAgentResumeStatesByPanelId and pendingResumeBreadcrumbsByPanelId
alongside restoredAgentVerificationByPanelId, preventing stale breadcrumbs from
being delivered after a new binding reports .commandRunning.

Comment on lines +16 to +21
nonisolated struct CrashRecoveryVerificationFingerprint: Equatable, Sendable {
var kind: RestorableAgentKind?
var sessionId: String?
var cwd: String?
var claudeConfigDir: String?
var codexHome: String?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Include fact-affecting fields in the verification fingerprint.

Cached verification freshness is gated by CrashRecoveryVerificationFingerprint, but CrashRecoveryVerification(binding:) computes hasBinding and resumeCommandConstructable from binding.isProcessDetected and binding.command, while the fingerprint ignores both. A binding that flips detected/live state or loses constructable input can reuse stale verified facts.

Suggested fix
 nonisolated struct CrashRecoveryVerificationFingerprint: Equatable, Sendable {
     var kind: RestorableAgentKind?
     var sessionId: String?
     var cwd: String?
     var claudeConfigDir: String?
     var codexHome: String?
+    var resumeCommandConstructable: Bool?
+    var isProcessDetected: Bool?
 }

@@
         CrashRecoveryVerificationFingerprint(
             kind: agent.kind,
             sessionId: nonEmpty(agent.sessionId),
             cwd: nonEmpty(agent.workingDirectory),
             claudeConfigDir: nonEmpty(agent.launchCommand?.environment?["CLAUDE_CONFIG_DIR"]),
-            codexHome: nonEmpty(agent.launchCommand?.environment?["CODEX_HOME"])
+            codexHome: nonEmpty(agent.launchCommand?.environment?["CODEX_HOME"]),
+            resumeCommandConstructable: agent.resumeCommand != nil,
+            isProcessDetected: nil
         )
@@
         CrashRecoveryVerificationFingerprint(
             kind: binding.kind.flatMap(RestorableAgentKind.init(rawValue:)),
             sessionId: nonEmpty(binding.checkpointId ?? WorkspaceResumeCoordinator.bareSessionId(from: binding.command)),
             cwd: nonEmpty(binding.cwd),
             claudeConfigDir: nonEmpty(binding.environment?["CLAUDE_CONFIG_DIR"]),
-            codexHome: nonEmpty(binding.environment?["CODEX_HOME"])
+            codexHome: nonEmpty(binding.environment?["CODEX_HOME"]),
+            resumeCommandConstructable: !binding.isProcessDetected
+                && !binding.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+            isProcessDetected: binding.isProcessDetected
         )

Also applies to: 259-280

🤖 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 `@Sources/Workspace`+CrashRecovery.swift around lines 16 - 21,
`CrashRecoveryVerificationFingerprint` is missing fact-affecting inputs used by
`CrashRecoveryVerification(binding:)`, so stale cached verification can be
reused when process detection or command availability changes. Update the
fingerprint to include the binding state that drives `hasBinding` and
`resumeCommandConstructable` (from `binding.isProcessDetected` and
`binding.command`), and make sure the fingerprint is populated consistently
wherever `CrashRecoveryVerificationFingerprint` is built so cache freshness
reflects those facts.

Comment on lines +219 to +226
guard let sessionId = nonEmpty(sessionId),
isSafeFilename(sessionId),
let cwd = nonEmpty(cwd) else {
return .absent
}
guard searchElsewhere else {
return .absent
}

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.

P1 CodexTranscriptPresenceResolver short-circuits the at-cwd check when searchElsewhere: false

Lines 224–226 return .absent immediately for any searchElsewhere: false call — the existsAtWindowCwd path is never run. ClaudeTranscriptPresenceResolver.resolve (lines 86–92) correctly does the at-cwd check regardless of searchElsewhere, using the flag only to skip the broader "elsewhere" scan. The Codex resolver must do the same.

The practical breakage: scheduleRestoredAgentVerificationRefresh always passes searchElsewhere: false, so every Codex panel's cached verification is written with existsAtWindowCwd: false, causing two observable failures: (1) auto-summary workspace titles are never re-applied for valid Codex sessions, and (2) if scheduleRestoredAgentVerificationRefresh completes after scheduleCrashRecoveryReentry (which uses searchElsewhere: true), it overwrites the correctly-verified result with the always-absent one — turning a verified recovery into a silent miss for Codex users.

Suggested change
guard let sessionId = nonEmpty(sessionId),
isSafeFilename(sessionId),
let cwd = nonEmpty(cwd) else {
return .absent
}
guard searchElsewhere else {
return .absent
}
guard let sessionId = nonEmpty(sessionId),
isSafeFilename(sessionId),
let cwd = nonEmpty(cwd) else {
return .absent
}
var resolvedAtCwd: String?
var existsElsewhere = false
let needle = sessionId.lowercased()

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Hi @austinywang - this one's gone quiet since the review round on Jun 25. I think I've addressed all the CodeRabbit/Greptile threads and CI is fully green. It is a big diff, so if the size is the blocker I'm glad to carve it into smaller PRs (e.g. split the recovery core from the UI pieces). Let me know what would make it easiest to land.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Thanks for this work on crash and update recovery. After a crash on 2026-09-26 took five agent sessions down, we opened #14870, which reopens sessions the agent journal never saw end and resumes each through its original launcher, and #14824, which keeps rotated session snapshots. They take a different route and don't reuse code from here, but your write-up of the failure modes helped frame them.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks @teamleaderleo, glad the failure-mode write-up was useful. #14870 and #14824 sound like the right shape, especially resuming through the original launcher. I'll close this one out since those cover it. Happy to test either PR against the crash cases I hit if that helps.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Closing this in favor of #14824 (merged) and #14870, which cover crash and update recovery through the agent journal and original launchers. Thanks again @teamleaderleo. Happy to run the crash cases I hit against #14870 if useful.

@mvanhorn mvanhorn closed this Sep 27, 2026
@mvanhorn

Copy link
Copy Markdown
Contributor Author

@teamleaderleo thanks, and glad the failure-mode write-up was useful. Resuming through the original launcher in #14870 is a cleaner route than what I had here. I'll try both once they land.

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.

3 participants