Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,14 +303,39 @@ The page-side `mousedown` handler also calls `fitAddon.fit()`, and the initial f
- **Close** (`vm.CloseCommand`) → `MainViewModel.OnSessionCloseRequested` → `vm.Dispose()` + remove from `Sessions` + `SessionManager.RemoveSession()`. Session is gone from `state.json`.
- **Sleep** (`SleepSession(vm)`) → `vm.Dispose()` + remove from `Sessions` but **keep** the `ShellSession` in `SessionManager` with `IsDormant = true`. A muted dormant sidebar entry replaces the active one.
- **Wake** (`WakeSessionAsync(session)`) → re-runs `LaunchSessionAsync(session, restoring: true)` — same path as restore-on-startup.
- **Restart** (`RestartSessionAsync(vm)`) → sleep-style teardown *without* the dormant bookkeeping, then `LaunchSessionAsync(session, restoring: true, removeOnFailure: false)`. The `ShellSession` stays in `SessionManager` (so Id, group, run commands and sidebar slot survive) and never enters the recently-closed ring. Used by "Edit session…" — see below. Both non-default arguments matter: `restoring: true` makes a Claude session resume rather than start a fresh conversation (matching Wake — a restart tears down the same way, so it must recover the same way), and `removeOnFailure: false` stops a bad edit from *deleting* the session, since `LaunchSessionAsync`'s PTY-failure path calls `SessionManager.RemoveSession` — correct for a session that never started, destructive for a relaunch. If the relaunch fails either way, the session falls back to dormant so the row stays visible and fixable instead of leaving a launching placeholder that never resolves.
- **Restart** (`RestartSessionAsync(vm)`) → sleep-style teardown *without* the dormant bookkeeping, then `LaunchSessionAsync(session, restoring: true, removeOnFailure: false)`. The `ShellSession` stays in `SessionManager` (so Id, group, run commands and sidebar slot survive) and never enters the recently-closed ring. Reachable from the **↻ toolbar button and the "Restart" menu entries** (see "Restarting sessions" below) and from "Edit session…". Both non-default arguments matter: `restoring: true` makes a Claude session resume rather than start a fresh conversation (matching Wake — a restart tears down the same way, so it must recover the same way), and `removeOnFailure: false` stops a bad edit from *deleting* the session, since `LaunchSessionAsync`'s PTY-failure path calls `SessionManager.RemoveSession` — correct for a session that never started, destructive for a relaunch. If the relaunch fails either way, the session falls back to dormant so the row stays visible and fixable instead of leaving a launching placeholder that never resolves.

A Claude restart also **waits for the old process to actually exit** (`DisposeAndWaitForExitAsync` + a config quiesce) before relaunching. Without that it recreates the concurrent-config-writer race the launch stagger and the shutdown loop both exist to prevent, and `--resume` can read a session index the outgoing process hasn't finalised. Non-Claude sessions skip the wait — they don't touch that file.

6. On app close: `_vm.SaveStateAsync()` flushes `_sessionManager.Sessions` (live + dormant) to `state.json` (unless `--clean`).

**Waiting for a PTY to exit.** Check `PseudoTerminal.HasExited`, never `IsRunning`. `IsRunning` is `_hProcess != IntPtr.Zero` and the handle is only released in `Dispose`, so it stays true for a child that exited on its own — subscribing to `Exited` for one of those waits out the full timeout for an event that already fired. `HasExited` is latched immediately before `Exited` is raised. This is not academic: combined with `ClaudeShutdownBudgetMs`, two stale panes consumed the entire shutdown budget and every remaining *live* Claude session was then force-disposed with no exit wait — the exact opposite of what the budget was for.

**`ClaudeShutdownBudgetMs` is sized from measurement (30s).** The original 15s came from the only data available at the time — idle sessions exiting in 460–770ms. Real shutdowns of *busy* sessions measure **2.3–4.7s each**, so nine of them need roughly 30s, and 15s meant force-disposing more than half the fleet on an ordinary close. Waiting is the right trade: a clean exit lets Claude finish writing its config, and `ShutdownOverlay` is already on screen telling the user why. The budget exists to bound a genuinely wedged session, not to hurry a healthy one. If you shrink it, re-measure `exit=` in `crash.log` first — the summary line alone can't distinguish "slow exits" from "waits that aren't returning".
6. On app close: `_vm.SaveStateAsync()` flushes `_sessionManager.Sessions` (live + dormant) to `state.json` (unless `--clean`).

