Skip to content

Claude session management: liveness, rename, delete, pins, and spend on the card - #55

Merged
flipbit03 merged 19 commits into
mainfrom
worktree-claude-session-mgmt
Aug 24, 2026
Merged

Claude session management: liveness, rename, delete, pins, and spend on the card#55
flipbit03 merged 19 commits into
mainfrom
worktree-claude-session-mgmt

Conversation

@flipbit03

@flipbit03 flipbit03 commented Aug 23, 2026

Copy link
Copy Markdown
Owner

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_id tmux window option, stamped from inside the window ahead of the command like @claude_birth_name always was. Window ↔ session becomes a recorded fact instead of a heuristic over transcript mtimes; everything below resolves against one list-windows sweep over these stamps.

What the card now shows

  • Live badge: ● 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 (gitBranch per record) beside it.
  • Spend on the meta line: 3 days ago • 12 msgs • 3.4M in / 42k out • ~$7.93 — token totals summed from every assistant record's usage, 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

  • Rename: a live session renames through its tmux window (the title-sync plugin adopts the name into the session, the same path a manual tab rename takes — nothing touches a transcript Claude holds open); a stopped session gets the same custom-title record /rename writes, appended.
  • Pin / Unpin: pinned sessions lead the list in pin order, survive the recency cap however old they get, persist in .forestui-config.json (additive pinned_sessions field, #[serde(default)]), and reorder with K/J while the cursor is on a pinned card. Marker is is tofu in JetBrains Mono, the same problem that retired .
  • Del: permanent, confirmed ("There is no undo" — by design, no trash), disabled while a window holds the transcript, and re-checked against a fresh tmux listing at the moment of deletion so a stale sweep cannot let a running Claude lose its transcript.

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-id refuses 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)

  • Cards with floating buttons: two stacked rows flush right (Claude/YOLO/customs over Rename/Pin/Del) sharing rows with the text column — no full-width button band; issue cards get the same via the shared CardBody node. Width-aware clipping (unicode-width) keeps text and buttons from ever colliding, emoji included; branch and meta always clear the button block.
  • One launch vocabulary: the CLAUDE section and the cards offer the same Claude/YOLO/custom buttons in a new theme-driven Variant::Accent (the branch-name colour) — the yolo-red split is retired everywhere except genuinely destructive actions. Sections read CLAUDE: NEW SESSION / CLAUDE: RECENT SESSIONS.
  • Plugin renamed to 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:

  1. Char-count clipping vs display-width padding broke card borders on wide-charactered turns — fixed with unicode-width; the regression test was mutation-validated (it fails with the fix reverted).
  2. Sparse one-turn cards clipped their meta mid-price beside the button block — the closing lines now always clear it.
  3. macOS ps prints comm as a full path — the heartbeat pipeline (hook ancestry walk + pid validator) would have been silently inert there; both compare basenames now.
  4. heartbeat.sh changed after v4 shipped — bumped to v5 so the existing install reads Outdated (upgradeable) rather than Drifted (accused of hand edits). Crashed-write .tmp files 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 check and make check-shipped green: 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.
  • Full tu-driven e2e on an isolated tmux server with a stub claude and 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 → -r resume.

Known small gaps, deliberate: agent-*.jsonl sidechains 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.)

- 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.
@flipbit03
flipbit03 merged commit e8b2d59 into main Aug 24, 2026
2 checks passed
@flipbit03
flipbit03 deleted the worktree-claude-session-mgmt branch August 24, 2026 00:28
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.

1 participant