SLOPSHOPPER

cs

cs's in-session mod: the rotate band above the prompt, forced rotation past CS_ROTATE_FORCE_CTX, and the wrap key

newpanebandcommandtoastprompt
★ 45v0.1.0MITupdated 2026-10-07hex/claude-sessions/mods/cs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cs
› 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 › /queue ⎿ cs: The launch did not say where cs is (CS_BIN); run `cs -queue add "<task>"` from a shell in this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 1012 lines
1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4// ABOUTME: cs mod: keys above the prompt: rotate past the threshold, wrap up, or /clear once a handoff is armed.
5// ABOUTME: A turn ending past CS_ROTATE_FORCE_CTX (default 80, off disables) runs /rotate itself, then counts down to the /clear (session colour, amber, crit); session.start writes a heartbeat for doctor.
6// ABOUTME: /queue adds a task to the session's walk-away queue through `cs -queue add`, at once even mid-turn; bare, it prints `cs -queue list`. /finish toasts its start and outcome and, while its gate runs, draws a band with the elapsed time.
7import type { On, EngineInterface } from 'claude-code'
8
9declare const h: any
10declare const Fragment: any
11
12// KEEP IN SYNC with the ctx warn and crit defaults in bin/cs-statusline
13// (_seg_ctx): by default the band appears where the status bar turns amber and
14// the Stop hook gives its headroom notice. CS_STATUSLINE_CTX_WARN in the
15// process environment moves it, as it moves the bar; CS_ROTATE_BUTTON_CTX moves
16// the band alone. A value that is not a number is ignored.
17export const DEFAULT_PERCENT = 40
18
19// KEEP IN SYNC with _bg_shade and the `surface` arm of _sgr in
20// bin/cs-statusline: the band paints the bar's own capsule fill, a shade of the
21// terminal background nudged a tenth away from itself (darker on a light
22// terminal, lighter on a dark one), so the keys read as one more capsule of the
23// bar rather than as a row of the transcript. cs measures the background at
24// launch and exports it; without that measurement the band paints no fill, so a
25// guess can never leave the engine's own text on a surface it cannot read
26// against.
27export const SURFACE_SHIFT = 10
28
29export function surfaceColor(bg: string | undefined): string | undefined {
30  const parts = (bg ?? '').split(';')
31  if (parts.length !== 3) return undefined
32  const rgb = parts.map(part => (/^\s*\d{1,3}\s*$/.test(part) ? Number(part) : 256))
33  if (rgb.some(v => v > 255)) return undefined
34  const [r, g, b] = rgb
35  const shade = 2126 * r + 7152 * g + 722 * b >= 1275000
36    ? rgb.map(v => Math.floor(v * (100 - SURFACE_SHIFT) / 100))
37    : rgb.map(v => v + Math.floor((255 - v) * SURFACE_SHIFT / 100))
38  return `rgb(${shade.join(',')})`
39}
40
41// The count's colour ramp, in the status bar's inks. KEEP IN SYNC with _sgr in
42// bin/cs-statusline (tests/test_mod_rotate.sh pins every value here against
43// it): the session palette is Claude Code's /color, the tab colour cs sets;
44// amber and crit are the bar's warning and critical inks. Amber pivots on the
45// measured background's luminance (the ink pivot, not the surface one) and
46// falls back to the theme; crit follows the theme.
47export const SESSION_PALETTE: Record<string, string> = {
48  red: '220,38,38', blue: '106,155,204', green: '22,163,74', yellow: '202,138,4',
49  purple: '130,125,189', orange: '217,119,87', pink: '196,102,134', cyan: '8,145,178',
50}
51export const AMBER_LIGHT = '180,83,9'
52export const AMBER_DARK = '253,230,138'
53export const CRIT_LIGHT = '215,0,21'
54export const CRIT_DARK = '255,69,58'
55// Seconds left at which the count turns amber, and then crit (below CRIT_AT).
56export const AMBER_AT = 10
57export const CRIT_AT = 5
58// The pane's bar: one block per second of the grace.
59export const BAR_FULL = '\u2588'
60export const BAR_EMPTY = '\u2591'
61// The band's chip and caps, in the bar's inks. KEEP IN SYNC with _sgr in
62// bin/cs-statusline (tests/test_mod_rotate.sh pins them): brand is the Claude
63// coral the bar's mark wears, and white the chip's ink on a filled chip, softer
64// on a dark terminal as the bar's is.
65export const BRAND = '217,119,87'
66export const WHITE_LIGHT = '255,255,255'
67export const WHITE_DARK = '230,230,230'
68// The bar's rounded capsule ends, Powerline glyphs drawn in the fill they close.
69export const CAP_LEFT = '\ue0b6'
70export const CAP_RIGHT = '\ue0b4'
71
72// The colour the count wears with `left` seconds to go: the session's own
73// colour while there is time (none, so the surrounding ink, when the session
74// has no colour in the palette), amber from AMBER_AT, crit under CRIT_AT.
75export function countdownColor(left: number, session: string | undefined, bg: string | undefined, theme: string | undefined): string | undefined {
76  const dark = theme === 'dark'
77  if (left < CRIT_AT) return `rgb(${dark ? CRIT_DARK : CRIT_LIGHT})`
78  if (left <= AMBER_AT) {
79    const rgb = (bg ?? '').split(';').map(part => (/^\s*\d{1,3}\s*$/.test(part) ? Number(part) : NaN))
80    const measured = rgb.length === 3 && rgb.every(v => v <= 255)
81    const light = measured ? 2126 * rgb[0] + 7152 * rgb[1] + 722 * rgb[2] >= 1530000 : !dark
82    return `rgb(${light ? AMBER_LIGHT : AMBER_DARK})`
83  }
84  return paletteColor(session)
85}
86
87// A session colour name as an rgb() the engine paints, or undefined for a name
88// outside the palette (state hand-edited, or from a newer Claude Code).
89export function paletteColor(name: string | undefined): string | undefined {
90  const rgb = name === undefined ? undefined : SESSION_PALETTE[name]
91  return rgb === undefined ? undefined : `rgb(${rgb})`
92}
93
94// The pane's bar with `left` of GRACE_SECONDS still to run.
95export function countdownBar(left: number): string {
96  const full = Math.max(0, Math.min(GRACE_SECONDS, left))
97  return BAR_FULL.repeat(full) + BAR_EMPTY.repeat(GRACE_SECONDS - full)
98}
99
100// Doctor observes the mod RUNNING, not merely installed: under a managed
101// machine's policy a mod can load and never run. Written when the plugin loads
102// (process start or reload; session.start does not fire on /clear). Path is
103// relative to the session's cwd, which under cs is the session directory (or
104// its worktree).
105export const HEARTBEAT = '.cs/local/cs.heartbeat'
106// Written by /wrap's last pass: the conversation it wrapped.
107export const WRAPPED = '.cs/local/wrapped'
108
109// The rotate skill's last step writes the handoff's basename here; cs's
110// SessionStart hook reads it on the next conversation and starts the handoff's
111// next step. While it names a handoff the conversation has nothing left to do
112// but /clear, whatever the context reads. An encrypted session keeps the
113// marker and its handoffs behind .cs/private (a link into its vault).
114export const MARKER = '.cs/local/pending-handoff'
115export const HANDOFFS = '.cs/handoffs'
116export const PRIVATE_MARKER = '.cs/private/pending-handoff'
117export const PRIVATE_HANDOFFS = '.cs/private/handoffs'
118
119// The conversation a forced rotation already ran /rotate for, by id. Written
120// BEFORE the run is scheduled: a rotation that fails must not be retried at
121// the end of every turn. Module state would not do: it survives a /clear
122// (measured; a timer started before one kept firing after it) and is lost
123// on a reload of the mod.
124export const FORCED = '.cs/local/cs.forced'
125
126// Once the forced rotation has armed its handoff, how long the band counts
127// down before the mod runs the /clear itself. Pressing the button or sending a
128// prompt stops it.
129export const GRACE_SECONDS = 20
130// The percentage a turn must end past for the mod to rotate on its own. Above
131// the 65% nudge, so the ladder stays suggest -> offer -> force, and below where
132// Claude Code's own auto-compact lands, so a handoff is written while the
133// conversation is still whole.
134export const FORCE_DEFAULT = 80
135
136// The pane the first grace of a session opens beside the band: what the
137// handoff will do next, read while the count runs. It carries no keys, so
138// stopping the count stays on the band, and it closes whenever the count ends.
139// Shown once per load of the mod: later graces keep to the band. A pane the
140// mod opens on its own is not drawn below 144 columns (110 once the person has
141// opened it themselves), so on a narrow terminal the band is all there is.
142export const PREVIEW_PANE = 'cs-rotate-handoff'
143// The most text a Markdown element takes: a longer one refuses the whole tree,
144// and the pane would draw nothing (KEEP IN SYNC with MarkdownProps.text in
145// the mods type contract).
146export const MARKDOWN_LIMIT = 10000
147
148// The wrap key's question, and the answer that runs /wrap. `$.ui.ask` opens
149// the engine's own AskUserQuestion dialog and resolves to the label chosen, or
150// to free text typed under Other, so the answer is compared exactly; it rejects
151// when the dialog is dismissed and in a `-p` run, where there is nobody to ask.
152export const WRAP_QUESTION = 'Run /wrap for this session?'
153export const WRAP_YES = 'Yes, wrap up'
154
155// Bare /queue's offer, and the prompt a Start sends when no turn is running.
156// `cs -queue start` only arms the queue: the Stop hook hands over each task as
157// a turn ends, so an idle session needs one turn to reach that first stop.
158export const QUEUE_START = 'Start'
159export const QUEUE_COMPACT = 'Compact'
160// How much of a task a toast shows: a queued one, or the feature /finish lands.
161const TOAST_TASK_CHARS = 60
162
163function cutTask(task: string): string {
164  return task.length > TOAST_TASK_CHARS ? `${task.slice(0, TOAST_TASK_CHARS)}…` : task
165}
166
167// /finish's progress, as cs records it while `cs <base> -integrate-feature`
168// and `-retire-feature` run (_finish_progress_write in lib/30-worktree.sh):
169// one record, replaced whole at each step, behind .cs/private in an encrypted
170// session and in .cs/local otherwise. pid is the cs process that wrote it.
171export const FINISH_RECORD = 'finish-progress.json'
172// How often the watch /finish starts reads the record.
173export const FINISH_POLL_MS = 1000
174export type FinishRecord = {
175  id: string
176  pid: number
177  task: string
178  sha: string
179  step: string
180  gate_started?: number
181  result?: string
182  reason?: string
183}
184const FINISH_OUTCOMES = new Set(['landed', 'refused', 'retired'])
185
186// Asked after Start or Compact: how the tasks run, as the word cs -queue start
187// takes for each (none runs them in this conversation).
188export const QUEUE_MODE_QUESTION = 'How should the queued tasks run?'
189export const QUEUE_HERE = 'In this conversation'
190export const QUEUE_SUBAGENTS = 'In subagents'
191export const QUEUE_WORKFLOWS = 'As workflows'
192const QUEUE_MODE_WORDS: Record<string, string[]> = { [QUEUE_HERE]: [], [QUEUE_SUBAGENTS]: ['subagents'], [QUEUE_WORKFLOWS]: ['workflow'] }
193export const QUEUE_KICK = 'The cs walk-away queue is started. Reply with one short line saying so, then stop: the cs Stop hook hands you each queued task in turn.'
194
195// The countdown: seconds left, its ticker, and what the band last saw. Module
196// state survives a /clear (measured), so every path that ends the countdown
197// cancels the ticker; a reload of the mod drops it with its timers.
198let left: number | undefined
199let ticker: { cancel: () => void } | undefined
200let bandIdle = false
201// Whether a turn was running when the band last drew: a queue started then
202// reaches its first stop without a prompt from the mod.
203let turnRunning = false
204// The preview's lines while its pane is open, and whether this load has shown it.
205let preview: Step | undefined
206let previewShown = false
207
208// Which conversation the turns belong to, and whether it is being judged. A
209// conversation that begins past the force threshold did not get there by
210// working: forcing it would rotate again as soon as its successor woke
211// (measured at 1%). Only a conversation born of a /clear seen in this process
212// (`clearSeen`, then `birth`) is judged that way, by the context its first
213// turn ended with (`startPercent`). Any other new id (a launch, a reload, a
214// /resume at 72%) is adopted unjudged, since past the line is exactly where
215// the person asked to be rotated. Module state is kept across /clear, which
216// is what makes the birth visible.
217let adopted: string | undefined
218let clearSeen = false
219let birth: string | undefined
220let startPercent: number | undefined
221// Whether this load has registered /queue: session.start fires at load, and
222// registering the same name again would only replace it.
223let registered = false
224// The /finish watch: its timer, whether the turn that started it has ended,
225// which steps of which runs have been toasted (`<id>:started`,
226// `<id>:<outcome>`), the gate the band shows while one runs under a live pid,
227// and whether a problem reading the record or the pid has been said.
228let finishWatch: { cancel: () => void } | undefined
229let finishTurnOver = false
230let finishShown = new Set<string>()
231let finishGate: { task: string; since: number } | undefined
232let finishProblemShown = false
233
234export function register(on: On) {
235  // A (re)load has no countdown: the engine cancelled the old one's timers.
236  left = undefined; ticker = undefined; bandIdle = false; turnRunning = false; preview = undefined; previewShown = false; adopted = undefined; clearSeen = false; birth = undefined; startPercent = undefined; registered = false
237  finishWatch = undefined; finishTurnOver = false; finishShown = new Set(); finishGate = undefined; finishProblemShown = false
238  on('session.start', async ($, e, next) => {
239    if (!registered) {
240      registered = true
241      await $.command.register({ name: 'queue', description: "Add a task to this cs session's walk-away queue, or list it.", argumentHint: '[task]', immediate: true })
242    }
243    // Only a cs session has .cs/local; anywhere else the mod stays silent.
244    const local = `${e.cwd}/.cs/local`
245    if (await $.fs.exists(local)) {
246      await $.fs.write(`${e.cwd}/${HEARTBEAT}`, `${new Date().toISOString()}\n`)
247    }
248    return next(e)
249  })
250
251  // `/queue <task>` runs `cs -queue add` by the path the launch exported;
252  // `/queue` alone runs `cs -queue list`. The child inherits the claude
253  // process's environment, and CLAUDE_SESSION_META_DIR there picks the queue.
254  // Registered immediate, so the hook may run while a turn streams: it reads
255  // nothing of the turn. cs is the judge of the task: whatever it refuses
256  // (an empty or multi-line body) comes back as its own stderr.
257  on('command.run', { command: 'queue' }, async ($, e) => {
258    const task = e.args.trim()
259    const argv = task === '' ? ['-queue', 'list'] : ['-queue', 'add', e.args]
260    const bin = await $.env.get("CS_BIN")
261    if (!bin) return { text: 'The launch did not say where cs is (CS_BIN); run `cs -queue add "<task>"` from a shell in this session.' }
262    const what = `cs ${argv.slice(0, 2).join(' ')}`
263    let result: { exitCode: number; stdout: string; stderr: string }
264    try {
265      result = await $.process.run([bin, ...argv])
266    } catch (err) {
267      return { text: `${what} did not run: ${String(err instanceof Error ? err.message : err)}` }
268    }
269    if (result.exitCode !== 0) {
270      const tail = result.stderr.split('\n').filter(l => l.trim() !== '').slice(-5).join('\n')
271      return { text: tail === '' ? `${what} exited ${result.exitCode}.` : `${what} exited ${result.exitCode}.\n${tail}` }
272    }
273    if (task !== '') {
274      // The command's text lands in the transcript, which a running turn
275      // scrolls past; the toast under the prompt confirms the add where the
276      // person is looking. One line, so a long task is cut.
277      $.ui.toast(`cs: queued: ${cutTask(task)}`)
278      return { text: `Queued: ${task}` }
279    }
280    const pending = /^Pending \((\d+)\)$/m.exec(result.stdout)
281    if (pending && !(await queueRunning($))) void offerToStart($, bin, Number(pending[1]))
282    return { text: result.stdout.trimEnd() }
283  })
284
285  // The end of a turn is the one moment a rotation can be started for the
286  // person: the answer is in, nothing runs. An aborted or errored turn is no
287  // place to start one, and a subagent's turn ends in the same event.
288  on('turn.complete', async ($, e, next) => {
289    if (e.agentId === undefined) finishTurnOver = true
290    if (e.reason === 'answer' && e.agentId === undefined) await forceRotation($)
291    return next(e)
292  })
293
294  // /finish is a skill: this fires when it is typed, and when `cs <base>
295  // -finish` launches a conversation on it. Its turn runs cs's integrate and
296  // retire, and the watch follows the record cs writes meanwhile, until that
297  // turn is over and nothing runs.
298  on('skill.prompt', { skill: 'finish' }, async ($, e, next) => {
299    finishTurnOver = false
300    if (!finishWatch) await watchFinish($)
301    return next(e)
302  })
303
304  // A prompt entering the session, from anywhere, means the conversation is
305  // not done with: the countdown stops and the prompt goes through untouched.
306  on('prompt.submit', async ($, e, next) => {
307    if (ticker) stopCountdown($)
308    return next(e)
309  })
310
311  // A /clear from anywhere else (typed, another plugin) ends the conversation
312  // the count belongs to, so the timer must not outlive it, and makes the
313  // next conversation a birth; a run the engine refuses makes nothing.
314  on('command.run', { command: 'clear' }, async ($, e, next) => {
315    clearSeen = true
316    if (ticker) stopCountdown($)
317    try {
318      return await next(e)
319    } catch (err) {
320      clearSeen = false
321      throw err
322    }
323  })
324
325  // A turn that starts from a prompt is work after any wrap that finished: the
326  // marker that wrap left no longer describes the conversation. A continuation
327  // (no prompt, as a Stop hook's feedback starts one) is the wrap's own turn.
328  on('turn.start', async ($, e, next) => {
329    if (e.text !== '') await clearWrapped($)
330    return next(e)
331  })
332
333  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
334    const drawn = await withGateBand($, e, await next(e))
335    // The band draws in a new conversation before any of its turns end, so
336    // the birth is settled here: a later /resume is not mistaken for it.
337    noteConversation(await $.session.id())
338    // A survey owns the band; a running turn cannot be rotated out of.
339    turnRunning = e.props.isWorking
340    bandIdle = !e.props.hasSurvey && !e.props.isWorking
341    if (!bandIdle) return drawn
342    const armed = await handoffArmed($)
343    const { context } = await $.session.usage()
344    const percent = context.percent
345    if (!armed && (percent === undefined || percent < (await threshold($)))) return drawn
346    if (!(await ownsRotation($))) return drawn
347    const wrapped = !armed && (await wrapFinished($))
348    const fill = surfaceColor(await $.env.get("CS_TERM_BG_RGB"))
349    const chords = await boundChords($)
350    const own = paletteColor(await sessionColor($))
351    // The chip is the band's identity, as the session name is the bar's: on
352    // the session colour, Claude coral once a handoff is armed so the /clear
353    // reads apart, and on the surface in the terminal's own ink for a session
354    // with no colour. The body keeps the surface either way, where the count's
355    // ramp keeps its contrast.
356    const chipFill = armed ? `rgb(${BRAND})` : own ?? fill
357    const chipInk = chipFill === fill
358      ? undefined
359      : `rgb(${(await $.env.get("CS_TERM_THEME")) === 'dark' ? WHITE_DARK : WHITE_LIGHT})`
360    // Caps close a fill on the terminal's own background, so they need both a
361    // fill and this machine's consent to the glyph.
362    const caps = fill !== undefined && chipFill !== undefined && (await capsWanted($))
363    const { Box, Text, Button } = await $.ui.resolve(e)
364    // The Buttons carry no hotkey: a bare digit pressed them from an empty
365    // composer, where a digit typed as the answer to a numbered question
366    // belongs. A chord bound to each Button's action presses it instead, and
367    // the engine draws a Button with no hotkey as its label alone, so the key
368    // is spelled beside it, and only when keybindings.json binds one.
369    const key = (action: string) => chords[action] !== undefined &&
370      <Box marginRight={1}><Text bold color={own}>{chords[action]}</Text></Box>
371    // One capsule in the status bar's idiom: the cs chip, then the keys on the
372    // bar's own fill, a blank line above them so the band reads apart from the
373    // transcript. The context percentage is the bar's to carry; the band does
374    // not repeat it. The keyed box lights coral under the pointer; the engine
375    // restyles it without running the hook. It starts two columns in, where
376    // Claude Code draws the bar and the mode line under the prompt.
377    return (
378      <Box flexDirection="column">
379        {drawn}
380        <Box marginTop={1}>
381          <Box key="cs-rotate-band" marginLeft={2}>
382            {caps && <Text color={chipFill}>{CAP_LEFT}</Text>}
383            <Box paddingX={1} backgroundColor={chipFill}><Text bold color={chipInk}>cs</Text></Box>
384            <Box key="cs-rotate-band-body" paddingX={1} backgroundColor={fill}>
385            {key(ROTATE_ACTION)}
386            {armed
387              ? <Button key="cs-rotate" action={ROTATE_ACTION} plain label="/clear and continue from the handoff"
388                        onPress={() => clearAndContinue($)} />
389              : <Button key="cs-rotate" action={ROTATE_ACTION} plain label="rotate this conversation"
390                        onPress={() => rotate($)} />}
391            {/* a Button is a block: nested in a Text the engine refuses the whole tree (measured), so the separator stands beside it */}
392            {!armed && !wrapped && <Text dimColor>{'  \u00b7  '}</Text>}
393            {!armed && !wrapped && key(WRAP_ACTION)}
394            {!armed && !wrapped && <Button key="cs-wrap" action={WRAP_ACTION} plain label="wrap up this session" onPress={() => askToWrap($)} />}
395            {/* the forced rotation's grace: the seconds left before the mod runs the /clear itself */}
396            {armed && left !== undefined && <Text dimColor>{'  \u00b7  '}</Text>}
397            {armed && left !== undefined && <Text bold color={await rampColor($, left)}>{`/clear in ${left}s`}</Text>}
398            </Box>
399            {caps && <Text color={fill}>{CAP_RIGHT}</Text>}
400          </Box>
401        </Box>
402      </Box>
403    )
404  })
405
406  // The preview's body: the handoff's next step and the count. Any other pane,
407  // or this one once the count has ended, is not the mod's to draw.
408  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
409    if (e.requestId !== PREVIEW_PANE || preview === undefined) return next(e)
410    const { Box, Text, Markdown } = await $.ui.resolve(e)
411    // With one pane open the engine draws no title, so the pane carries its
412    // own, in the session's colour; the step is drawn as a reply's markdown is.
413    const own = paletteColor(await sessionColor($))
414    const color = left === undefined ? undefined : await rampColor($, left)
415    return (
416      <Box flexDirection="column" paddingX={1}>
417        <Text key="header" bold color={own}>Handoff</Text>
418        <Box flexDirection="column" marginTop={1}>
419          <Markdown key="step" text={preview.text} />
420          {preview.cut && <Text key="cut" dimColor>… the rest of the step is in the handoff</Text>}
421        </Box>
422        {left !== undefined && (
423          <Box flexDirection="column" marginTop={1}>
424            <Box>
425              <Text key="bar" color={color}>{countdownBar(left)}</Text>
426              <Text>{'  '}</Text>
427              <Text key="count" bold color={color}>{`/clear in ${left}s`}</Text>
428            </Box>
429            <Text dimColor>press 1 to clear now, or send a prompt to stay</Text>
430          </Box>
431        )}
432      </Box>
433    )
434  })
435}
436
437function numberOr(raw: string | undefined, fallback: number): number {
438  return raw !== undefined && /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : fallback
439}
440
441// On at FORCE_DEFAULT unless CS_ROTATE_FORCE_CTX says otherwise: `off` (any
442// case) and `0` turn the forcing off, a percentage moves it, and anything else
443// — a typo — is the default rather than silence, because a value nobody can
444// read must not quietly disable a rotation the person is relying on.
445// `claude plugin validate` lists what a module reads, and a name it does not
446// spell is refused.
447async function forceThreshold($: EngineInterface): Promise<number | undefined> {
448  const raw = (await $.env.get("CS_ROTATE_FORCE_CTX"))?.trim()
449  if (raw === undefined || raw === '') return FORCE_DEFAULT
450  if (raw.toLowerCase() === 'off' || /^0+$/.test(raw)) return undefined
451  return /^\d+$/.test(raw) ? Number(raw) : FORCE_DEFAULT
452}
453
454// A conversation id the mod has not met yet is the current one from here on,
455// and the call says so. It is a birth, to be judged by its first turn's end,
456// only when a /clear was seen since the last id; a /resume, a launch or a
457// reload adopt unjudged, and an earlier judgment of the same id is dropped
458// with them.
459function noteConversation(id: string): boolean {
460  if (id === adopted) return false
461  adopted = id
462  startPercent = undefined
463  birth = clearSeen ? id : undefined
464  clearSeen = false
465  return true
466}
467
468// Runs /rotate for the person once a turn ends past the force threshold, once
469// per conversation, from a timer: the contract refuses `$.command.run` inside
470// a hook the turn is waiting on, and a `clock.after` callback runs once the
471// hook has returned (measured: a 0 ms timer scheduled in turn.complete ran the
472// command). A rejected run is not retried; the button stays for the person.
473async function forceRotation($: EngineInterface) {
474  const force = await forceThreshold($)
475  if (force === undefined) return
476  if (!(await ownsRotation($))) return
477  const id = await $.session.id()
478  const { context } = await $.session.usage()
479  noteConversation(id)
480  if (birth === id) {
481    birth = undefined
482    startPercent = context.percent
483    if (startPercent !== undefined && startPercent >= force) {
484      $.ui.toast(`cs: CS_ROTATE_FORCE_CTX=${force} is below this conversation's starting context (${startPercent}%); not forcing a rotation`)
485    }
486  }
487  if (await handoffArmed($)) {
488    if (!ticker) startCountdown($)
489    return
490  }
491  if (startPercent !== undefined && startPercent >= force) return
492  if (context.percent === undefined || context.percent < force) return
493  const forced = `${await $.session.cwd()}/${FORCED}`
494  if ((await $.fs.exists(forced)) && (await $.fs.read(forced)).trim() === id) return
495  await $.fs.write(forced, `${id}\n`)
496  $.clock.after(0, () => {
497    rotate($).catch(err => $.ui.toast(`cs: /rotate did not run: ${String(err)}`))
498  })
499}
500
501// Counts the band down, one redraw a second (the contract folds calls past
502// ten a second). Each redraw re-reads the marker and the handoff: the count
503// must see the press and the prompt that stop it, so nothing here is cached.
504// At zero the /clear runs only where the band would draw the button: the band
505// idle, the handoff still armed, this the lead; otherwise the count stops and
506// the button stays for the person.
507function startCountdown($: EngineInterface) {
508  left = GRACE_SECONDS
509  if (!previewShown) {
510    previewShown = true
511    // From a timer: the count starts inside the turn's own hook.
512    $.clock.after(0, () => openPreview($))
513  }
514  ticker = $.clock.every(1000, async () => {
515    // Nothing to count once stopped, and nothing below zero: a period that
516    // lands while the zero tick is still reading leaves the count where it is.
517    if (left === undefined || left <= 0) return
518    left -= 1
519    $.ui.invalidate('ui.render')
520    if (left > 0) return
521    // The count holds at zero through the reads below: a prompt or a press
522    // landing meanwhile stops it (left becomes undefined), and this tick
523    // then does nothing, so nothing clears twice or behind a new turn.
524    const idle = bandIdle && (await handoffArmed($)) && (await ownsRotation($))
525    if (left !== 0) return
526    stopCountdown($)
527    if (idle) await clearAndContinue($).catch(err => $.ui.toast(`cs: /clear did not run: ${String(err)}`))
528  })
529}
530
531function stopCountdown($: EngineInterface) {
532  ticker?.cancel()
533  ticker = undefined
534  left = undefined
535  if (preview !== undefined) {
536    preview = undefined
537    $.ui.close({ id: PREVIEW_PANE }).catch(err => $.ui.toast(`cs: the handoff pane did not close: ${String(err)}`))
538  }
539  $.ui.invalidate('ui.render')
540}
541
542// Opens the preview for the count that scheduled it, if that count still runs.
543async function openPreview($: EngineInterface) {
544  if (ticker === undefined) return
545  const text = await armedHandoff($)
546  if (text === undefined || ticker === undefined) return
547  preview = nextStep(text)
548  await $.ui.open({ id: PREVIEW_PANE, title: 'Handoff' })
549  // The count can end while the open is in flight, and its close may reach the
550  // engine first: a pane that lands after its count is closed here, since
551  // nothing else will close it.
552  if (preview === undefined) {
553    await $.ui.close({ id: PREVIEW_PANE }).catch(err => $.ui.toast(`cs: the handoff pane did not close: ${String(err)}`))
554  }
555}
556
557// A handoff's next step as the pane shows it: the section's markdown, and
558// whether MARKDOWN_LIMIT cut it short.
559export type Step = { text: string; cut: boolean }
560
561// The handoff's Next Step section (`# Next Step`, `## 1. Next Step`) as
562// written, blank lines and all, trimmed of the blank lines around it; empty
563// when it has none. Past MARKDOWN_LIMIT it keeps the whole lines that fit.
564export function nextStep(text: string): Step {
565  const lines = text.split('\n')
566  const start = lines.findIndex(line => /^#+\s*(\d+\.\s*)?next step\s*$/i.test(line.trim()))
567  if (start < 0) return { text: '', cut: false }
568  const body: string[] = []
569  // A `#` line inside a fenced block (a shell comment in a command block) is
570  // the step's text, not the next section.
571  let fence: string | undefined
572  for (const line of lines.slice(start + 1)) {
573    const marker = /^\s*(```|~~~)/.exec(line)?.[1]
574    if (marker !== undefined) fence = fence === undefined ? marker : fence === marker ? undefined : fence
575    else if (fence === undefined && /^#+\s/.test(line)) break
576    body.push(line.trimEnd())
577  }
578  const step = body.join('\n').replace(/^\n+/, '').trimEnd()
579  if (step.length <= MARKDOWN_LIMIT) return { text: step, cut: false }
580  // a line longer than the bound has no line end to stop at: cut at the bound
581  const end = step.lastIndexOf('\n', MARKDOWN_LIMIT)
582  return { text: end > 0 ? step.slice(0, end).trimEnd() : step.slice(0, MARKDOWN_LIMIT), cut: true }
583}
584
585// The band's own threshold; without one it is the bar's warn band, read the way
586// the bar reads it. Each variable's name is a literal: `claude plugin validate`
587// lists what a module reads, and a name it does not spell is refused.
588async function threshold($: EngineInterface): Promise<number> {
589  return numberOr(
590    await $.env.get("CS_ROTATE_BUTTON_CTX"),
591    numberOr(await $.env.get("CS_STATUSLINE_CTX_WARN"), DEFAULT_PERCENT),
592  )
593}
594
595// Armed means the marker names a handoff the SessionStart hook will accept
596// after the /clear: a bare basename (the hook rejects a separator), a file in
597// the store, and frontmatter that still says unconsumed. A marker an aborted
598// rotation left behind, or one naming a handoff since consumed, would offer a
599// /clear that lands in a conversation with nothing to continue from, so it
600// does not arm. One exists per render; the reads only while the marker is there.
601async function handoffArmed($: EngineInterface): Promise<boolean> {
602  return (await armedHandoff($)) !== undefined
603}
604
605// The armed handoff's text, read under the rule above; undefined when unarmed.
606// Each marker names a handoff in its own store only: the vault's marker never
607// arms from the plaintext store, nor the reverse. The marker is read rather
608// than probed for, as queueRunning does: a read follows the .cs/private link
609// the way the hook's own does, and a marker that cannot be read (absent, or
610// behind a locked vault) arms nothing.
611async function armedHandoff($: EngineInterface): Promise<string | undefined> {
612  const cwd = await $.session.cwd()
613  for (const [marker, store] of [[PRIVATE_MARKER, PRIVATE_HANDOFFS], [MARKER, HANDOFFS]]) {
614    let name: string
615    try {
616      name = (await $.fs.read(`${cwd}/${marker}`)).trim()
617    } catch {
618      continue
619    }
620    if (name === '' || /[/\\]/.test(name)) return undefined
621    try {
622      const text = await $.fs.read(`${cwd}/${store}/${name}`)
623      return isUnconsumed(text) ? text : undefined
624    } catch {
625      return undefined
626    }
627  }
628  return undefined
629}
630
631// The hook's own rule (_handoff_is_unconsumed in hooks/session-start.sh): a
632// frontmatter block opened by `---` on the first line and CLOSED by the next
633// `---`, carrying `status: unconsumed` between them. A file the closing line
634// never reaches (a truncated write) is not armed: the hook would refuse it,
635// and a /clear on it would land in a conversation with nothing to continue.
636export function isUnconsumed(text: string): boolean {
637  const lines = text.split('\n')
638  if (lines[0] !== '---') return false
639  let matched = false
640  for (const line of lines.slice(1)) {
641    if (line === '---') return matched
642    if (line === 'status: unconsumed') matched = true
643  }
644  return false
645}
646
647// The count's colour for this session and terminal. Each name is a literal:
648// `claude plugin validate` lists what a module reads.
649async function rampColor($: EngineInterface, secs: number): Promise<string | undefined> {
650  return countdownColor(secs, await sessionColor($), await $.env.get("CS_TERM_BG_RGB"), await $.env.get("CS_TERM_THEME"))
651}
652
653// The session's colour name as cs recorded it in state, or undefined.
654async function sessionColor($: EngineInterface): Promise<string | undefined> {
655  const state = await readState($)
656  return state?.match(/^claude_session_color: *"?([^"\s]+)"?[ \t]*$/m)?.[1]
657}
658
659// Whether this machine has said its font has the bar's cap glyphs: the
660// statusline's own rule (_caps_wanted in bin/cs-statusline, KEEP IN SYNC).
661// CS_STATUSLINE_CAPS=1 or 0 decides; otherwise the per-machine answer file
662// holding `on`. Unanswered, or unreadable, means square ends.
663async function capsWanted($: EngineInterface): Promise<boolean> {
664  const forced = await $.env.get("CS_STATUSLINE_CAPS")
665  if (forced === '1') return true
666  if (forced === '0') return false
667  const config = (await $.env.get("XDG_CONFIG_HOME")) || `${await $.env.get("HOME")}/.config`
668  try {
669    return (await $.fs.read(`${config}/cs/statusline-caps`)).split('\n')[0] === 'on'
670  } catch {
671    return false // no answer file: unanswered
672  }
673}
674
675// The engine actions the band's Buttons answer to. They carry no default
676// chord and no engine handler is mounted for them (measured on 2.1.291 and
677// 2.1.292), so the chord a person binds to one presses the Button. KEEP IN
678// SYNC with CS_ROTATE_WRAP_KEYS in lib/01-manifests.sh, which binds them.
679export const ROTATE_ACTION = 'strip:jump1'
680export const WRAP_ACTION = 'strip:jump2'
681
682// The chord keybindings.json binds to each action in the Global context, as
683// the person wrote it. Claude Code reads that file from CLAUDE_CONFIG_DIR, else
684// ~/.claude; an encrypted session's config dir holds a link to the shell's
685// file, so this reads the file Claude Code reads. An unreadable or malformed
686// file, or a key unbound with null, binds nothing.
687async function boundChords($: EngineInterface): Promise<Record<string, string>> {
688  const dir = (await $.env.get("CLAUDE_CONFIG_DIR")) || `${await $.env.get("HOME")}/.claude`
689  let doc: any
690  try {
691    doc = JSON.parse(await $.fs.read(`${dir}/keybindings.json`))
692  } catch {
693    return {} // no file, or not JSON: no chord to spell
694  }
695  const chords: Record<string, string> = {}
696  for (const block of Array.isArray(doc?.bindings) ? doc.bindings : []) {
697    if (block?.context !== 'Global' || typeof block.bindings !== 'object' || block.bindings === null) continue
698    for (const [chord, action] of Object.entries(block.bindings)) {
699      if (typeof action === 'string' && chords[action] === undefined) chords[action] = chord
700    }
701  }
702  return chords
703}
704
705async function readState($: EngineInterface): Promise<string | undefined> {
706  try {
707    return await $.fs.read(`${await $.session.cwd()}/.cs/local/state`)
708  } catch {
709    return undefined // no state: not a session cs launched
710  }
711}
712
713// Only the lead conversation of a cs session may be offered a rotation. The
714// rotate skill refuses outside a cs session, .cs/local/disabled opts a
715// directory out of cs entirely, and the handoff it writes carries the UUID in
716// .cs/local/state, which belongs to the one conversation cs launched: a
717// teammate claude in the same directory would arm the lead's marker under the
718// lead's identity. The checks run only once the band has a button to draw.
719// The value may be quoted and may carry trailing spaces, as cs's own state
720// readers allow.
721async function ownsRotation($: EngineInterface): Promise<boolean> {
722  const local = `${await $.session.cwd()}/.cs/local`
723  if (!(await $.fs.exists(local)) || (await $.fs.exists(`${local}/disabled`))) return false
724  const state = await readState($)
725  if (state === undefined) return false
726  const lead = state.match(/^claude_session_id: *"?([^"\s]+)"?[ \t]*$/m)?.[1]
727  return lead !== undefined && lead === (await $.session.id())
728}
729
730// Runs the rotate skill as if the person had typed /rotate: the skill draws
731// the purpose from the conversation itself.
732async function rotate($: EngineInterface) {
733  await $.command.run({ command: 'rotate', args: '' })
734}
735
736// /wrap's last pass writes WRAPPED naming the conversation it ran in
737// (CLAUDE_CODE_SESSION_ID, so a teammate's wrap names the teammate), so a wrap
738// that finished is the conversation's latest act and the key has nothing to
739// offer. The next turn started from a prompt clears it. A summary written any
740// other way proves nothing.
741async function wrapFinished($: EngineInterface): Promise<boolean> {
742  const marker = await readWrapped($)
743  return marker !== '' && marker === (await $.session.id())
744}
745
746async function clearWrapped($: EngineInterface) {
747  const marker = await readWrapped($)
748  if (marker === '' || marker !== (await $.session.id())) return
749  await $.fs.write(`${await $.session.cwd()}/${WRAPPED}`, '').catch(() => {})
750}
751
752async function readWrapped($: EngineInterface): Promise<string> {
753  try {
754    return (await $.fs.read(`${await $.session.cwd()}/${WRAPPED}`)).trim()
755  } catch {
756    return '' // no wrap has finished here
757  }
758}
759
760// `2` asks before running /wrap: it replaces .cs/summary.md and runs two Opus
761// passes before the narrative rotation, so a key that may have been meant for
762// the composer opens the engine's own dialog rather than acting on the press.
763async function askToWrap($: EngineInterface) {
764  let answer: string
765  try {
766    answer = await $.ui.ask(WRAP_QUESTION, { header: 'Wrap', options: [WRAP_YES, 'Not now'] })
767  } catch {
768    return // dismissed, or a `-p` run with nobody to ask
769  }
770  if (answer !== WRAP_YES) return
771  try {
772    await $.command.run({ command: 'wrap', args: '' })
773  } catch (err) {
774    $.ui.toast(`cs: /wrap did not run: ${String(err)}`)
775  }
776}
777
778// An armed or draining queue is already on its way; only bin/cs and the Stop
779// hook write the file, and no file is an idle queue.
780// An encrypted session keeps its queue behind .cs/private (a link into its
781// vault), every other session in .cs/local. A file that cannot be read (absent,
782// or behind a locked vault) says nothing about the queue.
783async function queueRunning($: EngineInterface): Promise<boolean> {
784  const cwd = await $.session.cwd()
785  for (const dir of ['private', 'local']) {
786    let state: string
787    try {
788      state = (await $.fs.read(`${cwd}/.cs/${dir}/queue.state`)).trim()
789    } catch {
790      continue
791    }
792    return state === 'armed' || state === 'draining'
793  }
794  return false
795}
796
797// Asked after bare /queue has printed the list, so the tasks are on screen
798// when the question is. Start arms the queue; Not yet defers it the way the
799// Stop hook's own offer does, so that offer does not ask again straight away.
800// Compact compacts the conversation first, then starts as Start does; a
801// compaction that does not happen leaves the queue unarmed. Start and Compact
802// then ask how the tasks run, before any compaction, so a dismissed second
803// question starts and compacts nothing.
804async function offerToStart($: EngineInterface, bin: string, count: number) {
805  let answer: string
806  try {
807    answer = await $.ui.ask(`Start the ${count} queued ${count === 1 ? 'task' : 'tasks'} now?`, { header: 'Queue', options: [QUEUE_START, 'Not yet', QUEUE_COMPACT] })
808  } catch {
809    return // dismissed, or a `-p` run with nobody to ask
810  }
811  const starting = answer === QUEUE_START || answer === QUEUE_COMPACT
812  let mode: string[] = []
813  if (starting) {
814    let how: string
815    try {
816      how = await $.ui.ask(QUEUE_MODE_QUESTION, { header: 'Queue', options: [QUEUE_HERE, QUEUE_SUBAGENTS, QUEUE_WORKFLOWS] })
817    } catch {
818      return
819    }
820    const words = QUEUE_MODE_WORDS[how]
821    if (words === undefined) {
822      // Free text typed under "Other" names no mode cs knows.
823      $.ui.toast(`cs: '${how}' is not a way to run the queue; it is not started`)
824      return
825    }
826    mode = words
827  }
828  if (answer === QUEUE_COMPACT) {
829    let reason: string | undefined
830    try {
831      const compacted = await $.session.compact()
832      reason = 'skip' in compacted ? compacted.skip : undefined
833    } catch (err) {
834      reason = String(err instanceof Error ? err.message : err)
835    }
836    if (reason !== undefined) {
837      $.ui.toast(`cs: the conversation was not compacted (${reason}); the queue is not started`)
838      return
839    }
840  }
841  const verb = starting ? 'start' : 'defer'
842  let result: { exitCode: number; stdout: string; stderr: string }
843  try {
844    result = await $.process.run([bin, '-queue', verb, ...mode])
845  } catch (err) {
846    $.ui.toast(`cs: cs -queue ${verb} did not run: ${String(err instanceof Error ? err.message : err)}`)
847    return
848  }
849  if (result.exitCode !== 0) {
850    const tail = result.stderr.split('\n').filter(l => l.trim() !== '').slice(-1)[0] ?? ''
851    $.ui.toast(`cs: cs -queue ${verb} exited ${result.exitCode}${tail === '' ? '' : `: ${tail}`}`)
852    return
853  }
854  if (verb !== 'start' || turnRunning) return
855  try {
856    await $.prompt.submit({ text: QUEUE_KICK })
857  } catch (err) {
858    $.ui.toast(`cs: the queue is armed, but its first turn did not start: ${String(err instanceof Error ? err.message : err)}`)
859  }
860}
861
862// /clear ends this conversation, and cs's SessionStart hook then starts the
863// armed handoff's next step in the new one.
864// Measured: the run resolves once the screen has cleared and a new transcript
865// is open; the marker is the hook's to consume.
866async function clearAndContinue($: EngineInterface) {
867  clearSeen = true
868  if (ticker) stopCountdown($)
869  try {
870    await $.command.run({ command: 'clear', args: '' })
871  } catch (err) {
872    clearSeen = false
873    throw err
874  }
875}
876
877// Starts the /finish watch. Whatever the record holds now belongs to an
878// earlier run, so its steps count as shown: only what this /finish writes is
879// toasted.
880async function watchFinish($: EngineInterface) {
881  const before = await readFinish($)
882  if (before !== undefined) {
883    finishShown.add(`${before.id}:started`)
884    finishShown.add(`${before.id}:${before.step}`)
885  }
886  finishWatch = $.clock.every(FINISH_POLL_MS, () => followFinish($))
887}
888
889// One read of the record: toast a run's start and its outcome once each, keep
890// the band's gate while a gate runs under a live pid, and end the watch once
891// the turn is over with nothing running. A step whose pid is gone was left by
892// a run that was killed: it is over, whatever it says.
893async function followFinish($: EngineInterface) {
894  const record = await readFinish($)
895  let live = false
896  let gate: typeof finishGate
897  if (record !== undefined) {
898    const outcome = FINISH_OUTCOMES.has(record.step)
899    const key = `${record.id}:${outcome ? record.step : 'started'}`
900    if (!finishShown.has(key)) {
901      finishShown.add(key)
902      $.ui.toast(finishToast(record))
903    }
904    live = !outcome && (await pidAlive($, record.pid))
905    if (live && record.step === 'gate' && record.gate_started !== undefined) gate = { task: record.task, since: record.gate_started }
906  }
907  // While the band shows, every read redraws it: its elapsed time moves.
908  if (gate !== undefined || finishGate !== undefined) $.ui.invalidate('ui.render')
909  finishGate = gate
910  if (finishTurnOver && !live) {
911    finishWatch?.cancel()
912    finishWatch = undefined
913  }
914}
915
916// The toast for a record: the run starting, or its outcome.
917export function finishToast(record: FinishRecord): string {
918  const task = cutTask(record.task)
919  switch (record.step) {
920    case 'landed': return `cs: landed ${task} ${record.sha.slice(0, 7)} -> ${(record.result ?? '').slice(0, 7)}`
921    case 'refused': return `cs: /finish ${task} refused: ${record.reason ?? ''}`
922    case 'retired': return `cs: retired ${task}`
923    default: return `cs: finishing ${task}`
924  }
925}
926
927// The record as cs wrote it, from the vault's store first, as queueRunning
928// reads; undefined when there is none. One cs did not write (hand-edited, or
929// from another version) is said once and otherwise read as no record, since
930// the watch would only repeat the complaint every second.
931async function readFinish($: EngineInterface): Promise<FinishRecord | undefined> {
932  const cwd = await $.session.cwd()
933  for (const dir of ['private', 'local']) {
934    let text: string
935    try {
936      text = await $.fs.read(`${cwd}/.cs/${dir}/${FINISH_RECORD}`)
937    } catch {
938      continue // absent, or behind a locked vault
939    }
940    const record = parseFinish(text)
941    if (record === undefined) finishProblem($, `.cs/${dir}/${FINISH_RECORD} is not a record cs wrote; /finish progress is not shown`)
942    return record
943  }
944  return undefined
945}
946
947export function parseFinish(text: string): FinishRecord | undefined {
948  let r: unknown
949  try {
950    r = JSON.parse(text)
951  } catch {
952    return undefined
953  }
954  if (typeof r !== 'object' || r === null) return undefined
955  const o = r as Record<string, unknown>
956  if (typeof o.id !== 'string' || typeof o.task !== 'string' || typeof o.sha !== 'string' || typeof o.step !== 'string') return undefined
957  if (typeof o.pid !== 'number' || !Number.isInteger(o.pid) || o.pid <= 0) return undefined
958  if (o.gate_started !== undefined && typeof o.gate_started !== 'number') return undefined
959  if (o.step === 'landed' && typeof o.result !== 'string') return undefined
960  if (o.step === 'refused' && typeof o.reason !== 'string') return undefined
961  return {
962    id: o.id, pid: o.pid, task: o.task, sha: o.sha, step: o.step,
963    gate_started: o.gate_started as number | undefined,
964    result: typeof o.result === 'string' ? o.result : undefined,
965    reason: typeof o.reason === 'string' ? o.reason : undefined,
966  }
967}
968
969// Whether the cs process that wrote a step still runs: `kill -0` sends no
970// signal, it only asks. A check that cannot run is said once and counts as
971// not running, so a band never outlives what it stands for.
972async function pidAlive($: EngineInterface, pid: number): Promise<boolean> {
973  try {
974    return (await $.process.run(['kill', '-0', String(pid)])).exitCode === 0
975  } catch (err) {
976    finishProblem($, `cannot tell whether /finish is still running: ${String(err instanceof Error ? err.message : err)}`)
977    return false
978  }
979}
980
981function finishProblem($: EngineInterface, text: string) {
982  if (finishProblemShown) return
983  finishProblemShown = true
984  $.ui.toast(`cs: ${text}`)
985}
986
987// The time since a gate started, as the band shows it: 42s, 1m 05s.
988export function gateElapsed(secs: number): string {
989  if (secs < 60) return `${secs}s`
990  return `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s`
991}
992
993// The gate band under what the engine drew, while a /finish gate runs; the
994// drawing itself, untouched, at any other time. It draws mid-turn, since the
995// gate runs inside one, on the status bar's capsule fill as the rotation band.
996async function withGateBand($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], drawn: unknown) {
997  if (finishGate === undefined) return drawn
998  const { Box, Text } = await $.ui.resolve(e)
999  const fill = surfaceColor(await $.env.get("CS_TERM_BG_RGB"))
1000  const secs = Math.max(0, Math.floor((await $.clock.now()) / 1000) - finishGate.since)
1001  return (
1002    <Box flexDirection="column">
1003      {drawn}
1004      <Box marginTop={1}>
1005        <Box paddingX={1} backgroundColor={fill}>
1006          <Text>{`finishing ${cutTask(finishGate.task)} · gate ${gateElapsed(secs)}`}</Text>
1007        </Box>
1008      </Box>
1009    </Box>
1010  )
1011}
1012