Claude session management: liveness, rename, delete, pins, and spend on the card - #55
Merged
Conversation
- Pre-mint --session-id for fresh sessions and stamp @claude_session_id on every Claude window, making window <-> session a recorded fact - Live/stale badge per session card, from one list-windows sweep - Duplicate-open guard: resuming an open session offers its window - Live pane peek: last lines of a running session's pane on its card - Rename session: live via tmux window (plugin syncs), stopped via a custom-title record appended to the transcript - Delete session: confirmed, permanent, refused while a window is open - Pin/unpin sessions per path, K/J reorder, persisted in the config - Branch, token totals and estimated cost on every card
- Pin marker is `◆`, not `★`: the star is tofu in JetBrains Mono and friends — the same problem that retired `⟳` — verified in a live tu render - A live rename refreshes the live-window snapshot in the same task, so the card's badge shows the new name immediately instead of after the next ten-second sweep (extracted tmux::live_snapshot for both callers) - tu-sweep normalises the pre-minted `--session-id <uuid>`, which is freshly random per launch and would have made UC-63/64 flaky - Baselines refreshed: session cards now carry Rename/Pin/Del
Adversarial-review findings, each verified against a real environment:
- The up arrow after a Ctrl-C used to replay the launch line, and a
fresh session's launch line now carries `--session-id` — which Claude
refuses once the session exists ("Session ID is already in use",
verified against claude 2.x). The shell history now remembers the
*resume* form of the same id instead, so the up arrow goes back into
the same conversation rather than into an error.
- Resume and session ids are single-quoted in the command line. A
resume id is a transcript's filename stem — read off disk, not
written by forestui — and it reaches an interactive shell.
- Deleting a session re-asks tmux at the moment of truth instead of
trusting the live map, which can be a sweep old: a window opened in
that gap no longer loses its transcript out from under Claude.
Feedback round: - The live pane peek is gone — card, event payload, capture plumbing and all. The badge still says a session is live and where; what the pane shows belongs to the pane. - Tokens and cost moved from their own line onto the meta line: "3 days ago • 12 msgs • 3.4M in / 42k out • ~$7.93". The meta line moves off the button row to a row of its own — sharing it pushed Pin and Del past the card edge at ordinary widths, which the tu render caught. The recognition line keeps only the live badge and the branch. - tu-sweep: the <pid> rule now runs before the <sha> rule (a 7-digit pid is also seven hex characters, and rule order was a per-run coin flip that kept dirtying UC-82), with dev-<hhmm> masked ahead of both so a dev window timestamp is not read as a pid. - UC-96 baseline refreshed for the meta line's own row.
Crammed under the turns it read as part of the conversation block; at the bottom of the card the button row is what separates it. UC-96 baseline refreshed.
The field used to open empty on a session with no chosen title, even though its card visibly has a name. It now continues the existing name with the cursor at the end — the way tmux's own rename prompt does — falling back window name → chosen title → shown title, clipped to the field's cap so the seed is never something typing could not produce.
'on main' under the turns read as part of the conversation; it now says 'on branch main' — the name in the accent the headers use for branches — on its own line between the buttons and the meta. The badge line above the turns keeps only the live badge. The sweep transcripts carry no gitBranch, so no baseline moved.
A badge line saying 'live in <window>' under a title that *is* the window name repeated it for nothing. The badge now rides the title — '<name> · ● live' — and names the window only when it actually differs from the session name (a :2-uniquified window, or an unnamed session whose title is its first prompt). No baseline moved: the sweep sessions have no live windows.
The '○ open' state held a session hostage to a shell prompt: badge on the card, resume guarded, delete refused — for a window holding nothing. The live map now admits only windows where Claude is the foreground process, so an exited session is resumable anywhere, renamable and deletable again, and the badge simply disappears. The stamp stays on the window, so an up-arrow restart of the same session is re-detected on the next sweep — verified end-to-end: live -> Ctrl-C (freed, Del re-enabled, no guard) -> up-arrow -r (badge back, Del disabled).
The window stamp only sees windows forestui opened. A `claude -r <id>` typed into a hand-made tmux window, or a terminal with no tmux at all, was invisible — and a manual resume of a *different* session inside a forestui window wore the stale stamp's badge. The plugin (now v4, upgrade offered in Settings) gains `heartbeat.sh`: on SessionStart and UserPromptSubmit it writes one file per session under ~/.config/forestui/live/ carrying the claude process id (found by walking the hook's ancestry — the hook's own parent is a throwaway shell), removes it on SessionEnd, and re-stamps @claude_session_id on its window, healing stale stamps. forestui validates each heartbeat's pid against `ps` (comm must be claude/node — a reused pid is not a session) and sweeps stale files, so a crash cannot leave a session reading as live. `services/live.rs` merges both sources: stamped windows win (they are switchable); a heartbeat whose pane resolves onto our own tmux session becomes switchable too; anything else shows as "● live elsewhere", resuming it is an eyes-open red "Resume anyway" confirm, and rename and delete refuse it (delete re-checks both sources at the moment of truth). Also: unit tests can no longer reach a real tmux server, ever. A test that exercised the resume path opened a real window in the developer's live tmux — the test binary inherits TMUX_PANE, and only nonexistent fixture paths had been keeping the older tests safe. services::tmux now refuses every command under cfg!(test), a regression test pins it, and the offending test drives the guard directly instead of the launch path. Verified end-to-end in an isolated rig: a heartbeat for a claude-named process outside any tmux shows "live elsewhere" with Del disabled, Resume raises the fork warning, and killing the process frees the session and sweeps the heartbeat file. heartbeat.sh itself is executed under test with ps/tmux faked, pinning the file it writes, the SessionEnd sweep, id sanitisation and the stamp healing.
The user opted in, and a newer build ships hooks their install lacks — the liveness heartbeat arrived in plugin v4 — so staying silent would look exactly like the feature not working. Not-installed and drifted stay quiet: one was never chosen, the other is the user's own edit. The status read hashes the installed files, so it runs off the loop like every other startup check.
Since plugin v4 it does two jobs — tab/session name sync and session liveness — so 'Session Name Sync' undersold and misdescribed it. The Settings button is 'Manage Integration...', the dialog is 'Claude Integration' and describes both roles, and the CLI help and docs follow. The internal plugin name (forestui-tmux-title) deliberately stays: renaming the directory would orphan every existing install. UC-57 baseline refreshed for the new button label.
The directory was still called forestui-tmux-title, which described one of the two jobs it does since v4. No install base exists to migrate — the one known install was removed by hand and reinstalled under the new name — so the rename is clean: no legacy sweep, no compat shim.
The full-width button band cost three rows that held nothing but buttons and read as a divider through the card. The buttons now float flush right in two rows — launch (Claude, YOLO, customs) above manage (Rename, Pin, Del) — sharing screen rows with the text column, which keeps its own order: title, one blank, the turns, one blank, branch, meta. The renderer clips the left text so it can never run under a button, and claims each control exactly as the old band did, so the item order, K/J card-width arithmetic and hit regions all hold — verified by clicking a floating Pin in the rig. 'Resume' is now 'Claude', matching the launch-row naming. Cards drop from nine rows to seven. UC-96 baseline refreshed.
Same treatment as the session cards: the button's three rows share the card's own rows — title, blank, meta on the left, Create WT flush right — instead of a full-width band under the title. The node is renamed CardBody since it now serves both card kinds. UC-96 baseline refreshed.
Five cards of -destructive YOLO buttons read as a wall of warnings. The card's launch row — Claude, YOLO, and custom buttons alike — now uses a new Variant::Accent: Normal's quiet fill and border with the label in the theme accent, the colour the pane gives branch names. Theme-driven throughout, no hardcoded colours. The CLAUDE section keeps its full-strength colouring — the card is not the place to relitigate a choice made at launch — and Del stays destructive: deleting is. Colour-only, so the text baselines are untouched; verified in a live render.
The CLAUDE section's buttons join the standardization the cards started: 'New Session' and 'New Session: YOLO' are now 'Claude' and 'YOLO' in Variant::Accent, custom buttons likewise (YOLO-style ones included — Variant::claude and its red split retire). The section headers say what the buttons no longer repeat: 'CLAUDE: NEW SESSION' and 'CLAUDE: RECENT SESSIONS'. The Edit Button dialog's note about --dangerously-skip-permissions styling the button red was stale and is replaced. Baselines refreshed across the sweep for the new labels.
Four findings against the whole branch, each fixed and pinned:
- The card renderer clipped left text by character count while its
padding measured display width. Turns routinely carry emoji — two
columns each — so a wide-charactered line ran under the gap and broke
the card's right border. clip_spans now measures with unicode-width
(the same authority Span::width resolves to); the regression test was
validated by mutation — it fails when the fix is reverted.
- A sparse card (a one-turn session) put its branch and meta beside the
manage row's bottom border, clipping the meta mid-price ("~$0.0").
The left column now pads so both always clear the button block.
- macOS ps prints comm as the executable's full path, which would have
made the heartbeat pipeline silently inert there: the hook's ancestry
walk never found claude and the validator never believed a pid. Both
now compare basenames.
- heartbeat.sh changed after plugin v4 shipped; without a bump the one
existing install would read as hand-edited drift instead of outdated.
v5. A crashed write's `.tmp` sibling is also swept from the live dir.
Retested end to end: 262 unit tests in both build configs, sweep 22/22
(UC-96 refreshed for the pad), and a full tu matrix on the final build —
boot notice v4->v5, emoji-card border integrity, custom buttons in the
card launch rows resuming with the custom command, live badge
re-detection after an up-arrow restart, the switch guard, pin plus K/J
keyboard reorder, the elsewhere badge from a heartbeat, and an 80-column
resize surviving with buttons intact.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Claude session management: the session list stops being read-only, and forestui stops guessing which window holds which conversation.
The enabler
A fresh session launches with a pre-minted
--session-id, and every Claude window — fresh or resumed — carries a@claude_session_idtmux window option, stamped from inside the window ahead of the command like@claude_birth_namealways was. Window ↔ session becomes a recorded fact instead of a heuristic over transcript mtimes; everything below resolves against onelist-windowssweep over these stamps.What the card now shows
● live in <window>when Claude is the pane's foreground process,○ open in <window>when the window is open but Claude exited or was suspended — with the branch the conversation last saw (gitBranchper record) beside it.3 days ago • 12 msgs • 3.4M in / 42k out • ~$7.93— token totals summed from every assistant record'susage, and an estimated cost for models in a small documented price table. Tokens always show; the dollar figure only when the model's prices are known, never invented. The meta line takes a row of its own so the button row never clips.What the card can now do
custom-titlerecord/renamewrites, appended..forestui-config.json(additivepinned_sessionsfield,#[serde(default)]), and reorder withK/Jwhile the cursor is on a pinned card. Marker is◆—★is tofu in JetBrains Mono, the same problem that retired⟳.Duplicate-open guard
Resuming a session that already has a window offers to switch there instead of starting a second Claude on the same transcript — with distinct wording for running vs suspended (where the up arrow in that window is the way back). "Switch" renders as the safe (primary) choice, not destructive red.
Up-arrow restart kept honest
--session-idrefuses to run once the session exists ("Session ID is already in use", verified against claude 2.x) — and an up-arrow rerun is exactly that state. The shell history therefore remembers the resume form (claude -r '<id>') rather than the launch line, so the up arrow after a Ctrl-C goes back into the same conversation. Resume and session ids are also single-quoted now: a resume id is a transcript filename stem read off disk.UI (evolved over several feedback rounds)
Claude/YOLO/customs overRename/Pin/Del) sharing rows with the text column — no full-width button band; issue cards get the same via the sharedCardBodynode. Width-aware clipping (unicode-width) keeps text and buttons from ever colliding, emoji included; branch and meta always clear the button block.Claude/YOLO/custom buttons in a new theme-drivenVariant::Accent(the branch-name colour) — the yolo-red split is retired everywhere except genuinely destructive actions. Sections readCLAUDE: NEW SESSION/CLAUDE: RECENT SESSIONS.forestui-integration(v5), with a startup warning when an installed integration is outdated; Settings says "Claude Integration" throughout.Adversarial review round (post-feature)
Four findings against the finished branch, fixed and pinned by tests:
psprints comm as a full path — the heartbeat pipeline (hook ancestry walk + pid validator) would have been silently inert there; both compare basenames now.heartbeat.shchanged after v4 shipped — bumped to v5 so the existing install reads Outdated (upgradeable) rather than Drifted (accused of hand edits). Crashed-write.tmpfiles are swept.Known graceful degradation: on narrow panes, a long unnamed-session title can clip the live badge off the title line — the greyed Del and the resume guard still carry the signal.
Testing
make checkandmake check-shippedgreen: 262 tests (~30 new), covering the parse additions (usage/branch/model, old minimal transcripts unchanged), cost table boundaries, pin ordering/persistence/edges, the guard's two wordings, delete refusal + fold, K/J cursor-follow, live-map fold repaint discipline, window parsing from stamps, and the command-line/history forms.scripts/tu-sweep.sh: all 22 on-disk assertions pass; baselines refreshed for the new card buttons, and the sweep now normalises the pre-minted uuid (--session-id <uuid>) so UC-63/64 stay stable.tu-driven e2e on an isolated tmux server with a stubclaudeand seeded transcripts: card rendering (branch/tokens/cost verified by hand), pin float + persistence across restart, rename stopped (record on disk) and live (tab renamed, badge refreshed immediately), delete (file gone), resume seeding, guard dialog + switch, live badge, premint args, and Ctrl-C → up-arrow →-rresume.Known small gaps, deliberate:
agent-*.jsonlsidechains are not deleted with a session's main transcript; a pin whose session was deleted outside forestui lingers invisibly in the config; the live map is at most one 10s sweep stale for badges (every destructive path re-checks tmux directly).(The live pane peek was built, shipped, judged ugly, and removed.)