# In-Capsule Dirty-Exit: Design Rationale and Implementation Record (https://jackin.tailrocks.com/reference/research/runtime/in-capsule-dirty-exit-design/)



**Status**: Implemented — this is the design record for a shipped feature; see [Runtime Restore and Image Architecture](/roadmap/instant-launch-architecture/) for current roadmap status and remaining work.

## Problem [#problem]

The original host-side dirty-exit confirmation could only prompt *after* the container had already exited, at which point "want to resume?" meant rebuild/restart to get back to work. That is the wrong place to ask: the operator has already paid the teardown cost by the time the question appears.

## Decision — move the confirmation inside the capsule [#decision--move-the-confirmation-inside-the-capsule]

The confirmation runs **inside the capsule** (the in-container multiplexer), not on the host after the container exits. This supersedes the old host-side post-exit dialog for the dirty case.

Why in-capsule (operator's call, accepted):

* **Ask before teardown.** In-capsule, the container is still warm; starting a new agent is instant instead of requiring a rebuild/restart cycle.
* **Decision at the place of work.** The operator is already inside the container; the capsule already owns the TUI and a full set of dialogs. No new surface, no second binary.
* **Less resource churn.** No stop -> ask -> recreate cycle.

The split of responsibility: the **capsule** detects dirty/unpushed state and **asks** the operator before any teardown; the **host** still **executes** the filesystem keep/discard, because it owns the isolated worktrees. The capsule decides; the host carries it out.

## Architecture — share the dirty-detection logic [#architecture--share-the-dirty-detection-logic]

The detection logic had to be shared, not duplicated, so the capsule and the host can never disagree on what "dirty" means.

Before this work, detection lived only host-side, in `assess_cleanup` (pure git work: `git -C <path> status --porcelain` for uncommitted/untracked, `rev-list <upstream>..<branch>` for unpushed), coupled only to a worktree path and a command runner. Both `jackin-runtime` (host) and `jackin-capsule` (in-container) already depend on `jackin-core`, which already held the shared `CleanupStatus` and `IsolationRecord` types — the natural shared home, requiring no new crate and no new binary.

The extraction: a path-based assessment in `jackin-core` returning `Clean | Dirty | Unpushed`, plus the per-file change list the Inspect view needs. The host's `assess_cleanup` became a thin wrapper over it; the capsule calls the same function in-container against its workdir at last-session-close. One source of truth.

## Target workflow — in-capsule dirty-exit [#target-workflow--in-capsule-dirty-exit]

The hinge is the last-session-close branch in the capsule daemon:

```text
last live session exits (agent /exit, Ctrl-D, or crash)
  │  run the shared dirty assessment on the workspace mount(s) in-container
  ├─ clean / pushed ─────────────► drain_and_exit  (unchanged — silent exit)
  └─ dirty / unpushed ───────────► push in-capsule modal; container stays alive
        ├─ Start a new agent ─────► open the New-tab agent picker (verbatim) → spawn → back to work
        ├─ Inspect changes ───────► read-only diff view (Esc back to modal)
        ├─ Exit & keep changes ───► drain_and_exit → host preserves the instance (resumable)
        └─ Exit & discard changes ► drain_and_exit → host discards the dirty work
```

"Start a new agent" reuses the capsule's existing `new_agent_picker` (`PickerIntent::NewTab`) — the exact dialog "New tab" already opens. The confirmation reuses the capsule's existing dialog / modal-stack infrastructure; no new chrome.

## Alternatives considered for the host signal [#alternatives-considered-for-the-host-signal]

Three options for how the capsule tells the host which action the operator picked:

* **A file the capsule writes before draining** (chosen): the capsule writes `/jackin/state/exit-action.json` (`{ "action": "keep" | "discard" }`) before `drain_and_exit`; the host finalize reads it and executes that action. Survives the exit and is inspectable.
* **An exit-code convention**: rejected — range-limited, and conflates the signal with real process-failure exit codes.
* **A control frame over the existing socket**: rejected — more plumbing than a state file for the same one-shot signal.

Other settled decisions from this design pass:

* `Ctrl+Q` from the exit modal maps to `Exit & keep` — the safe default that never loses work.
* Discard takes no extra sub-confirm: selecting `Exit & discard changes` is itself the explicit destructive approval (the operator chose the split-Exit shape). No second Yes/No.

## UI design — agreed shape [#ui-design--agreed-shape]

Settled with the operator by walking faithful TUI previews. The modal renders in the capsule's existing chrome (row 0 `jackin❯` brand pill + tab strip, row 1 `━` underline) as a centered choice list, reusing the capsule's existing dialog / modal-stack components.

The exit modal, `Unsaved work — exit?`, shows one summary line per dirty repo (`<repo>  N changed · N unpushed`) above four choices:

```text
 jackin❯  [the-architect]
 ━━━━━━━━━━━━━━━━━━━━━━━━━━

  ┌─ Unsaved work — exit? ───────────────┐
  │ jackin      2 changed · 1 unpushed    │
  │ holla-apt   3 changed                 │
  │                                       │
  │ ▸ Start a new agent                   │
  │   Inspect changes                     │
  │   Exit & keep changes                 │
  │   Exit & discard changes              │
  └───────────────────────────────────────┘
  ↑↓ · ↵ select · Ctrl-Q quit
```

Design decisions:

* Default focus is the first row, `Start a new agent`. `Esc` is ignored — the modal is a forced explicit choice; there is no ambiguous cancel.
* `Start a new agent` opens the exact New-tab agent picker (`Dialog::new_agent_picker(agents, PickerIntent::NewTab)`), rendered identically: title `New tab`, full-width `── agents ──` / `── shells ──` section dashes, agent display names, type-to-filter, and the `Shell` row. It is the New-tab flow verbatim, not a lookalike. `Esc` in the picker walks back one step to the exit modal (modal stack).
* `Inspect changes` opens a read-only changed-file list inside the capsule, grouped by repo. `Esc` returns to the exit modal. The full side-by-side per-file diff pane was deliberately deferred — the file list ships first, the diff pane is a follow-up.
* Keep vs discard is decided in-capsule: the two `Exit & …` rows are the decision. The host only executes the chosen action — it never prompts.
* Multiple dirty repos are listed one line each, with full per-file detail behind `Inspect`.

Left open at the end of this design pass (minor, settled or still tracked at implementation time): exact row wording, and the cross-screen double-`Ctrl+C` immediate-exit guarantee reaching this modal (the modal currently consumes `Ctrl+C` like any other key; the cross-screen escape is a separate capsule-global concern).

## Implementation record [#implementation-record]

The work landed in three parts:

**Part 1 — share dirty detection into `jackin-core`.** Added the `worktree_dirty` module: `assess_worktree` returning `WorktreeState` (`Clean | Dirty | Unpushed`), plus `ChangedFile` / `parse_porcelain` / `changed_files` for the Inspect view, all driven through the existing `CommandRunner` seam so the crate stays subprocess-free. `PreservedReason` deliberately stayed host-side (it's exit-dialog wording the capsule doesn't consume; the capsule derives its own summary from `WorktreeState` and the change list). Host `assess_cleanup` became a thin wrapper with no host behavior change. Covered by `jackin-core` unit tests (clean, uncommitted, untracked, branch-ahead-no-upstream, ahead-of-upstream, fully-pushed, `[gone]`-merged, detached-HEAD off/at base, status-failure fail-closed, no-branches fail-closed, multiple repos, `changed_files`/`parse_porcelain`) plus the existing host `finalize` tests.

**Part 2 — in-capsule confirmation (`jackin-capsule`).** The capsule launch config (`/jackin/run/agent.toml`) grew `dirty_exit_policy: Option<String>` and `isolated_worktrees: Vec<String>` (serde-defaulted, backward-compatible) so the daemon knows the resolved policy and which mounts to assess at exit; the host's `capsule_config()` builder populates them. `no_live_sessions()` calls `handle_last_session_exit()`, which runs `decide_exit()`: clean, or policy `keep`/`discard`, drains (preserving the non-clean-exit reason); policy `ask` plus dirty pushes the modal and keeps the daemon alive. The `Dialog::ExitDirty` variant reuses the `FilterPicker` snapshot and render machinery; rows route to `Action::OpenAgentPicker(NewTab)`, a read-only changed-files view, or setting `exit_request` so the event loop writes `exit-action.json` and drains. `Esc` is ignored on the modal; default focus is the first row. `Dialog::ExitInspect` groups changed files by repo, read-only, `Esc` walks back.

**Part 3 — host executes the chosen action (`jackin-runtime`).** Clean last-session shutdown no longer fails the launch: the non-zero `docker exec` result at clean shutdown is treated as the attach socket-close race, not a failure (resolved host-side rather than by racing the capsule client's exit code). `launch_role_runtime` falls through to a successful return on a clean exit instead of returning `attach_failure_error`, so finalize still runs. The hardline attach path no longer short-circuits on a non-zero attach result; finalize re-inspects the container and reads `exit-action.json`. The host reads the capsule-signaled action via `ExitActionPrompt` (replacing `RichCleanupPrompt` at both finalize call sites): keep maps to `KeepAll` (preserve), discard maps to `DiscardAll` (terminal cleanup); an absent file maps to `KeepAll` so a crash before the file is written never silently loses work.

## Related work [#related-work]

* Roadmap tracking item: [Runtime Restore and Image Architecture](/roadmap/instant-launch-architecture/)
* <RepoFile path="crates/jackin-core/src/worktree_dirty.rs" />
* <RepoFile path="crates/jackin-capsule/src/daemon.rs" />
* <RepoFile path="crates/jackin-capsule/src/tui/components/dialog.rs" />
* <RepoFile path="crates/jackin-isolation/src/finalize.rs" />
* <RepoFile path="crates/jackin-runtime/src/runtime/launch.rs" />
* <RepoFile path="crates/jackin-runtime/src/runtime/attach.rs" />