### Restarting sessions

Restart closes and reopens a session in place. The use case is picking up a new build of the CLI — `claude` updated on disk — without losing the session, its group, its run commands or its conversation. Five entry points, all routed through `RestartSessionsAsync`:

| Entry point | Scope | Confirms? |
|---|---|---|
| **↻** on the terminal toolbar (between ⚙ and 💤) | the one session | no |
| **"Restart"** in the per-session right-click menu (above Sleep / Close) | the one session | no |
| **"Restart (N)…"** — same menu, with 2+ sidebar rows selected | the selection | yes |
| **"Restart all…"** in the group right-click menu, next to Sleep all / Close all | live sessions in that group | yes |
| **"Restart all…"** under Bulk actions in the sidebar quick-menu | every live session | yes |

Multi-select resolves through `MainViewModel.ResolveActionTargets`, exactly like Sleep / Close. The per-session menu is rebuilt on `ContextMenuOpening`, so the count always reflects the live selection.

Confirmation is driven by the `confirmHeadline` parameter rather than a count test: **null** (the toolbar button and the per-session menu) prompts only for 2+ targets with a generic headline, so a single "Restart" just goes; the bulk entry points pass a scope-naming headline and therefore always prompt, matching their "Close all…" siblings. The prompt defaults to **No**, like "Close all…" — a restart terminates every running process *and* stops in-flight run commands (`vm.Runner.StopAll()`), and a stray Enter shouldn't start a minute-scale operation with no stop button. Dormant sessions are filtered out everywhere — they want Wake, and they pick up a new binary when woken anyway.

**The guard lives on the entry points, not the restart itself.** `RestartSessionCoreAsync` does the work and holds no guard; `RestartSessionAsync` (single session — used by "Edit session…") and `RestartSessionsAsync` (everything else) each take `_restartInProgress` and call the core. The split exists because the bulk loop holds the flag across all its iterations, so a guard inside the core would reject the loop's own work. Don't call the core from anywhere that doesn't already hold the flag: the edit dialog used to call the restart directly, which meant answering "Restart now?" during a bulk restart put two `claude.exe` instances on `~/.claude.json` at once. A rejected invocation raises a toast rather than failing silently, since a bulk restart runs long enough that a dead-feeling button reads as a bug.

**Two shutdown checks, and both are needed.** `RestartSessionsAsync` breaks out of its queue when `_isShuttingDown`, and `RestartSessionCoreAsync` checks again *after* its teardown waits and before `LaunchSessionAsync`. The second one is the load-bearing one: the core removes the VM from `_vm.Sessions` **before** the 10s exit wait, so a session that is mid-restart is invisible to `OnClosing`'s `var all = _vm.Sessions.ToList()` snapshot. Relaunching past that point starts a ConPTY child nothing will ever dispose — and session PTYs are started with `useJobObject: false` (only `RunInstance` passes `true`), so no job object cleans up the tree either. The result is an orphaned `claude.exe` outliving the app. Returning early leaves the `ShellSession` in the `SessionManager`, so it is still in `state.json` and simply starts again next launch.

**Restart restores focus.** The teardown hands `ActiveSession` to `_vm.Sessions.LastOrDefault()` and `LaunchSessionAsync` never claims it back, so the core captures `wasActive` up front and re-resolves the session by id afterwards (the relaunch builds a *new* `SessionViewModel`). Without it, restarting the session you're looking at swaps the visible pane in `LayoutMode.Single` and silently retargets `Ctrl+W` / `F5` at an unrelated session.

`RestartSessionsAsync` runs its targets **strictly sequentially** — `RestartSessionAsync` waits for each Claude process to exit before starting its replacement, and awaiting those in parallel would put several `claude.exe` instances back on the shared config file at once, which is the race the wait exists to prevent. A `_restartInProgress` flag drops (rather than queues) an overlapping invocation for the same reason — see the guard note above. That sequencing is also why the bulk entry points confirm: restarting a fleet of Claude sessions is a minute-scale operation, not an instant one. Each target is re-resolved per iteration, since every restart replaces the `SessionViewModel` for that id and an earlier one in the loop may have failed into dormant.

## Editing a Session's Configuration

Expand Down
Loading