Expose clauth profiles to the session: list/usage, switch, delegate.

<img src="media/clauth.png" alt="clauth: Claude Code account switcher and usage monitor TUI" width="480" />
<h1 align="center">Claude Code multi-account manager & MCP Plugin</h1>
<img src="https://cov.uwuclxdy.dev/badges/uwuclxdy/clauth/coverage.svg" alt="coverage" /> <img src="https://cov.uwuclxdy.dev/badges/uwuclxdy/clauth/ratio.svg" alt="code to test ratio" /> <img src="https://cov.uwuclxdy.dev/badges/uwuclxdy/clauth/time.svg" alt="test execution time" />
<a href="https://github.com/uwuclxdy/clauth/actions/workflows/release.yml"><img src="https://github.com/uwuclxdy/clauth/actions/workflows/release.yml/badge.svg" alt="Release build status" /></a> <a href="https://crates.io/crates/clauth"><img src="https://shields.uwuclxdy.dev/github/v/release/uwuclxdy/clauth?sort=semver&logo=rust&label=version&color=orange" alt="latest version" /></a> <a href="https://github.com/uwuclxdy/clauth/releases"><img src="https://shields.uwuclxdy.dev/github/downloads/uwuclxdy/clauth/total?label=downloads&color=blue" alt="GitHub release downloads" /></a> <img src="https://shields.uwuclxdy.dev/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2b90d9" alt="Linux, macOS, Windows" /> <a href="LICENSE"><img src="https://shields.uwuclxdy.dev/badge/license-MIT-green" alt="MIT license" /></a>
<a href="#features">Features</a> · <a href="#how-it-works">How it works</a> · <a href="#install">Install</a> · <a href="#quickstart">Quickstart</a> · <a href="#claude-code-plugin">Plugin</a> · <a href="#alternatives">Alternatives</a> · <a href="#faq">FAQ</a> · <a href="https://github.com/uwuclxdy/clauth/wiki">Wiki</a>
Juggle every Claude Code account from one terminal: switch in a keypress, track live 5h / 7d usage, auto-switch before a limit stops you, even hand a task to another account from inside Claude.
Most account tools do one half. clauth pairs instant switching between multiple Claude Code accounts with a live usage monitor, then wires the two together so a fallback chain moves you off an exhausted account before Claude Code ever blocks. Works with Claude Pro, Max, Team, Enterprise OAuth accounts or any custom API endpoint. Linux, macOS, Windows.

