SLOPSHOPPER

cs-update

Release notes for a pending cs update, once per launch, with 1 to install it in place; /cs-update reopens the pane.

newpanecommandtoastprocess
★ 45v0.1.0MITupdated 2026-09-24hex/claude-sessions/mods/cs-update
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cs-update
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cs-update ⎿ cs-update: The release-notes pane belongs to the conversation cs launched. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="https://raw.githubusercontent.com/hex/claude-sessions/main/assets/banner.svg" width="100%" alt="cs: a session manager for Claude Code">

Test

A session manager for Claude Code that creates isolated workspaces with automatic documentation.

Why cs?

Claude Code doesn't require a project. You can spin up an instance to debug an API, troubleshoot home automation, research a hardware problem, or explore any idea that comes to mind.

But conversations get lost. You discover key insights, create useful scripts, figure out a tricky configuration - then the session ends and it's gone.

cs gives every task a home:

cs debug-api          # Investigate that flaky endpoint
cs homeassistant      # Fix your smart home setup
cs router-config      # Document your network settings
cs research-llms      # Explore a topic, keep your notes

Each session is a persistent workspace - documentation and secrets that survive across conversations.

No git repo required. No project structure needed. Just a name for what you're working on.

Features

Session workspaces

  • Isolated session workspaces - Each session has its own directory with structured documentation
  • Documentation templates - Pre-configured markdown files for the session narrative and outcome
  • Automatic git version control - Every session gets a local git repo; in-session edits are autosaved to a shadow ref for crash recovery
  • Session locking - PID-based lock prevents the same session from being opened in two terminals simultaneously; use --force to override. cs also treats a session as live when its statusline heartbeat is fresh — in the TUI (■ live · unlocked), cs -live, and the cs -usage marker — so a conversation opened outside cs still registers as live. The destructive guards (cs -rm/-archive/-spawn) stay on the strict PID lock, so a session whose process is gone is still removable without --force
  • Deterministic Claude-session resume - Each session pre-allocates a conversation UUID in the gitignored .cs/local/state, so cs <name> resumes the exact conversation via claude --resume <uuid>, not the most-recent one --continue might pick from a sibling. A ps-based guard refuses to launch a second claude for the same conversation (it counts a process only when the UUID follows --session-id, --resume/-r or --parent-session-id, so a leftover Bash-tool child carrying the id does not block a relaunch; --force overrides), and every launch passes --name plus a per-session /color so parallel sessions stay visually distinct.
  • Per-session memory path redirect - cs points Claude Code's built-in auto-memory writer at <session>/.cs/memory/ (via CLAUDE_COWORK_MEMORY_PATH_OVERRIDE) so durable facts land in the session instead of the global project store. The harness owns how memory files are written (naming, frontmatter, MEMORY.md index); cs owns only the storage path.
  • Conversation rotation - a heavy conversation can hand off to a fresh one without losing context: the rotate skill (self-invoked, or nudged once per conversation at 65% context) writes a lineage-stamped handoff to .cs/handoffs/ and arms it, then /clear continues from it without leaving Claude Code — and continues on its own, since the session wakes itself a moment later and starts the handoff's next step with nothing typed (CS_NO_ROTATION_WAKE=1 to wait for a word instead). Exiting and answering r at the next cs <name> launch does the same; d discards the handoff. cs -conversations shows the resulting chain. The cs mod, a Claude Code function-hooks plugin the installer deploys and every cs launch enables, puts a rotate this conversation button above the prompt once context reaches 40% (CS_ROTATE_BUTTON_CTX moves it), in a capsule styled like the status bar and led by a cs chip in the session's colour, beside wrap up this session, which asks first, since a wrap runs two Opus passes and replaces the summary. Ctrl+X 1 and Ctrl+X 2 press them, never a bare digit, so a number typed to answer a question stays in the prompt. Ctrl+X 2 opens the engine's own dialog — Run /wrap for this session?, with Yes, wrap up and Not now — and only the yes runs it (once a wrap finishes, its last pass writes .cs/local/wrapped and the key hides until the next turn that starts from a prompt, so a finished wrap is not offered again); the rotate press runs /rotate, and once the handoff is armed the band draws /clear and continue from the handoff alone and runs the /clear itself. The mod presses the button for you once a turn ends past 80% context: it runs /rotate, and once the handoff is armed the capsule counts down 20 seconds to the /clear (Ctrl+X 1 to go now, send a prompt to stop it). CS_ROTATE_FORCE_CTX=<percent> moves that threshold and CS_ROTATE_FORCE_CTX=off turns it off; the first launch on a machine says so once. CS_NO_FUNCTION_HOOKS=1 withholds the mod; see docs/hooks.md.
  • Rotate and wrap keys - the installer asks once per machine whether to bind Ctrl+X R to /rotate, Ctrl+X W to /wrap, and Ctrl+X 1 / Ctrl+X 2 to the cs band's keys in Claude Code's keybindings.json. Ctrl+X R and W work anywhere in Claude Code, band or no band, and all four keep what you are typing. It never replaces a key you already bind, refuses a file it cannot parse, and swaps out the Option+1 / Option+2 keys an earlier cs bound unless Ctrl+X R or Ctrl+X W is already yours. cs -uninstall takes back only its own keys, and cs -doctor shows the state. See docs/configuration.md.
  • Release notes in the session - when a launch finds a newer cs, the cs-update mod (a function-hooks plugin the installer deploys beside the cs mod) opens one pane with the full changelog for every version above the installed one, once per load of the mod (a launch, or a plugin reload), in the conversation cs launched: 1 runs cs -update in place (the new files take effect on the next launch; the pane keeps the outcome until you close it), Esc closes it, and /cs-update brings it back. /config → cs-update.showReleaseNotes turns the launch pane off; /cs-update still works. CS_NO_FUNCTION_HOOKS=1 withholds both mods.
  • Works outside the cs launcher - a session is any directory containing .cs/, so the hooks find it whether cs <name> started the conversation or you opened the folder in a front end that cannot export environment into it — Claude Code desktop, an IDE, a plugin. A terminal is the exception, because there a session is entered by running cs: claude typed in a session directory stays cs-blind. cs still owns creating sessions and the launch experience (resume prompt, rotation menu, statusline, tmux spawner); what carries over is the documentation, narrative, timeline, autosave, and scope grounding. The session's recorded conversation stays with the cs launch, so a conversation opened another way — or a teammate claude working in the same folder — contributes to the session without becoming the one cs <name> resumes. When one of those is newer than the recorded conversation, the next launch says so and names it, rather than resuming the older one in silence:
  A newer conversation was opened here outside cs: 11111111-2222-4333-8444-555555555555
  Resuming the recorded one instead. To continue the newer: claude --resume 11111111-2222-4333-8444-555555555555

