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

<img src="https://raw.githubusercontent.com/hex/claude-sessions/main/assets/banner.svg" width="100%" alt="cs: a session manager for Claude Code">
A session manager for Claude Code that creates isolated workspaces with automatic documentation.
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.
--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.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.<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.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, 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.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.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.
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.mdscope-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.mdctrl+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/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/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.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 statecs -search <query> greps across all sessions' narrative, memory, and READMEcs -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 offcs -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.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..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.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.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.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.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.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.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.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.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_NO_ITERM2=1 disables the bounce, the progress line and the icon; cs -doctor reports the integration surface..cs/local/session.log (machine-local, never git-synced) with timestampscs-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.hooks/register.tsx 1012 lines1/* @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