Font is kinda off on the recording, I promise it looks better than this.
clauth <name>: OAuth (Pro / Max / Team / Enterprise) or a custom API endpoint, plan tier detected for you5h / accounts apart instead of all at oncedaemon --listen, can hand its permission asks to a paired phoneclauth daemon runs the refresh and auto-switch loop with no TUI and publishes status.json for a menu-bar app to read, or serves to another machine over HTTPS with --listen: that feed, the account switch, the herdr panes with their terminal streams, Claude Code session history, prompts and key presses into a pane, and prompts and interrupts into a sessioncodex under it in its own CODEX_HOME, and let a separate codex chain rotate accounts between sessions (Codex)start --auto to pick the account by the models a session will run, shell completions, signed self-updates, multi-instance safeFull reference: the wiki.
Claude Code stores its session in ~/.claude/.credentials.json (OAuth tokens) and the env block of ~/.claude/settings.json (base URL, API key). clauth keeps a per-profile snapshot of both. A switch swaps those two in place and leaves the rest of ~/.claude/ untouched. clauth start takes a different route: it launches claude against a temporary ~/.claude mirror, so several accounts run at once.
Linux, macOS, Windows (Git Bash / MSYS2).
cargo install clauth
# no Rust toolchain needed; --nocargo forces a binary download
curl -fsSL https://raw.githubusercontent.com/uwuclxdy/clauth/mommy/install.sh | bash
Binary installs update themselves in the background, checksum and signature verified before anything is replaced; the Config tab's auto-update toggle turns that off, and CLAUTH_NO_UPDATE=1 overrides it. Cargo installs upgrade with cargo install clauth. On first launch clauth offers to install shell completions, asking before it touches your shell rc. More: Install.
Capture your current Claude Code login as a profile:
clauth capture work
or in the TUI: clauth, Setup tab, + new, then the + capture current login row.
Repeat while logged in to a different account, then switch in the TUI (<kbd>⏎</kbd> + confirm) or directly by name:
clauth work
# switched to 'work'
Run claude under a profile without touching the global config:
clauth start personal -- --model haiku
# spawns claude with personal's credentials in a per-profile CLAUDE_CONFIG_DIR
For a clean, blind session (auth only, no global memory, plugins, or hooks):
clauth start --isolated personal -p < prompt.txt
# pass the prompt on stdin: a variadic claude flag (e.g. --disallowedTools a,b,c)
# would otherwise swallow a trailing positional prompt forwarded through clauth
| Command | Does |
|---|---|
clauth | open the TUI |
clauth <profile> | switch and exit |
clauth start <profile> | run claude under that account, in its own config dir |
clauth login <profile> | add or re-authenticate an account, browser or API key; the browser login also takes a code from a link opened on any device (ssh, no browser) |
clauth list / clauth which | account table with cached usage / who owns this session |
clauth sessions, resume, info | browse past Claude Code sessions and resume one anywhere |
clauth daemon | headless refresh + auto-switch loop, optionally serving the REST API (--listen) |
Every command and flag: Quickstart.
The active profile shows in orange. Usage bars are cached locally, so they stay on screen even when the Anthropic API is rate-limited or offline. <kbd>←</kbd> <kbd>→</kbd> or <kbd>tab</kbd> / <kbd>⇧tab</kbd> move between the eight tabs, <kbd>⌥1</kbd>–<kbd>⌥9</kbd> jump straight to one, and <kbd>?</kbd> lists the keys for the tab you are on.
| Tab | What it holds |
|---|---|
| Overview | switch and reorder accounts |
| Usage | per-account window breakdown |
| Tokens | global Claude Code token stats, token speed + API-equivalent cost across all models |
| Setup | endpoint, key, env, auto-start, per-profile model routing, account presets |
| Fallback | chain editor |
| Config | appearance, scheduler, auto-switch defaults |
| Status | Claude incident feed |
| Services | the shunt gateway, delegates, the Claude Code plugin and herdr |
clauth ships a plugin that exposes your profiles to a live Claude Code session via MCP. Install it from the TUI: Services tab, plugin row, <kbd>f</kbd>, <kbd>⏎</kbd>, confirm. That drives Claude Code's own installer against a plugin tree clauth materializes locally, so there is nothing to add by hand. /plugin marketplace add uwuclxdy/clauth then /plugin install clauth@clauth works too; it registers the same plugin against this repo instead, and clauth re-points it at the local tree the next time it runs. Either way the plugin's tools are clauth mcp, so the binary has to be on your PATH.
A registration that breaks repairs itself: clauth mcp heals one at startup, so does the daemon's tick, and clauth start heals one before claude launches. That last one covers what a hook cannot, since a marketplace too broken to load means the plugin never loads and its hooks never fire.
| Tool | What it does | Quota |
|---|---|---|
profiles | every account with cached 5h/7d usage, provider, tier, live-session flag, observed throughput, and the account states worth a look before spending (disabled and no api key, both of which refuse a delegate; login expired, which refuses one except on an account that runs its own endpoint with its own key; a canceled subscription, which never refuses); scope: "session" names the account this session runs on | zero (disk cache) |
switch_profile | relink the global active profile; the reply says what it does to this session | zero |
delegate | hand a headless prompt to another account and return the answer (or a job_id) | real usage window on the target account |
monitor | check, collect or wait on backgrounded delegates' results, or wait on clauth's state (active profile, its usage cache, the credentials file) | zero (filesystem) |
delegate fields, kill and resume rules, the manual mcpServers entry: Claude Code plugin.
clauth is the only one of these that pairs account switching with a live usage monitor and ties them together with an auto-switch chain, in a single TUI.
| Tool | What it does | Compared to clauth |
|---|---|---|
| claude-swap | CLI account switcher (token backup/restore) | no usage view, no auto-switch |
| CCSwitcher, claude-account-switcher | macOS menu-bar switchers | macOS-only, no fallback chain |
| cc-account-switcher | credential-swap scripts | no TUI, no usage |
| Claude-Code-Usage-Monitor | real-time usage monitor with predictions | monitoring only, single account |
| claude-code-statusline | rate-limit status line inside Claude Code | in-session display, no switching |
CLAUDE_CONFIG_DIR by hand | manual per-account config dirs | what clauth start automates |
How do I switch between multiple Claude Code accounts without logging out? Install clauth, save each logged-in session as a profile once, then switch with clauth <name> or a single keypress in the TUI. No browser, no re-login.
Can I run Claude Code with multiple accounts at the same time? Yes. clauth start <profile> launches claude in an isolated CLAUDE_CONFIG_DIR, so parallel sessions don't share identity, settings, or billing caches.
How do I run Claude Code without my global CLAUDE.md, plugins, or hooks? clauth start --isolated <profile> keeps the account's auth but drops your CLAUDE.md, plugins, hooks, skills, MCP servers and tools, leaving a clean session for headless work or blind evals. The MCP delegate tool takes isolated: true for the same thing.
Can Claude Code switch accounts automatically when I hit the 5-hour limit? Yes: put accounts in the fallback chain and clauth switches to the next member with headroom the moment the active one crosses its threshold. It runs in the TUI or headless via clauth daemon.
Is there a Claude Code MCP server / plugin to switch accounts from inside a chat? Yes. clauth ships a plugin that runs as an MCP server (clauth mcp), so a live session can list accounts, switch_profile, or delegate a headless prompt to another account without leaving the chat.
How do I monitor Claude Code usage and rate limits? The Overview tab shows color-coded 5h (and 7-day) bars per account with reset times; the Usage tab breaks down every rate-limit window the API reports; the Tokens tab adds a global token dashboard with API-equivalent cost.
Does it work with Claude Pro, Max, Team, and Enterprise? Yes. OAuth profiles cover all paid tiers (plan auto-detected, including Max 5x / 20x). API-endpoint profiles cover the Anthropic API or any compatible proxy.
Where does clauth store my Claude Code credentials? Locally under ~/.clauth/, with 0600 permissions on Unix. Claude tokens only ever go to Anthropic, codex tokens only to OpenAI. See SECURITY.md.
More, including what to check when something misbehaves: FAQ.
| Page | Covers |
|---|---|
| Install | every install path, update verification, completions |
| Quickstart | first run, every command, flag, and env var |
| Interface and keys | the eight tabs, every keybinding, the action menus |
| Configuration | both TOML files key by key, model routing, storage layout |
| Auto-switch | thresholds, exclusion rules, burn-aware mode, spend ceilings |
| Daemon | clauth daemon, the REST API, and the status.json read contract |
| Claude Code plugin | the MCP server, delegate in full, answering a session from your phone |
| herdr plugin | the clauth popup in herdr, the key, the per-pane account tag |
| Tokens and cost | where the dashboard reads from, what the cost figure means |
| Codex | ChatGPT logins as codex profiles: capture, sessions, the codex chain |
| Security | where credentials live and how they move |
cargo build --release
cargo clippy --all-targets
cargo test
CI gates fmt --check, clippy -D warnings, the test suite, cargo-deny and cargo audit on every push to mommy and every pull request; a doc-only change is skipped.
[!TIP]
cargo test showcase -- --ignored --nocapturedrives the real interactive TUI on fake data against a throwaway home dir (no network, never compiled into the binary). Handy for screenshots.
clauth handles live OAuth tokens and replaces its own binary over the network, so SECURITY.md lays out the trust model: where credentials live, every host clauth contacts, how updates get verified, and how to switch each behavior off. Found something exploitable? Report it privately through the repo's Security → Report a vulnerability.
MIT
hooks/register.ts 601 lines1// One Claude Code session bridged to the clauth daemon over its same-user unix
2// socket, so a paired phone can answer the session's permission asks and steer
3// it without owning the desk's keystrokes.
4import { update } from 'claude-code'
5import type { EngineInterface, Register, Timer } from 'claude-code'
6
7// One event body, in bytes: past it the daemon refuses 413, so an ask whose
8// body would exceed it is never held (the engine's own dialog draws instead).
9const BODY_LIMIT = 1024 * 1024
10// The poll cycle. The daemon parks a poll up to 20 s and `$.http.fetch` aborts
11// at 30 s; one awaited fetch costs no hook budget, so the re-poll stays bounded
12// on both sides.
13const POLL_INTERVAL_MS = 1000
14// A poll error waits this long before the next poll, so a down daemon never
15// spins the loop.
16const POLL_BACKOFF_MS = 5000
17// The desk row's second line, leading text before the two permission buttons.
18const LINE2_LEAD = ' Allow this? Answer here or on your phone. '
19// The AskUserQuestion row's second line, leading text before the one button.
20const QUESTION_LINE2_LEAD = ' Answer on your phone, or here. '
21
22const SEQ = { plugin: 'clauth', key: 'seq' } as const
23const HELD = { plugin: 'clauth', key: 'heldRow' } as const
24
25const encoder = new TextEncoder()
26
27type HoldOutcome =
28 | { type: 'allow' | 'deny'; from: 'desk' | 'device' }
29 | { type: 'answers'; answers: Record<string, string> }
30 | { type: 'loss' }
31
32type HeldEntry = {
33 kind: 'decision' | 'answers'
34 tool: string
35 desc: string
36 turn: string | null
37 resolve: (o: HoldOutcome) => void
38}
39
40// Per-load runtime state. The only value a hot reload must keep, the event
41// sequence number, lives in `$.state` (SEQ); everything here is re-established
42// by the `session.start` that fires again on a reload.
43let inert = false
44let socketPath: string | null = null
45let sessionId: string | null = null
46let interactive = false
47let pollStarted = false
48let pollCancel: Timer | null = null
49let pollInFlight: Promise<void> | null = null
50let sendChain: Promise<unknown> = Promise.resolve()
51let nextPollAfter = 0
52let turnId: string | null = null
53let pendingPrompt: { text: string } | null = null
54let helds = new Map<string, HeldEntry>()
55let interruptedTurn: string | null = null
56let askCounter = 0
57
58const bodyBytes = (body: string): number => encoder.encode(body).length
59
60const oneLineJson = (value: unknown): string => {
61 try {
62 return JSON.stringify(value) ?? String(value)
63 } catch {
64 return String(value)
65 }
66}
67
68// The first non-empty line, with an ellipsis when more content follows, so a
69// multi-line command never grows the row past its two lines.
70const collapseLines = (s: string): string => {
71 const lines = s.replace(/\r\n?/g, '\n').split('\n')
72 let i = 0
73 while (i < lines.length && lines[i]!.trim() === '') i += 1
74 if (i >= lines.length) return ''
75 const first = lines[i]!
76 const more = lines.slice(i + 1).some(l => l.trim() !== '')
77 return more ? `${first}…` : first
78}
79
80// What line 1's `<what it acts on>` shows for a tool: Bash's command, a file
81// tool's path, else the input as one-line JSON.
82const describeInput = (tool: string, input: unknown): string => {
83 if (tool === 'Bash') return collapseLines((input as { command?: string })?.command ?? '')
84 if (tool === 'Write' || tool === 'Edit' || tool === 'Read') {
85 return (input as { file_path?: string })?.file_path ?? ''
86 }
87 return oneLineJson(input)
88}
89
90// The first question's text, the AskUserQuestion row's line 1 description.
91const firstQuestion = (input: unknown): string => {
92 if (Array.isArray(input)) {
93 const first = input[0] as { question?: unknown } | undefined
94 if (first && typeof first.question === 'string') return collapseLines(first.question)
95 }
96 return ''
97}
98
99function mintAskId(): string {
100 askCounter += 1
101 return `ask-${askCounter}-${Math.random().toString(36).slice(2, 10)}`
102}
103
104function bridgeUrl(path: string): string {
105 return `http://localhost${path}`
106}
107
108function fetchBridge($: EngineInterface, path: string, method: string, body?: string) {
109 return $.http.fetch(bridgeUrl(path), {
110 socketPath: socketPath as string,
111 method,
112 headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
113 body,
114 })
115}
116
117function nextSeq($: EngineInterface): Promise<number> {
118 return update($, SEQ, cur => (cur ?? 0) + 1)
119}
120
121// Runs `fn` as the next link of one ordered send chain: a link allocates its
122// seq and fetches together, so concurrent sends never reorder and a 409 from
123// the daemon's high-water mark cannot land on the wrong event. A rejected link
124// must not wedge the chain, so the tail swallows every link's error.
125function enqueueSend<T>(fn: () => Promise<T>): Promise<T> {
126 const link = sendChain.then(fn)
127 sendChain = link.then(
128 () => undefined,
129 () => undefined,
130 )
131 return link
132}
133
134async function postEvent(
135 $: EngineInterface,
136 kind: string,
137 fields: Record<string, unknown> = {},
138): Promise<void> {
139 if (inert || sessionId === null) return
140 await enqueueSend(async () => {
141 const seq = await nextSeq($)
142 const body = JSON.stringify({ session: sessionId, seq, kind, ...fields })
143 if (bodyBytes(body) > BODY_LIMIT) return
144 try {
145 await fetchBridge($, '/event', 'POST', body)
146 } catch {
147 // A refused or unreachable daemon must not stall the session: the event
148 // is best-effort, the daemon re-syncs from its own state.
149 }
150 }).catch(() => {})
151}
152
153// Posts `ask_opened` and reads the daemon's hold answer; null means the ask
154// must not be held (over-bound body, unreachable socket, or a non-ok reply).
155async function postAskOpened(
156 $: EngineInterface,
157 ask: string,
158 tool: string,
159 input: unknown,
160 agentId: string | undefined,
161): Promise<boolean | null> {
162 if (sessionId === null) return null
163 return enqueueSend(async () => {
164 const seq = await nextSeq($)
165 const fields: Record<string, unknown> = { ask, tool, input }
166 if (agentId !== undefined) fields.agent_id = agentId
167 const body = JSON.stringify({ session: sessionId, seq, kind: 'ask_opened', ...fields })
168 if (bodyBytes(body) > BODY_LIMIT) return null
169 try {
170 const res = await fetchBridge($, '/event', 'POST', body)
171 if (!res.ok) return null
172 const parsed = JSON.parse(res.text) as { hold?: boolean }
173 return parsed.hold === true
174 } catch {
175 return null
176 }
177 }).catch(() => null)
178}
179
180// The oldest held ask, the one the desk row shows; its buttons answer that ask.
181async function setHeldRow($: EngineInterface): Promise<void> {
182 const first = helds.entries().next().value as [string, HeldEntry] | undefined
183 const row = first
184 ? { ask: first[0], kind: first[1].kind, tool: first[1].tool, desc: first[1].desc }
185 : null
186 await $.state.set(HELD, row)
187}
188
189async function resolveHold(
190 $: EngineInterface,
191 ask: string,
192 outcome: HoldOutcome,
193 postClose: boolean,
194 aborted = false,
195): Promise<void> {
196 const entry = helds.get(ask)
197 if (!entry) return
198 helds.delete(ask)
199 const by =
200 aborted && interruptedTurn !== null && interruptedTurn === entry.turn ? 'interrupt' : 'desk'
201 try {
202 await setHeldRow($)
203 } catch {
204 // The drawing read is best-effort; the hold itself is over.
205 }
206 if (postClose) {
207 await postEvent($, 'ask_closed', { ask, by })
208 }
209 entry.resolve(outcome)
210}
211
212// The desk answered (a Button press): close the ask at the desk and resolve.
213function answerFromDesk($: EngineInterface, ask: string, decision: 'allow' | 'deny'): void {
214 void resolveHold($, ask, { type: decision, from: 'desk' }, true)
215}
216
217// A phone answer for an ask: the daemon already closed it, so just resolve.
218function answerFromDevice($: EngineInterface, ask: string, outcome: HoldOutcome): Promise<void> {
219 return resolveHold($, ask, outcome, false)
220}
221
222// The desk took the AskUserQuestion back: release the hold and hand the engine
223// its own question dialog.
224function releaseToDesk($: EngineInterface, ask: string): void {
225 void resolveHold($, ask, { type: 'loss' }, true)
226}
227
228async function handleAnswer($: EngineInterface, ask: string, cmd: Record<string, unknown>): Promise<void> {
229 const entry = helds.get(ask)
230 if (!entry) return
231 if (entry.kind === 'decision') {
232 // A permission ask releases only on a decision; the other form is ignored.
233 if (cmd.decision !== 'allow' && cmd.decision !== 'deny') return
234 await answerFromDevice($, ask, { type: cmd.decision, from: 'device' })
235 return
236 }
237 // An AskUserQuestion releases only on answers, each value a string, and at
238 // least one answer: an empty map holds nothing the engine can use.
239 if (cmd.answers === null || typeof cmd.answers !== 'object') return
240 const answers: Record<string, string> = {}
241 for (const [k, v] of Object.entries(cmd.answers as Record<string, unknown>)) {
242 if (typeof v !== 'string') return
243 answers[k] = v
244 }
245 if (Object.keys(answers).length === 0) return
246 await answerFromDevice($, ask, { type: 'answers', answers })
247}
248
249async function handleCommand($: EngineInterface, cmd: Record<string, unknown>): Promise<void> {
250 if (typeof cmd !== 'object' || cmd === null) return
251 switch (cmd.kind) {
252 case 'prompt':
253 if (typeof cmd.text === 'string') {
254 if (turnId !== null) {
255 pendingPrompt = { text: cmd.text }
256 } else {
257 try {
258 await $.prompt.submit({ text: cmd.text })
259 } catch {
260 // A refused submit while idle must not drop the prompt: the device
261 // already got 200, so keep it for the next turn.complete.
262 pendingPrompt = { text: cmd.text }
263 }
264 }
265 }
266 break
267 case 'interrupt':
268 if (turnId !== null) {
269 interruptedTurn = turnId
270 try {
271 await $.turn.abort({ turnId })
272 } catch {
273 // The abort was refused; the tag must not outlive the interrupt.
274 interruptedTurn = null
275 }
276 }
277 break
278 case 'answer':
279 if (typeof cmd.ask === 'string') {
280 await handleAnswer($, cmd.ask, cmd)
281 }
282 break
283 }
284}
285
286async function onPollError($: EngineInterface): Promise<void> {
287 const now = await $.clock.now()
288 nextPollAfter = now + POLL_BACKOFF_MS
289}
290
291// One timer poll: the session's only poll, carrying prompts, interrupts and
292// (redundantly) answers. A held check never pumps this loop — it waits on its
293// own `/wait` — so a poll error or a dropped remote flag here touches no hold.
294async function doPoll($: EngineInterface): Promise<void> {
295 try {
296 let res
297 try {
298 res = await fetchBridge($, `/poll?session=${encodeURIComponent(sessionId as string)}`, 'GET')
299 } catch {
300 await onPollError($)
301 return
302 }
303 if (!res.ok) {
304 await onPollError($)
305 return
306 }
307 let reply: { remote?: boolean; commands?: Record<string, unknown>[] }
308 try {
309 reply = JSON.parse(res.text) as typeof reply
310 } catch {
311 await onPollError($)
312 return
313 }
314 nextPollAfter = 0
315 if (reply.remote !== true) return
316 for (const cmd of reply.commands ?? []) {
317 try {
318 await handleCommand($, cmd)
319 } catch {
320 // A refused command must not drop the ones behind it in the reply.
321 }
322 }
323 } catch {
324 // A defect in the poll handler must not reject the pump loop.
325 await onPollError($).catch(() => {})
326 }
327}
328
329// The timer's single in-flight poll: at most one timer poll parks at a time.
330function pollOnce($: EngineInterface): Promise<void> {
331 if (pollInFlight) return pollInFlight
332 pollInFlight = doPoll($).finally(() => {
333 pollInFlight = null
334 })
335 return pollInFlight
336}
337
338// One wait on one ask: a single `/wait` fetch, parked at the daemon until that
339// ask settles. The awaited fetch pauses the hook budget, so a hold outlives the
340// 10 s bound. Every non-settle reads as a loss for this ask alone.
341async function waitOnce($: EngineInterface, ask: string): Promise<void> {
342 let res
343 try {
344 res = await fetchBridge(
345 $,
346 `/wait?session=${encodeURIComponent(sessionId as string)}&ask=${encodeURIComponent(ask)}`,
347 'GET',
348 )
349 } catch {
350 await resolveHold($, ask, { type: 'loss' }, true)
351 return
352 }
353 if (!res.ok) {
354 await resolveHold($, ask, { type: 'loss' }, true)
355 return
356 }
357 let reply: { remote?: boolean; answer?: Record<string, unknown> | null; closed?: boolean }
358 try {
359 reply = JSON.parse(res.text) as typeof reply
360 } catch {
361 await resolveHold($, ask, { type: 'loss' }, true)
362 return
363 }
364 if (reply.remote !== true) {
365 await resolveHold($, ask, { type: 'loss' }, true)
366 return
367 }
368 if (reply.answer !== null && reply.answer !== undefined) {
369 // A device answer, form-matched exactly as a poll answer is.
370 await handleAnswer($, ask, reply.answer)
371 if (helds.has(ask)) {
372 // The answer's form did not match this hold (a decision for a held
373 // question, a non-string or empty `answers`, ...): the daemon already
374 // closed the ask, so resolve it as a loss rather than re-waiting on a
375 // closed ask forever.
376 await resolveHold($, ask, { type: 'loss' }, true)
377 }
378 return
379 }
380 if (reply.closed === true) {
381 // Closed with no answer: the desk closed it (the press already resolved the
382 // hold, so resolveHold no-ops) or the session ended — either way a still-
383 // held ask resolves as a loss.
384 await resolveHold($, ask, { type: 'loss' }, true)
385 return
386 }
387 // The bound elapsed with the ask still open: wait again.
388}
389
390// Holds `ask` until it resolves (desk, phone, loss, or an abort), waiting on
391// that ask's own `/wait` so the hook budget stays paused on an awaited fetch.
392// The race against the outcome promise makes a desk press or an abort end the
393// wait without waiting out the parked fetch; its later reply is then ignored.
394async function holdUntilSettled(
395 $: EngineInterface,
396 ask: string,
397 kind: HeldEntry['kind'],
398 tool: string,
399 desc: string,
400 turn: string | null,
401 signal: AbortSignal | undefined,
402): Promise<HoldOutcome> {
403 let settled = false
404 const hp = new Promise<HoldOutcome>(resolve => {
405 // An abort resolves only this ask; a wait's loss also resolves only its own
406 // ask.
407 const onAbort = () => {
408 void resolveHold($, ask, { type: 'loss' }, true, true)
409 }
410 signal?.addEventListener('abort', onAbort, { once: true })
411 helds.set(ask, {
412 kind,
413 tool,
414 desc,
415 turn,
416 resolve: o => {
417 settled = true
418 signal?.removeEventListener('abort', onAbort)
419 resolve(o)
420 },
421 })
422 // The signal may have aborted while the ask_opened round trip was still in
423 // flight; a listener on an already-aborted signal never fires, so resolve
424 // the hold now.
425 if (signal?.aborted) onAbort()
426 })
427 try {
428 await setHeldRow($)
429 } catch {
430 // The row is a convenience; the hold stands even when it cannot draw.
431 }
432 while (!settled && !signal?.aborted) {
433 await Promise.race([hp, waitOnce($, ask)])
434 }
435 return hp
436}
437
438function startPollLoop($: EngineInterface): void {
439 if (pollStarted) return
440 pollStarted = true
441 pollCancel = $.clock.every(POLL_INTERVAL_MS, async () => {
442 if (inert) return
443 // The timer owns the session's one poll; a hold never shares it.
444 const now = await $.clock.now()
445 if (now < nextPollAfter) return
446 void pollOnce($)
447 })
448}
449
450function stopPollLoop(): void {
451 pollCancel?.cancel()
452 pollCancel = null
453 pollStarted = false
454}
455
456export const register: Register = on => {
457 on('session.start', async ($, e, next) => {
458 const os = await $.env.get('OS')
459 const home = await $.env.get('HOME')
460 if (os === 'Windows_NT' || !home || !home.startsWith('/')) {
461 inert = true
462 return next(e)
463 }
464 socketPath = `${home}/.clauth/bridge.sock`
465 interactive = e.isInteractive && e.surface !== null
466 sessionId = await $.session.id()
467 interruptedTurn = null
468 await $.state.set(HELD, null)
469 void postEvent($, 'start', { cwd: e.cwd, interactive: e.isInteractive })
470 startPollLoop($)
471 return next(e)
472 })
473
474 on('session.end', async ($, e, next) => {
475 if (!inert) {
476 stopPollLoop()
477 // Await the `end` post alone (the engine's own short bound caps it), so
478 // the daemon learns the end now rather than from its silence sweep.
479 await postEvent($, 'end')
480 }
481 return next(e)
482 })
483
484 on('session.append', async ($, e, next) => {
485 if (!inert) {
486 void postEvent($, 'append', {
487 uuid: e.uuid,
488 ...(e.agentId !== undefined ? { agent_id: e.agentId } : {}),
489 })
490 }
491 return next(e)
492 }).catch(($, e, next) => next(e))
493
494 on('turn.start', async ($, e, next) => {
495 if (!inert && e.agentId === undefined) {
496 turnId = e.turnId
497 void postEvent($, 'turn_start', { turn: e.turnId })
498 }
499 return next(e)
500 })
501
502 on('turn.complete', async ($, e, next) => {
503 if (!inert && e.agentId === undefined) {
504 turnId = null
505 interruptedTurn = null
506 void postEvent($, 'turn_end', { turn: e.turnId })
507 if (pendingPrompt) {
508 const p = pendingPrompt
509 try {
510 await $.prompt.submit(p)
511 pendingPrompt = null
512 } catch {
513 // The submit was refused; keep the prompt for the next turn.complete.
514 }
515 }
516 }
517 return next(e)
518 })
519
520 on(
521 'tool.check',
522 async ($, e, next) => {
523 if (inert) return next(e)
524 const v = await next(e)
525 if (v.decision !== 'ask') return v
526 if (!interactive) return v
527 const ask = mintAskId()
528 const turn = turnId
529 const heldNow = await postAskOpened($, ask, e.tool, e.input, e.agentId)
530 if (heldNow !== true) return v
531 const outcome = await holdUntilSettled(
532 $,
533 ask,
534 'decision',
535 e.tool,
536 describeInput(e.tool, e.input),
537 turn,
538 next.signal,
539 )
540 if (outcome.type === 'loss') return v
541 if (outcome.type === 'allow') return { decision: 'allow' }
542 if (outcome.type === 'deny') return { decision: 'deny' }
543 return v
544 },
545 ).catch(async ($, e, next) => (next.called ? next(e) : { decision: 'ask' }))
546
547 on(
548 'tool.call',
549 { tool: 'AskUserQuestion' },
550 async ($, e, next) => {
551 if (inert || !interactive) return next(e)
552 const ask = mintAskId()
553 const turn = turnId
554 const heldNow = await postAskOpened($, ask, 'AskUserQuestion', e.questions, undefined)
555 if (heldNow !== true) return next(e)
556 const outcome = await holdUntilSettled(
557 $,
558 ask,
559 'answers',
560 'AskUserQuestion',
561 firstQuestion(e.questions),
562 turn,
563 next.signal,
564 )
565 if (outcome.type === 'answers') {
566 return { result: { answers: outcome.answers } }
567 }
568 return next(e)
569 },
570 ).catch(($, e, next) => next(e))
571
572 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
573 const { value: row } = await $.state.get(HELD)
574 if (!row) return next(e)
575 const { Box, Text, Button } = $.ui.resolve(e)
576 const line1 = ` ${row.tool} ${row.desc}`
577 const line2 =
578 row.kind === 'decision'
579 ? Box({
580 children: [
581 Text({ children: LINE2_LEAD }),
582 Button({ key: 'allow', plain: true, hotkey: '1', label: 'Allow', onPress: () => answerFromDesk($, row.ask, 'allow') }),
583 Text({ children: ' ' }),
584 Button({ key: 'deny', plain: true, hotkey: '2', label: 'Deny', onPress: () => answerFromDesk($, row.ask, 'deny') }),
585 Text({ children: ' Esc denies' }),
586 ],
587 })
588 : Box({
589 children: [
590 Text({ children: QUESTION_LINE2_LEAD }),
591 Button({ key: 'answer-here', plain: true, hotkey: '1', label: 'Answer here', onPress: () => releaseToDesk($, row.ask) }),
592 Text({ children: ' Esc cancels' }),
593 ],
594 })
595 return Box({
596 flexDirection: 'column',
597 children: [Text({ wrap: 'truncate-end', children: line1 }), line2],
598 })
599 })
600}
601types/index.d.ts 29 lines1// The clauth mod's `$.state` contract: values the desk row's drawing reads
2// and the event sequence counter, each held by the host so a hot reload of the
3// module keeps them.
4export type ClauthHeldRow = {
5 /** The ask id the held check was posted under. */
6 ask: string
7 /** Which row line 2 shows: a permission ask's Allow/Deny, or an
8 * AskUserQuestion's answer-here. */
9 kind: 'decision' | 'answers'
10 /** The tool name, shown as line 1's `<Tool>`. */
11 tool: string
12 /** What the tool acts on (Bash's command, a path, a question, or one-line
13 * JSON), cut to the band's width at draw time. */
14 desc: string
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 clauth: {
20 /** The highest sequence number already sent on the bridge; the next
21 * event sends `seq + 1`, so a module reload never re-sends an old one. */
22 seq: number
23 /** The desk row shown above the prompt while a check is held, null when
24 * nothing is held. */
25 heldRow: ClauthHeldRow | null
26 }
27 }
28}
29