Drop .cs/local/disabled into a session to opt it out.

Prompt and writing aids

  • Prose hygiene - the prose-hygiene skill carries the full AI-slop taxonomy (phrases, structures, voice rules) that no regex can catch; /summary applies it with a subagent judge that scores .cs/summary.md and returns concrete rewrites. See skills/prose-hygiene/SKILL.md
  • Auto-grounded scope - On each code-work prompt, the scope-prompt hook injects a bounded context block — matching tracked files, recent commits, and a working-tree diff — grounding Claude in the current codebase before it acts. Capped at 8000 bytes; opt out per-session with CS_SCOPE_DISABLE=1. Each run also appends a stage trace to the machine-local .cs/local/scope-prompt.trace, so a run the hook's timeout kills leaves a trail naming the stage it hung on; on a slow machine the hook skips the scan once CS_SCOPE_BUDGET_MS (1500 ms) has passed and still delivers the digests and notes; opt out with CS_SCOPE_TRACE_DISABLE=1. The same hook asks Claude to question an ambiguous request rather than guess at it; skip one turn with a leading ~, or the session with CS_CLARIFY_DISABLE=1. It also names the day when it changes: a conversation that lives across midnight gets one line saying what today is and what date it last heard, and nothing on any other prompt; opt out with CS_DATE_REMINDER_DISABLE=1. See docs/hooks.md
  • Prompt rewriting - Type a rough prompt, press ctrl+g, and the composer holds a precise engineering request you can review, edit and send. cs points $EDITOR at a rewrite shim, so Claude Code's own external-editor round-trip does the substitution; nothing is sent on your behalf, and every failure leaves your text exactly as typed. Claude Code blanks the interface for the round-trip, so the shim fills that screen: your prompt held in a margin rule that breathes while the rewrite runs, the engine and model answering it, and the time left against the timeout — shown only where something actually enforces one. CS_REWRITE_PROGRESS picks the style — screen, a native line in Claude Code's own idiom, a bare centred line, or a static one-liner. Nothing interrupts a rewrite from the keyboard — the terminal sends ctrl+c to Claude Code too, ending the session — so the timeout is the only bound. CS_REWRITE_PROVIDER=openai or =gemini rewrites with that vendor instead: each prefers its CLI when the binary is on PATH (codex, agy, on your subscription) and falls back to the vendor's API when it is not. Append -api (gemini-api, openai-api) to reach the API past an installed CLI, which is roughly eight times faster and the only way to Gemini's lite tier; claude-api calls Anthropic's Messages endpoint rather than driving Claude Code. CS_REWRITE_PROVIDER=grok reaches xAI's OpenAI-compatible endpoint on XAI_API_KEY or GROK_API_KEY — API-only, since xAI ships no rewriter CLI to prefer — defaulting to grok-4.3, the fastest xAI model measured that never resolved an unspecified thing by fiat. CS_REWRITE_MODEL sets the model on every arm. Opt out with CS_REWRITE_DISABLE=1. See docs/hooks.md
  • Second opinion at a decision point - When claude-council or the codex plugin is present, the Stop hook appends a standing note naming what each one offers: /claude-council:advise, which puts the conversation itself to external models instead of a question you retype, and /codex:review before you call built work done. Claude judges whether a moment qualifies. It checks with you before running the council, because the digest goes to third-party providers, and it only offers /codex:review, which is yours to type. One note whatever you have installed, at most once every 30 minutes, and silent when you have neither. See docs/hooks.md
  • Voice drafting - /write-as-me drafts messages, replies, PR text, or docs in your own writing voice. On first use it distills your typed messages from Claude Code transcripts into an editable profile at ~/.claude-sessions/.voice/profile.md; drafting loads the profile and writes as you. Writing that lives outside the transcripts (an exported chat, sent mail) goes in ~/.claude-sessions/.voice/sources/*.md and every build appends it to the corpus.

Managing many sessions

  • Agent state - cs -live and the TUI's state row show what Claude Code says each session is doing right now — busy, waiting, idle — read from the per-session records Claude Code publishes under ~/.claude/sessions/. A record outlives a crash, so cs believes one only while its pid is alive and still reports the process start time the record holds; otherwise a recycled pid would keep a dead session looking busy. Hosts that publish no records (Claude Code before 2.1.224, or without jq for the shell reader) simply show no state
  • Cross-session search - cs -search <query> greps across all sessions' narrative, memory, and README
  • Health checks - cs -doctor reports status of Keychain backend, hook registration, shadow-ref freshness, auto-memory writability, status line registration, Claude Code settings audit (hooks/MCPs/permissions/env vars counts), cumulative token usage for the current project, whether the cs and cs-update mods ran, whether the session's migration stamp still lets opens skip the one-time migration checks, and an authority section listing every hook that injects into the model's context together with the switch that turns each one off
  • Usage attribution - cs -usage shows which sessions are consuming the 5-hour and weekly rate-limit windows: per-session input/output token sums (deduplicated by API request, cache-read excluded), anchored at the true reset boundaries when the cs status line is active. cs -usage <name> breaks one session down per conversation with a lifetime column. Both views fold in every subagent and workflow-agent transcript beneath a conversation, however deep Claude Code nests them, while the model column stays the conversation's own. A READS>350L column counts Read tool results over 350 lines as untargeted/all ~tokens: a read is targeted when the model's own Read call carried an offset or a limit, so a bare read the harness capped at its line limit still counts as untargeted, and the token figure is the untargeted characters at four per token, the share a size gate could have intercepted. Subagent transcripts keep the file text in the tool result itself rather than in a file record; the column counts both shapes. Reads made through Bash (cat, head) carry no file shape in the transcript and are not counted.
  • Session tags - cs -tag add api tags the current session in its README frontmatter (tags: [api] — the same field Obsidian indexes); cs -list --tag api filters the listing, and the picker filters live with #api in the search query (combining with fuzzy name search). Tags show in the preview card. The encrypted tag marks a session that keeps its notes in an encrypted volume (.cs/memory linked into the mount): the picker draws a lock beside its name and a vault line in the preview reading locked or unlocked, from whether .cs/memory resolves, and the status line draws the same lock after the session name. The lock is a Nerd Font glyph, drawn once this machine has confirmed its font (the status line's cs -statusline caps answer); until then both show enc.
  • Encrypted sessions - A session can keep its notes, Claude Code's config and cs's own files on an encrypted volume by linking four names under .cs/ into its mountpoint: memory, plans, claude-config (Claude Code's config dir for the session, so transcripts and prompt history never reach ~/.claude) and private (cs's own log, mail, queue, traces, rotation handoffs and checkpoints). While the volume is not mounted, cs refuses to open the session and writes nothing in plaintext in its place. With the volume mounted inside the session, cs -rm refuses to remove the session, and neither /finish nor cs -uninstall deletes through it. On macOS, cs -encrypt <name> sets this up for an existing session. Every open then asks for the password, and the volume unmounts when the session's Claude Code exits. Some files stay outside the vault; the docs list them. See docs/session-layout.md.
  • Session archive - cs -archive <name> drops a tracked .cs/archived marker that hides a finished session from the picker, cs -list, and cs -search (the marker syncs with the session, so archiving on one machine archives everywhere). cs -list --archived lists only archived sessions, cs -search <q> --include-archived searches them, and the picker toggles visibility with A (archived rows render dimmed) and archives or unarchives the selected session with a. Opening an archived session unarchives it.

Unattended and multi-agent work

  • Walk-away supervision - a draining queue is watched by circuit breakers: too many tool failures in one task (default 5, CS_QUEUE_MAX_FAILURES), context past 85% (CS_QUEUE_MAX_CTX), or the 5-hour rate-limit window past 85% (CS_QUEUE_MAX_5H) parks the queue with a debrief instead of feeding the next task — nothing is lost, cs -queue start re-arms. Everything that happened while you were away (tasks done, breaker trips) lands in a per-machine journal: a one-line digest surfaces once on your return, and cs -queue log shows the full history.
  • Cross-session mail - cs -msg <session> "note" drops a message in another session's machine-local mailbox (--kind notify|task|text|result; task also lands in its walk-away queue). Delivery is atomic — each message is its own file, written whole and renamed into place, so concurrent senders can never interleave. Bodies may be up to 64KB, and a lone - body reads from stdin (cs -msg <session> -). The recipient sees the unread bodies inlined into its context on every prompt until it reads them with cs -msg (bounded to 5, truncated; task kind shows a count-only label since it is already queued). Same-machine only; attribution is unauthenticated by design.
  • Threads - every message carries a thread id, and the sender keeps its own copy, so an exchange can be re-read from either end — including after a rotation, when an agent otherwise has no way to find out what it already said. cs -msg --reply <thread> "body" answers without naming the peer (it comes from the thread; naming a different one is an error, not an override), and cs -msg thread <id> prints the conversation ordered by what answers what — not by time, since a question and its reply usually land in the same whole second.
  • Mail wakes - unread mail takes a turn instead of waiting for a keystroke, so agent-to-agent work advances unattended. A session that just finished a turn is woken at that boundary; a session already parked at the prompt is woken by Claude Code's file watcher noticing the delivery, which arrives as a system-reminder rather than as synthesised typing. Either way the wake names who the new mail is from, so the woken session knows its correspondent before it opens the mailbox. Fires once per arrival, never for task kind (the queue owns those), never while a walk-away drain is running, and only in the launched conversation — not in teammates sharing the mailbox. Bounded by CS_MAIL_WAKE_MAX (default 5) wakes between prompts so two sessions cannot volley forever; CS_NO_MAIL_WAKE=1 silences it without swallowing the message.
  • tmux spawner - cs -spawn <name> opens a session in a cs-owned tmux session (tmux attach -t cs); --brief <file> hands it a brief it reads at its first turn (landing as the session's .cs/brief.md), --task "..." seeds and arms its walk-away queue so it starts working unattended, and the spawner hears back over cross-session mail when the queue drains. Same-machine only.
  • Features from inside a session - the feature skill (/feature fix-auth) writes a brief from the conversation and spawns <base>@fix-auth as a parallel worktree session with it, so a session can hand off a feature and keep working. The spawn keeps its permission prompt; /finish fix-auth lands the result.

Terminal experience

  • Status line - cs-statusline renders Claude Code's status bar as one line of rounded capsules on the terminal's own background: an identity capsule (the Claude mark, a darker coral (red in a 16-colour terminal) from the end of a turn until your next prompt; the session name; a queued-task count and an unread cross-session mail count when there are any, both read from .cs/local/; the git branch with ahead/behind and dirty counts; the model and its effort level in Claude Code's own /effort colours), a context capsule whose pie icon fills with the band, and a quota capsule: the 5-hour window always, the weekly window beside it from 50%, and on a Fable session its model window in a capsule of its own from 50% (each gaining a reset countdown as it fills) — branch, model, context and limits all from the status-line JSON plus one bounded git call every five seconds, with no transcript parsing; a warm render forks nothing but the interpreter and one jq, so it survives a loaded machine. Colour is state: amber ink past a warn threshold, a red capsule at crit. The rounded capsule ends are the one glyph that needs a patched font, so the installer shows a sample once and asks whether they render; until a machine has answered, the capsules have square ends (cs -statusline caps on|off|ask revisits it). On a Fable session it folds Fable's own weekly window, which is model-scoped and so appears in none of the rate limits Claude Code puts on stdin, into the same rule; that one figure is fetched out of band into a machine-global cache, never from the render, and only while Fable is the active model — using Claude Code's own credential, which cs reads and never writes. It writes two machine-local files as it renders — .cs/local/context-pct and .cs/local/limits — which is what makes the liveness heartbeat and cs -usage's reset anchoring work. Session cost is available as an opt-in segment. Enable or remove it any time with cs -statusline enable|disable; choose and order segments with CS_STATUSLINE_SEGMENTS. cs auto-detects the terminal's light/dark theme (override with CS_TERM_THEME; cs -detect-theme shows the result). A companion cs-subagent-statusline styles the agent-panel rows so each running subagent shows the model driving it, its own context %, and elapsed time; cs -statusline enable registers both (Claude Code reads the registration at startup, so restart it to see them). See docs/statusline.md

cs-statusline: the identity and context capsules, amber ink past a warn threshold, a red capsule at crit

  • iTerm2 awareness - inside iTerm2 the session color tints the tab (native escapes, reset on exit), and with iTerm2 shell integration installed a finished turn bounces the dock until your next prompt. Under tmux, the tab also shows Claude Code's progress line while a turn runs and the Claude icon next to the title. CS_NO_ITERM2=1 disables the bounce, the progress line and the icon; cs -doctor reports the integration surface.

Security and trust

  • Secure secrets handling - Store sensitive data in the OS keychain (value read from stdin, never written to a file); exportable as age-encrypted files for backup
  • Bash command audit trail - Every Bash command Claude runs is logged to .cs/local/session.log (machine-local, never git-synced) with timestamps
  • Update notifications - Checks for updates and notifies when new versions are available. When an update is pending, cs shows the release notes for every version above the installed one: the cs-update mod's pane inside the session (see Release notes in the session above), a compact summary card in the launch banner only when CS_NO_FUNCTION_HOOKS=1 withholds that mod, and the full notes under cs -update --check.
  • Verified updates - Updates are
Source 1 files
hooks/register.tsx 267 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4// ABOUTME: cs-update mod: when a cs launch found a newer release, one pane per load with its release notes, `1` to install it in place, Esc for later.
5// ABOUTME: Reads the launch's verdict from CS_UPDATE_AVAILABLE, the cs path from CS_BIN, and the span cs cached; /cs-update reopens the pane; session.start writes a heartbeat for doctor.
6import type { On, EngineInterface, PluginOptions } from 'claude-code'
7
8declare const h: any
9declare const Fragment: any
10
11export const PANE = 'cs-update'
12// The /config row (`cs-update.showReleaseNotes`): off, the launch pane is
13// skipped and /cs-update still opens it.
14export const OPTION = 'showReleaseNotes'
15// Doctor observes the mod RUNNING, not merely installed (see the cs mod).
16export const HEARTBEAT = '.cs/local/cs-update.heartbeat'
17// The finished pane's outcome, written on a clean `cs -update` exit and read
18// back on the reloaded module's next render: installing the update rewrites
19// this mod's own deployed file, Claude Code reloads it and immediately
20// re-renders the open pane, and the reload drops the module state the `done`
21// pane was drawing from before this file existed. `session.start` does not
22// fire on a reload, only at load, so the restore cannot wait for it alone.
23export const DONE = '.cs/local/cs-update.done'
24// Where cs writes the id of the conversation it launched; a teammate claude
25// in the same directory has its own id and must not pop its own pane.
26export const STATE = '.cs/local/state'
27// KEEP IN SYNC with check_update_notify in lib/20-update.sh: the file cs
28// caches the pending release's changelog span in, keyed by that version.
29export const NOTES = (home: string, version: string) => `${home}/.cache/cs/update-notes-full-${version}`
30// How long `cs -update` may take: the download, the checksum, the signature.
31export const UPDATE_TIMEOUT_MS = 600000
32
33export type Section = { version: string; lines: string[] }
34
35// Links, bold and inline code, as _md_strip_inline strips them in bash: the
36// link text survives a replace, but every `**` and every backtick is removed
37// outright, paired or not, so the pane never shows raw markup.
38export function stripInline(s: string): string {
39  return s
40    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
41    .replace(/\*\*/g, '')
42    .replace(/`/g, '')
43}
44
45// The span as check_update_notify caches it: `## X.Y.Z` opens a section; its
46// prose and bullets are kept in order with the marks stripped; `###` labels,
47// comments and blank lines are dropped. A continuation line keeps its indent.
48export function parseSpan(text: string): Section[] {
49  const sections: Section[] = []
50  for (const raw of text.split('\n')) {
51    const line = raw.replace(/\s+$/, '')
52    if (line.startsWith('## ')) { sections.push({ version: line.slice(3).trim(), lines: [] }); continue }
53    if (sections.length === 0) continue
54    if (line === '' || line.startsWith('#') || line.startsWith('<!--')) continue
55    sections[sections.length - 1].lines.push(stripInline(line))
56  }
57  return sections
58}
59
60// What the pane shows. Module state survives a /clear (as in the cs mod) and is
61// dropped on a reload, which is what "once per load" means. A finished update
62// is the one exception: the DONE marker survives the reload the update itself
63// causes, and the marker is redrawn from disk, either by session.start at the
64// next load or by the Pane render the reload triggers immediately, before
65// this state would otherwise sit empty.
66let version: string | undefined
67let sections: Section[] | undefined
68let accent: string | undefined
69let shown = false
70// The update key's life: idle until pressed, running while cs -update is,
71// then what happened. `failed` keeps the key, so a transient failure (a
72// download) gets another press; `done` retires it, since a second update
73// would install the same version again.
74let phase: 'idle' | 'running' | 'done' | 'failed' = 'idle'
75let outcome = ''
76let registered = false
77
78export function register(on: On, options: PluginOptions) {
79  version = undefined; sections = undefined; accent = undefined; shown = false
80  phase = 'idle'; outcome = ''
81  registered = false
82  const wanted = options[OPTION] !== false
83
84  on('session.start', async ($, e, next) => {
85    if (!registered) {
86      registered = true
87      await $.command.register({ name: 'cs-update', description: 'Release notes for the pending cs update, with 1 to install it.' })
88    }
89    if (await $.fs.exists(`${e.cwd}/.cs/local`)) {
90      await $.fs.write(`${e.cwd}/${HEARTBEAT}`, `${new Date().toISOString()}\n`)
91    }
92    if (!shown && await isLead($, e.cwd)) {
93      if (!(await restoreDone($, e.cwd)) && wanted) {
94        const pending = await $.env.get('CS_UPDATE_AVAILABLE')
95        if (pending) { shown = true; await openPane($, e.cwd, pending) }
96      }
97    }
98    return next(e)
99  })
100
101  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
102    if (e.requestId !== PANE) return next(e)
103    // The engine re-renders the open pane the instant it reloads this
104    // module, before session.start (which does not fire on a reload) gets a
105    // chance to restore it, so a pane asked to draw with no module state
106    // tries the marker itself before giving up to the ordinary gate.
107    if (version === undefined && !(await restoreDone($, await $.session.cwd()))) return next(e)
108    const { Box, Text, Button } = await $.ui.resolve(e)
109    // A lone pane draws no title of its own (the tab shows only with two or
110    // more), so the body opens with it. The pane does not scroll (measured
111    // live 2026-09-22 on Claude Code 2.1.278), and a long changelog span
112    // pushes anything below it off the bottom, so the keys sit right under
113    // the title, above the notes, where a long span can never hide them.
114    // Version headings take the session colour cs recorded, as the status
115    // bar's name does; a continuation line hangs under its bullet on the
116    // changelog's own indent, which parseSpan keeps in the line (as a bullet
117    // keeps its `- `), so the text is drawn verbatim and no margin doubles it.
118    return (
119      <Box flexDirection="column" paddingX={1}>
120        <Text bold>{`cs ${version} is available`}</Text>
121        <Box marginTop={1} flexDirection="column">
122          {phase === 'running' && <Text dimColor>{`updating… running cs -update ${version}`}</Text>}
123          {(phase === 'done' || phase === 'failed') && <Text>{outcome}</Text>}
124          {(phase === 'idle' || phase === 'failed') && (
125            <Box>
126              {/* a Button is a block: nested in a Text the engine refuses the whole tree (measured in the cs mod), so the keys stand in a Box */}
127              <Button key="cs-update-now" hotkey="1" plain label="update now" onPress={() => runUpdate($)} />
128              <Text dimColor>{'   Esc: later'}</Text>
129            </Box>
130          )}
131          {phase === 'idle' && <Text dimColor>{'Installs in place; the new files take effect on your next launch.'}</Text>}
132          {phase === 'done' && <Text dimColor>{'Esc: close'}</Text>}
133        </Box>
134        {sections === undefined || sections.length === 0
135          ? <Box marginTop={1}><Text>{`Release notes could not be fetched at launch; the update is ${version}.`}</Text></Box>
136          : sections.map(s => (
137              <Box key={`v-${s.version}`} flexDirection="column" marginTop={1}>
138                <Text bold color={accent}>{s.version}</Text>
139                {s.lines.map((line, j) => (
140                  <Box key={`l-${s.version}-${j}`}>
141                    <Text>{line}</Text>
142                  </Box>
143                ))}
144              </Box>
145            ))}
146      </Box>
147    )
148  })
149
150  // On demand: the same pane, whether or not the launch opened it (the
151  // option off, or dismissed). A registered command is answered with
152  // `{ text }` (the contract; an unanswered run prints "no hook answered"),
153  // so the pane carries the notes and the text just points at it. A launch
154  // that found nothing pending has nothing to show, and says so instead; a
155  // teammate (which inherits the exports) is refused as the launch pane
156  // refuses it.
157  on('command.run', { command: 'cs-update' }, async ($, e) => {
158    const cwd = await $.session.cwd()
159    if (!(await isLead($, cwd))) return { text: 'The release-notes pane belongs to the conversation cs launched.' }
160    const pending = await $.env.get('CS_UPDATE_AVAILABLE')
161    if (!pending) return { text: 'This launch found no newer cs; the check runs again at the next launch.' }
162    // After a reload (which drops the module state, and fires no
163    // session.start) a pane dismissed before the update installed leaves
164    // nothing open for the render path to restore, so the command asks the
165    // marker itself before it offers an install that already finished.
166    if (version === undefined && (await restoreDone($, cwd))) return { text: 'Release notes are in the side pane.' }
167    shown = true
168    await openPane($, cwd, pending)
169    return { text: 'Release notes are in the side pane.' }
170  })
171}
172
173// The conversation cs launched is the one whose id cs recorded before the
174// launch; a teammate in the same directory reads the same file and does not
175// match. A directory without the file is not a cs session: no pane. The id
176// may be quoted (KEEP IN SYNC with ownsRotation in mods/cs).
177async function isLead($: EngineInterface, cwd: string): Promise<boolean> {
178  if (await $.fs.exists(`${cwd}/.cs/local/disabled`)) return false
179  let state: string
180  try { state = await $.fs.read(`${cwd}/${STATE}`) } catch { return false }
181  const lead = state.match(/^claude_session_id: *"?([^"\s]+)"?[ \t]*$/m)?.[1]
182  return lead !== undefined && lead === (await $.session.id())
183}
184
185// The session colour cs recorded, for the version headings; none is fine.
186async function sessionColor($: EngineInterface, cwd: string): Promise<string | undefined> {
187  try {
188    const state = await $.fs.read(`${cwd}/${STATE}`)
189    return state.match(/^claude_session_color: *"?([^"\s]+)"?[ \t]*$/m)?.[1]
190  } catch { return undefined }
191}
192
193async function openPane($: EngineInterface, cwd: string, pending: string) {
194  version = pending
195  accent = await sessionColor($, cwd)
196  const home = await $.env.get('HOME')
197  let text = ''
198  try { text = home ? await $.fs.read(NOTES(home, pending)) : '' } catch { text = '' }
199  sections = parseSpan(text)
200  // focus is a request the surface grants only over an idle, empty composer;
201  // without it the keys stay with the prompt and `1` does nothing.
202  await $.ui.open({ id: PANE, title: `cs ${pending} is available`, focus: true, closeOnEscape: true })
203}
204
205// Restores the finished pane from the DONE marker on a reload, since the
206// reload itself drops the module state the pane was showing. Answers whether
207// it restored anything: false leaves the caller to its own fallback (the
208// launch-pane gate in session.start, or the ordinary render in ui.render). A
209// marker naming a version that is no longer CS_UPDATE_AVAILABLE is stale (a
210// later launch, nothing pending, or something newer) and is cleared rather
211// than redrawn; `$.fs` has no delete, so an empty write is the tombstone and
212// empty text reads as absent.
213async function restoreDone($: EngineInterface, cwd: string): Promise<boolean> {
214  let text: string
215  try { text = await $.fs.read(`${cwd}/${DONE}`) } catch { return false }
216  const [doneVersion, line] = text.split('\n')
217  if (!doneVersion || line === undefined) return false
218  const pending = await $.env.get('CS_UPDATE_AVAILABLE')
219  if (pending !== doneVersion) {
220    await $.fs.write(`${cwd}/${DONE}`, '')
221    return false
222  }
223  shown = true; phase = 'done'; outcome = line
224  await openPane($, cwd, doneVersion)
225  return true
226}
227
228// Runs the update cs would run from the shell, by the path launch exported
229// (no shell: `$.process.run` takes an argv, and the claude process's PATH is
230// not the launching shell's). The pane keeps the outcome until dismissed, so
231// it is read rather than flashed. The new files take effect on the next
232// launch for the rest of this claude, but Claude Code reloads this mod's own
233// file as soon as the update installs it, which is why a clean exit also
234// writes the DONE marker restoreDone reads back.
235async function runUpdate($: EngineInterface) {
236  // Claimed before the first await: two presses in one tick must not both
237  // pass the guard and start two installers.
238  if (phase === 'running' || phase === 'done') return
239  phase = 'running'; outcome = ''
240  $.ui.invalidate('ui.render')
241  try {
242    const bin = await $.env.get('CS_BIN')
243    if (!bin) {
244      phase = 'failed'; outcome = 'The launch did not say where cs is; run `cs -update` from a shell.'
245      $.ui.invalidate('ui.render'); return
246    }
247    const { exitCode, stderr } = await $.process.run([bin, '-update'], { timeoutMs: UPDATE_TIMEOUT_MS })
248    if (exitCode === 0) {
249      // Version-neutral: cs -update resolves the latest release when it runs,
250      // which may be newer than the one this launch saw.
251      phase = 'done'; outcome = 'Update finished. Takes effect on your next launch.'
252      try {
253        const cwd = await $.session.cwd()
254        await $.fs.write(`${cwd}/${DONE}`, `${version}\n${outcome}\n`)
255      } catch (err) {
256        $.ui.toast(`cs-update: could not record the finished update: ${String(err instanceof Error ? err.message : err)}`)
257      }
258    } else {
259      const tail = stderr.split('\n').filter(l => l.trim() !== '').slice(-5).join('\n')
260      phase = 'failed'; outcome = `cs -update exited ${exitCode}.\n${tail}`
261    }
262  } catch (err) {
263    phase = 'failed'; outcome = `cs -update did not run: ${String(err instanceof Error ? err.message : err)}`
264  }
265  $.ui.invalidate('ui.render')
266}
267