SLOPSHOPPER

clauth

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

newbandguardnetworktimer
★ 263v0.18.0MITupdated 2026-10-26uwuclxdy/clauth/plugins
A shopper browsing a rack in a slop shop
README

<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.

clauth TUI demo: switching Claude Code accounts with live usage bars

Font is kinda off on the recording, I promise it looks better than this.

Features

  • 🔄 Switch accounts in one keypress or clauth <name>: OAuth (Pro / Max / Team / Enterprise) or a custom API endpoint, plan tier detected for you
  • 📊 Monitor live 5h / 7d rate-limit bars, a global token dashboard with API-equivalent cost, plus a live Claude status-incident feed
  • 🤖 Auto-switch down a fallback chain the moment an account hits its limit, with weekly-window and spend-ceiling gates so a long run never stalls and never surprises you with a bill. Opted-in accounts queue their auto-start, opening 5h windows 5h / accounts apart instead of all at once
  • 🧩 Run in parallel: several accounts at once in isolated config dirs, or a clean headless session with none of your global memory, plugins, or hooks
  • 🔌 From inside Claude: an MCP plugin lets a live session list, switch, or delegate a whole prompt (even headless) to another account, tells a session when the account behind it changed, and, with daemon --listen, can hand its permission asks to a paired phone
  • 🖥️ Headless: clauth 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 session
  • 🔀 Codex too: adopt or mint a ChatGPT login as a codex profile, run codex under it in its own CODEX_HOME, and let a separate codex chain rotate accounts between sessions (Codex)
  • 🛠️ Quality-of-life: browse and resume past sessions under any account, per-profile model routing, start --auto to pick the account by the models a session will run, shell completions, signed self-updates, multi-instance safe

Full reference: the wiki.

How it works

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.

Install

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.

Quickstart

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
CommandDoes
clauthopen 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 whichaccount table with cached usage / who owns this session
clauth sessions, resume, infobrowse past Claude Code sessions and resume one anywhere
clauth daemonheadless 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.

TabWhat it holds
Overviewswitch and reorder accounts
Usageper-account window breakdown
Tokensglobal Claude Code token stats, token speed + API-equivalent cost across all models
Setupendpoint, key, env, auto-start, per-profile model routing, account presets
Fallbackchain editor
Configappearance, scheduler, auto-switch defaults
StatusClaude incident feed
Servicesthe shunt gateway, delegates, the Claude Code plugin and herdr

Claude Code plugin

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.

ToolWhat it doesQuota
profilesevery 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 onzero (disk cache)
switch_profilerelink the global active profile; the reply says what it does to this sessionzero
delegatehand a headless prompt to another account and return the answer (or a job_id)real usage window on the target account
monitorcheck, 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.

Alternatives

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.

ToolWhat it doesCompared to clauth
claude-swapCLI account switcher (token backup/restore)no usage view, no auto-switch
CCSwitcher, claude-account-switchermacOS menu-bar switchersmacOS-only, no fallback chain
cc-account-switchercredential-swap scriptsno TUI, no usage
Claude-Code-Usage-Monitorreal-time usage monitor with predictionsmonitoring only, single account
claude-code-statuslinerate-limit status line inside Claude Codein-session display, no switching
CLAUDE_CONFIG_DIR by handmanual per-account config dirswhat clauth start automates

FAQ

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.

Documentation

PageCovers
Installevery install path, update verification, completions
Quickstartfirst run, every command, flag, and env var
Interface and keysthe eight tabs, every keybinding, the action menus
Configurationboth TOML files key by key, model routing, storage layout
Auto-switchthresholds, exclusion rules, burn-aware mode, spend ceilings
Daemonclauth daemon, the REST API, and the status.json read contract
Claude Code pluginthe MCP server, delegate in full, answering a session from your phone
herdr pluginthe clauth popup in herdr, the key, the per-pane account tag
Tokens and costwhere the dashboard reads from, what the cost figure means
CodexChatGPT logins as codex profiles: capture, sessions, the codex chain
Securitywhere credentials live and how they move

Development

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 --nocapture drives the real interactive TUI on fake data against a throwaway home dir (no network, never compiled into the binary). Handy for screenshots.

Security

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.

License

MIT

Source 2 files
hooks/register.ts 601 lines
1// 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}
601
types/index.d.ts 29 lines
1// 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