The TUI's preview attached without checking status, so after a host reboot —
every session dormant — moving the sidebar highlight tried a live attach on
each one in turn; the daemon has no bridge to answer, so it 404/409s.
Gate every attach (preview, committed AttachHighlighted, and the reconciling
poll after a resume completes) on status == Running, and show a metadata +
resume placeholder in the pane instead, per docs/PREVIEW.md.
docs/SIDEBAR.md's dormant card contract (#168) applied to the TUI: name and
glyph render in the muted role, the activity rail carries no colour, and a
dormant session sorts after every live one in its folder, name notwithstanding.
The respawn runs with the session map unlocked, so two takes of the
lock straddle it. A kill landing in that window deleted the sidecar
before resume wrote it back, leaving a file for a session that was
gone — the next recovery registered it as a dormant ghost. Resume now
deletes the sidecar in the branch where it finds its own record gone.
Two resumes both passed the `Running` check for the same reason. The
record is marked in-flight under the first lock, so the second request
is refused with `already_running` instead of losing a race for the
FIFO and reporting a tmux failure. Every path out of resume clears the
marker.
Both races are driven deterministically in the tests: the manager's
tmux spawn is parked mid-resume while the racing kill or resume runs.
Closes#175
docs/SIDEBAR.md: the dormant card's muted rendering, its resume
action, and its lowest-precedence ordering.
docs/PREVIEW.md: a session that isn't Running never gets a live
attach through the preview path either — its pane reads the same as
its card.
Muted card foreground and resume-button hover to match spawn-similar
(#168); the content pane's metadata-and-resume placeholder in place of
a live terminal.
Integration review (#168): the content pane opened a pane for
whatever the sidebar highlighted with no status check. After a host
reboot every session is dormant, so arrowing down the sidebar opened
a pane per session that immediately died with WS 1006 (the daemon
answers a bridgeless attach with 404/409, never a completed
handshake).
Gate every path that fills a pane (preview, open, mobile's single
pane) on the session's own status: a session that isn't Running gets
the same metadata-and-resume view its sidebar card shows
(docs/SIDEBAR.md, docs/PREVIEW.md) instead of an attach attempt. The
shell also reacts to onNeverAttached (previous commit) by dropping the
dead connection and re-rendering, and to an open session's
Running-ness flipping — most commonly a resume completing — so a
placeholder and a live pane swap in step with the catalog.
Two integration-review fixes plus the card's own resume action
(#168):
- dataset.status and the aria-label read a two-way running/dead test,
so a dormant card announced itself as "exited" while its visible
dormant flag disagreed. Both now read the session's own three-way
status.
- A dormant card is a muted variant (docs/SIDEBAR.md): spawn-similar
is replaced by a resume action (button, context menu) that calls
POST /api/sessions/:id/resume — there is nothing running to clone
from a dormant session, but resuming it in place makes sense. Kill
is unchanged: it already dismisses a dormant session by removing
its sidecar.
Integration review (#168): a rejected attach handshake — a dormant
session's 409 not_attachable, most likely — looks identical to a
dropped stream from the WebSocket API (close code 1006, no reason).
shouldRetryClose kept retrying it on the catalog's Running/Dead check
alone, which loops for as long as the catalog hasn't caught up with
the session going dormant.
Track whether the socket ever reached `open`; a handshake that never
did is a rejection, not a blip, so it is never retried regardless of
catalog status. The caller (wired up in the next commit) gets a
distinct onNeverAttached callback instead of onClosed, so it can offer
the resume affordance rather than assuming the session is gone.
docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
sidebar tree, so a rebooted host's live work never sorts under a pile
of dormant cards.
#164 skipped the tmux probe whenever the stored boot id differed from the
current one, assuming a changed boot id proves the tmux server is gone. It
does not: a restored state directory, a VM or container snapshot rollback,
or a truncated boot_id file all change it while tmux keeps running with
live sessions. Every one of those sessions was then registered Dormant and
stranded — resume respawns `maestro-<id>`, which collides with the name
tmux still holds, so it failed 502 forever.
Recovery now always runs `tmux list-sessions`, the single source of truth
for liveness the rest of the daemon assumes. The boot id keeps its other
job: telling a reboot from a restart in recovery's log.
The #164 test asserted the old behaviour with tmux still alive, so it is
replaced by one asserting the session is adopted Running, plus one covering
a real reboot — boot id changed and the tmux server dead — still going
dormant.
Refs #174
Recovery now has a stated policy for sessions whose tmux pane is gone but
whose sidecar survived: dormant (default), auto, or never. auto goes down
the same SessionManager::resume the client's POST takes, so there is one
resume path. never prunes the sidecars the way #163 does for a missing cwd.
The default is dormant because resuming agents unattended seconds after a
reboot is a foot-gun; auto is opted into per host. Recovery logs which
policy ran and how many sessions it acted on.
Refs #167
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dormant session comes back under the same uuid and sidecar, so its
name, created_at, env and worktree ownership survive; only the tmux
session and the PTY bridge are new. The command is the matching preset's
resume_command with {agent_session} filled in, falling back to the
command the session was spawned with.
Refused with 409 for a session that is already running or whose cwd is
gone. Attaching to a session with no bridge now answers 409
not_attachable instead of 404 unknown_session, so a client can tell a
resumable session from one that does not exist.
The template's {agent_session} is filled from the session's own
conversation id; a template that asks for an id the session never
reported yields nothing rather than a `--resume` with no argument.
mark_session_dead cleared blocked_work on the local session clone
written to the sidecar, but not worktree — so a pristine tree the
daemon just removed stayed named in the sidecar, and recovery from
that sidecar advertised a branch and path that no longer exist.
Refs #172.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Recovery could not tell a daemon restart from a host reboot: both
looked like "some sessions were not in tmux list-sessions", so the
tmux probe ran unconditionally and the logs said nothing useful when
it was actually a reboot.
Record /proc/sys/kernel/random/boot_id in the state directory on
every recover(). When it differs from the stored value, every session
from the previous boot is provably dead, so the tmux probe is skipped
and the second recovery pass registers them dormant directly, logging
"host rebooted, sessions dormant" with the count. A missing stored
value or an unreadable boot id (non-Linux, restricted /proc) falls
back to today's probe-based recovery.
The boot id source is injected via a BootIdSource trait so tests can
simulate a changed or unreadable boot id without rebooting the
machine.
Refs #164
The test named for recovery only read the sidecar back; nothing covered
the id reaching the recovered session, which is what a resume reads.
Also narrows RESTART.md's leniency claim: the custom Deserialize lives in
the client's own binary, so an older build still fails on Dormant.
recover()'s dormant-registration pass (#162) now deletes a sidecar
instead of registering it dormant when the directory it points at is
gone. Nothing can be resumed there, so the honest move is to remove
the record rather than keep an unactionable one around (#163).
Sidecars whose cwd still exists are unaffected, regardless of age.
A host reboot kills the tmux server, so `SessionManager::recover` iterated an
empty list and every session vanished from the API while its sidecar sat
untouched on disk. A second recovery pass now reads the sidecars tmux had
nothing to adopt and registers each as `SessionStatus::Dormant` — a record of
what was running, with no pane and no PTY bridge behind it.
`SessionStatus` deserializes leniently now, like `ActivityState`: an unknown
spelling reads as `Dead` rather than failing the whole `Session`.
Refs #162
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Claude Code hook already receives a session_id in every payload, but
the activity route dropped it and the daemon had no field to store it on.
Parse it out of the hook body, add Session.agent_session_id, and write it
through to the sidecar once — the first report that carries one wins, and
later reports never overwrite it since an agent's own id never changes
mid-session.
This is the input to a follow-up POST /api/sessions/:id/resume.
The web N path sent the generated handle as the session name as well as
the worktree branch, so the session was born named and never got its
Haiku name from its first prompt. The TUI already spawns unnamed (#131);
the handle now only seeds the branch.
A preview moved the active tab's focus, and the sidebar took that as the
focused session, so hovering another row dropped the accent from the row the
operator had actually opened. Report the pre-preview focus instead.
Every attached client pushed its own dimensions straight to
`tmux resize-window`, so two clients of different sizes resized the
shared window past each other on every report — 198 resizes in 90
seconds on a four-session daemon. Each flip repaints a full-screen agent
at a new height and orphans the rows it drew at the old one, leaving a
stale input box the operator types into and reads as a stuck pane.
The window now goes to the smallest attached client per axis, tmux's own
rule for multiple clients, and only when that size actually changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TUI has always raised `KillConfirm` before `x` kills anything; the web
sent `DELETE /api/sessions/:id` on the first press and only prompted when the
daemon refused. The command bar puts both behind the same three keystrokes,
so the difference now reads as a bug (#160).
The web follows the TUI: a kill is destructive and unattended agents live in
these sessions. `killDialog.ts` grows a confirmation that shares the force
prompt's shell, names the blockers the card already knows about, and keeps the
focus on Cancel. The guarded refusal still opens its own dialog behind it.
The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`,
a module constant. Rebinding one left the other on `ctrl-a`, so the two
escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`)
moved on one client and not the other.
`GET /api/info` now carries `leader`. The web binds that when it arrives and
rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command
bar render the configured chord; an absent or empty field — an older daemon,
or an unreachable `/api/info` — leaves it on `ctrl-a`.
Chose the existing `[tui].leader` served as-is over a new shared key: one
knob, no precedence rules between two spellings, and no migration for
operators who already set it. The name is a wart — it is the leader for every
client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md`
say so.
There is no ctrl-k on a phone and the web UI is mobile-first, so the field
the operator already reaches for becomes the touch entry point: focusing or
tapping it dispatches OPEN_COMMAND_BAR_EVENT and the shell opens the bar with
an empty query. The field is readonly and blurs itself first, so no keyboard
rises over it and the bar can be closed without reopening it.
This replaces the inline substring filter — one search affordance in the
header, not two. buildTree loses its filter argument, and the tree's "No
matches" empty state goes with it; the bar's own no-results state took over.
The filter's name-and-directory matching is now covered against the bar.
The bar's phone-width sheet moves to the spawn modal's 599px break, with
thumb-sized rows and a field that clears the zoom-on-focus threshold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ctrl-a is consumed as the leader on every press, so it never reaches the
shell and readline's beginning-of-line was gone with no way back — unlike
Ctrl-K, which already has an escape hatch. Fix it the way tmux does:
pressing the leader twice sends the literal leader byte to the focused
pane and clears the pending chord, on both web and TUI, built from the
configured leader so a rebound leader carries it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ctrl-k now opens the command bar in both modes (#153), taking readline's
kill-to-end-of-line away from every pane. Leader then ctrl-k — ctrl-a
ctrl-k by default — sends the literal 0x0B byte to the focused pane
instead, built from the configured leader so rebinding it rebinds the
chord too.
Chording a second binding off the leader exposed a gap in ChordState:
a key bound both alone and as a chord prefix (ctrl-a alone, and ctrl-a
ctrl-k) would get stuck waiting on the next keystroke, discarding its
own meaning the moment that keystroke didn't continue the chord. Feed
now resolves the held prefix to its own binding when that happens and
hands the breaking key back via take_replay for the caller to process
fresh — otherwise, pressing leader then any other key would never
leave Insert mode again.
`tab` on a highlighted session opens its action page inside the command
bar: kill, rename, spawn similar, still fuzzy-searchable, with the
overlay titled after the session. Escape or backspace at an empty query
returns to the result list with the previous query intact.
Each action raises the flow the TUI already has — `KillConfirm`, the
rename prompt pre-filled, and the spawn form, now seeded from that
session's directory and command rather than the focused one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tab on a highlighted session in the web command bar opens that session's
action page in the same overlay: kill, rename, spawn similar, still fuzzy
searchable and headed by the session's name. Escape, or backspace at an
empty query, returns to the result list with the previous query intact.
Each action raises the flow the card already has. The two that lived
inside the sidebar get an event apiece — the confirm-and-blockers kill and
the inline rename prompt — so the bar picks the action and nothing more;
spawn similar reuses the existing SPAWN_SIMILAR_EVENT.
`>` narrows to commands from the live keymap for the current mode, matched
on the action's help_label only — the chord is displayed, never matched, so
results cannot depend on a user's rebinds. Rows dedupe by action, not by
chord: the default map binds both `enter` and `tab` to AttachHighlighted,
and AttachHighlighted and ToggleAttachHighlighted share a label because they
are the same match arm.
`~` narrows to recent dirs plus the project list. Enter takes the
fast-forward path pinned to that directory, with no recency fallback, so a
directory outside the project scan opens the pre-filled form rather than
spawning elsewhere.
Both fall out of parsing the query line, as in web/src/commandBar.ts: a
sigil is only a sigil in first position, and backspacing past one is just
the query becoming mixed again.
The global listener already ran in the capture phase, but deferred to
isTypingTarget, so xterm's hidden textarea kept Ctrl-K. The bar now treats
that textarea as not a typing target, while real form fields — the spawn
form's inputs — keep their own Ctrl-K.
Leader then ctrl-k (ctrl-a ctrl-k) sends the literal 0x0B to the focused
pane through the leader chord the command set already resolves, so
readline's kill-to-end-of-line stays reachable. The pool grows a
sendToFocused for it, over a new AttachConnection.sendInput.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`>` narrows the bar to commands, matched on the label only with the chord
shown beside each row and deduped by action. `~` narrows to directories over
recent dirs and the project list, filled in from /api/dirs as a path is typed;
Enter fast-forward-spawns there, falling back to the pre-filled spawn form
when a default cannot resolve. Without a sigil the list stays mixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>