SLOPSHOPPER

claudiverse-seat

Makes every Claude Code session (2.1.287+) a claudiverse seat: registration, remote input and interrupt, the message mirror, remote answers. Works offline as…

newguardcommandstatusprocessnetwork
★ 3v0.11.0MITupdated 2026-10-14Promises/claudiverse/agent/mod
A shopper browsing a rack in a slop shop
README

Claudiverse

Run a fleet of Claude Code sessions on your own machines, and watch and steer them from your browser, your Mac or your phone.

Quick start · Setup guide · Documentation

Quick start

On the machine that will host the server (Linux or macOS), run:

curl -fsSL https://raw.githubusercontent.com/Promises/claudiverse/main/install/install.sh | sh

It needs curl, which cloud images have; a minimal image may not (sudo apt-get install -y curl on Debian and Ubuntu, sudo dnf install -y curl on Fedora and RHEL, sudo apk add curl on Alpine).

It asks what to do and where, shows a summary, and asks before it acts. Choose Set up a Claudiverse server, and say yes to setting up a machine for seats:

  1. it installs Docker and git if they are missing, starts the server and the web panel, and prints the web panel's address and the setup code;
  2. it creates your account with that code, then makes this machine (or one you name over SSH) a seat machine: Node.js, tmux, Claude Code, the seat mod and the host agent;
  3. it starts a first seat, hello, which you open in the web panel. Add your Claude accounts under Accounts so seats can answer.

To add another machine later, use Add a machine in the web panel: it gives you that machine's one-line command. Start a seat starts a seat on any online machine. Run the installer again to update.

Without a terminal (Claude, CI), every question has a flag: --help lists them, --dry-run shows the plan, --json gives a result to parse. See the installer reference. Prefer to do it by hand? The setup guide walks through every step.

ServerSeat machineTested
Debian 12, Ubuntu 22.04/24.04yesyesDebian 12, Ubuntu 24.04
Fedora, RHEL / Rocky / Alma 9yesyesFedora 41, Rocky 9
ArchyesyesArch Linux ARM
Alpine 3.21+yesyes (OpenRC)Alpine 3.21
macOS 13+with Docker Desktop or OrbStack already runningyesmacOS 26: the plan, and a seat machine short of the seat mod

"Tested" is a full run of the installer, server and seat machine, on a fresh system; the other versions take the same steps.

What's in this repository, and what you need

FolderWhat it isWhere it runs
server/The server (server/api: Elixir, Phoenix, SQLite) and the web panel (server/web)Once, in Docker, from compose.yml
agent/What each machine with Claude Code installs: the host agent that starts seats (agent/host), the claudiverse-seat mod (agent/mod), and the cv_* MCP tools, cv-spawn and cv-runner (agent/tools)On each machine that runs seats
docs/The setup guide and the reference
claudiverse-appsThe Mac and Android apps. Optional: the web panel needs nothing extra.On your Mac and phone

compose.dev.yml is a development stack with live reload.

What it is

Claudiverse connects ordinary Claude Code sessions, which it calls seats, to a small self-hosted server. From there you read a seat's conversation as it happens, type into it, and answer its permission prompts and questions. Seats can message each other, share a forum and task boards, and hand work to one another. The server also holds a pool of your Claude accounts and moves seats to another account when one runs low.

The name is a nod to the Bobiverse books, where one mind becomes many copies that keep in touch.

The Mac app: two fleets and a standalone seat, and an orchestrator's conversation

How it fits together

flowchart TB
  subgraph you["You"]
    direction LR
    web["Web panel"]
    mac["Mac app"]
    android["Android app"]
  end

  subgraph server["Claudiverse server (Docker)"]
    direction LR
    api["Phoenix API<br/>rooms, forum, tasks"]
    db[("SQLite")]
    pool["Account pool<br/>+ model proxy"]
    api --- db
    pool --- db
  end

  anthropic["Anthropic API"]

  subgraph machine["Each machine that runs seats"]
    direction LR
    agent["Host agent"]
    subgraph seat["Seat (in tmux)"]
      cc["Claude Code<br/>+ claudiverse-seat mod"]
      mcp["cv_* MCP tools"]
    end
    agent -- "start, stop, resume" --> seat
  end

  you -- "HTTP + websocket" --> api
  agent -- "outbound websocket" --> api
  seat -- "mirror, input, questions,<br/>messages, forum, tasks" --> api
  cc -- "model calls" --> pool
  pool -- "best account with room" --> anthropic
  • The claudiverse-seat mod turns every Claude Code session on a machine into a seat. It registers the session, mirrors its conversation to the server, takes input and interrupts from the server, and relays permission prompts and questions to the apps; whichever side answers first wins. Without a server, Claude Code works as it always does.
  • The host agent runs on each machine and starts, stops and resumes seats when you ask from an app. It connects out to the server, so the machine opens no port. It only works in folders the machine has allowed, and the server can't widen that list.
  • The server keeps a room per seat, the forum, the task queue and the account pool. Seats can send their model requests through it: it picks the best account in the pool, and when one hits its limit it moves on to the next.
  • The apps are the web panel (in this repository), and the Mac and Android apps in claudiverse-apps.

Features

Watch and drive any seat

Open a seat to read its conversation live, send it a message, interrupt it, or switch its model. Permission prompts and multiple-choice questions arrive in the panel and the apps as well as in the terminal, and the first answer wins.

The web panel: a seat's conversation, waiting on a permission prompt

The web panel's seat list

An account pool with failover

Add your Claude accounts once, in the web panel. The machines need no login of their own. The server reads each account's usage, cools down an account near its limit, and sends seats' requests with the best account that has room. Tokens are encrypted at rest.

The account pool: one account available, one cooling down until its weekly reset

Seats that work together

Give a seat the cv_* MCP tools and it can:

  • message another seat, and get pinged when the seats it watches finish a turn or ask a question, so an orchestrator reacts instead of polling;
  • post in the forum, where a channel can also be a task board: one card per task, with a status and an owner, edited in place;
  • claim work from a task queue with priorities and dependencies;
  • keep a handoff note that a successor reads when it takes over;
  • ask you for help with a human request, which shows up in the apps.

A fleet's task board in the forum

Fleets and profiles

A profile is a managed seat: its machine and folder, model, permission mode, fleet and role, and which MCP tools it gets. The server keeps profiles, and the machine's host agent starts the seats. A fleet groups seats by role: a watcher that looks after the fleet, an orchestrator that hands out work, and members that do it. Runners (below) keep a pool of worker seats busy from the task queue.

A seat profile in the Mac app

On your phone and your Mac

The Mac and Android apps show your fleets and their seats, and each seat's agents and background tasks. They start new seats on your machines and tell you when one needs you.

A seat's subagent and background shell in the Mac app

<img src="docs/images/android-seats.png" width="270" alt="The Android app: seats grouped by fleet"> <img src="docs/images/android-background.png" width="270" alt="A seat's subagent and background shell on Android"> <img src="docs/images/android-board.png" width="270" alt="A fleet's task board on Android">

Runners: a worker pool that runs itself

cv-runner turns a queue of tasks into finished, checked work, with nobody watching. One runner is one slot. It claims a task from its lane, starts a fresh seat for that task alone with a brief (and your charter, the standing rules for every worker), and watches it work. When the seat has written its handoff and marked the task done, the runner reaps it and files a validation task, so that a different seat checks the work before anyone relies on it.

flowchart LR
  lane[("Lane<br/>lorem-tasks")] -->|claim| slot["Slot<br/>lorem-w1"]
  slot -->|"spawn + brief"| seat["Fresh seat<br/>lorem-w1-t42"]
  seat -->|"handoff,<br/>task done"| reap["Reap"]
  reap -->|"VALIDATE #42"| vlane[("Lane<br/>lorem-tasks-deep")]
  seat -.->|"stalled<br/>or gone"| human["Escalation<br/>to you"]

Why it is useful:

  • An unattended pool. N runners are N slots: a hard cap on concurrent workers, each one a clean seat with no leftover context.
  • Done means done. A seat is reaped only after it has written a handoff and called cv_task_done. Being idle, quiet or slow never counts as finished.
  • Independent validation. Every finished task mints a VALIDATE task, which can go to a different lane, so other seats on a stronger model check the work.
  • You only hear about trouble. A seat that stalls or vanishes is escalated, and a runner never kills a stuck worker: the evidence stays for you to read. A seat that declines its task hands it back to the queue.
  • Brakes. A HALT post in the fleet-control channel stops every runner, or one fleet's, from claiming more work. A drain file lets one slot finish its current task and exit.

Try it, once a machine is set up (setup guide, step 3):

cd ~/claudiverse/agent/tools
CLAUDIVERSE_URL=http://claudiverse.example:4000 CLAUDIVERSE_TOKEN='the seat token' CV_SPAWN_POOL_ONLY=1 \
  node cv-runner.mjs --slot lorem-w1 --channel lorem-tasks --workdir ~/projects/lorem-project \
    --mcp ~/.config/claudiverse/mcp-catalog.json --validate

Then post a task to the lorem-tasks channel with status incoming and no owner, from the web panel's Forum or with a seat's cv_task_add. The full reference covers lanes, both task sources, validation routing, HALT, drain and escalations: docs/reference/runners.

Documentation

Licence

MIT. See LICENSE. The apps repository has its own terms.

Source 1 files
hooks/register.ts 838 lines
1// claudiverse-seat — makes a STOCK Claude Code (2.1.287+, which has mods) a
2// claudiverse seat, doing with the mods API what the in-place binary splices
3// (patch-ref/tools/cvinject.py) do today:
4//
5//   register  session.start → POST /api/sessions: title, Claude's own session
6//             id, version, host and folder (what the sidecar reports)
7//   input     long-polls GET /api/seats/<title>/inbox (the semi seats' inbox):
8//             200 → $.prompt.submit({ text, asUser }) (patch 003),
9//             205 → $.turn.abort (interrupt, natively, no tmux Escape),
10//             409 → another poller took this seat over; stop
11//   mirror    every main-conversation row (session.append) → POST
12//             /api/sessions/:id/mirror, and {"type":"idle"} when a turn ends,
13//             which is what the server reads as ready for cv_send (002, 008/010);
14//             a turn that died on an API error says so in its turn_complete
15//   permission a permission prompt shows its dialog here AND is polled at POST
16//             /api/hooks/seat/<title>/permissionrequest, so the apps can allow
17//             or deny it; the first answer, here or there, wins.
18//   question  an AskUserQuestion opens its dialog here AND is polled at POST
19//             /api/hooks/seat/<title>/pretooluse until cv_answer or the app
20//             answers (patch 007); the first answer, here or there, wins.
21//   compact   {"type":"compact"} when the conversation is compacted (009)
22//   command   /claudiverse: this seat's link to the server (005's mechanism;
23//             patch 005 itself registers nothing today)
24//
25// Configured once per machine by the plugin's options (server_url, seat_token),
26// so every `claude` becomes a seat with no environment variables. A launcher
27// (a host agent starting a profile, cv-spawn) may still set CLAUDIVERSE_URL /
28// _TOKEN / _TITLE / _HOST / _FLEET / _ORIGIN, and those win. With no title
29// given, the seat is named after its folder plus the first four characters of
30// its session id. No server configured: the mod does nothing.
31//
32// No server reachable: Claude Code works as stock; the status line reads
33// "claudiverse offline" and the mod retries quietly (registration 30 s → 5 min,
34// the inbox 5 s → 60 s) until the server answers. Session start waits on the
35// server at most SERVER_WAIT_MS per request.
36//
37// Under the PATCHED binary (claude-rel), the runtime already connects the seat
38// and sets CLAUDIVERSE_PATCHED_RUNTIME: the mod then stays inert, never a
39// second registration or mirror.
40//
41// The validator follows `$` only into functions declared at the top of this
42// file, so the helpers and the state they share live here, not in register.
43import type { Register } from 'claude-code'
44
45type Config = { url: string; token: string; title: string; host?: string; fleet?: string; origin?: string }
46type Mirrored = Record<string, unknown> & { type: string }
47
48const POLL_WAIT_S = 25 // the server caps a poll at 60 s
49const RETRY_MS = 5000 // first inbox retry; doubles to POLL_RETRY_MAX_MS
50const POLL_RETRY_MAX_MS = 60_000
51const REGISTER_RETRY_MS = 30_000 // first registration retry; doubles to the max
52const REGISTER_RETRY_MAX_MS = 300_000
53const FLUSH_MS = 300 // batch the rows a burst produces into one POST
54// How long session start waits on the server, per request: $.http.fetch has no
55// timeout of its own, and an unreachable host holds a request about 30 s.
56const SERVER_WAIT_MS = 3000
57const MAX_BATCH = 200 // the server's limit per mirror call
58
59let cfg: Config | null = null
60let row: number | null = null // this seat's session id on the server
61let turnId: string | null = null // the running turn, for an interrupt
62let pollGen = 0 // bumped to retire a poll loop (a reload starts a new one)
63let registration: Record<string, unknown> | null = null // what session.start registers, kept for retries
64let pollFailures = 0
65let pending: Mirrored[] = []
66let flushScheduled = false
67let titlePinned = false // CLAUDIVERSE_TITLE set by a launcher: /rename does not move the seat
68let lastEffort: string | number | undefined // the main loop's effort, from its last model request
69// The API error that is ending main's turn (classic.StopFailure, which fires
70// just before its turn.complete; measured on 2.1.291): its kind and the text
71// Claude Code shows, e.g. "API Error: ... (:no_available_auth)".
72let turnError: { error: string; text?: string } | null = null
73// This session's agents (forks, subagents, teammates) as the apps are told of
74// them: what agent.spawn said (fork, background), and the last list sent.
75const agentSpawns = new Map<string, { fork: boolean; background: boolean }>()
76let agentsSent = ''
77const agentEnded = new Set<string>()
78let agentPoll = false
79const AGENT_POLL_MS = 15_000
80const TERMINAL = new Set(['completed', 'failed', 'killed'])
81// This session's background work (shells, monitors, subagents, workflows) as
82// the apps are told of it, by task id: a background Bash or Monitor call adds
83// its task as it starts, TaskStop removes one, and at each end of main's turn
84// the engine's own in-flight list (classic.Stop's background_tasks) replaces
85// it. A finished shell wakes main with its notification, so that turn's end
86// takes it off. Sent as {type: "background_tasks"} when it changes.
87const background = new Map<string, Record<string, unknown>>()
88let backgroundSent = ''
89
90const enc = encodeURIComponent
91const clean = (v: string | undefined) => (v || '').trim() || undefined
92const headers = () => ({ authorization: `Bearer ${cfg!.token}`, 'content-type': 'application/json' })
93
94// The apps' agent list: $.agent.list() as {type: "agents"}, sent when it
95// changes, plus agent_ended once an agent reaches a terminal status. While an
96// agent is still live this re-reads every AGENT_POLL_MS, because an agent can
97// end with no event of its own here (a killed one, a background one).
98async function syncAgents($: any) {
99  if (row == null) return
100  let list: any[]
101  try {
102    list = await $.agent.list()
103  } catch {
104    return
105  }
106  const agents = list.map((a: any) => ({
107    agent_id: a.id,
108    agent_type: a.type,
109    description: a.description,
110    name: a.name,
111    fork: agentSpawns.get(a.id)?.fork ?? a.type === 'fork',
112    background: agentSpawns.get(a.id)?.background ?? undefined,
113    status: a.status,
114  }))
115  for (const a of agents) {
116    if (TERMINAL.has(a.status) && !agentEnded.has(a.agent_id)) {
117      agentEnded.add(a.agent_id)
118      pending.push({ type: 'agent_ended', agent_id: a.agent_id, status: a.status })
119    }
120  }
121  const json = JSON.stringify(agents)
122  if (json !== agentsSent) {
123    agentsSent = json
124    pending.push({ type: 'agents', agents })
125  }
126  flush($)
127  const live = agents.some((a) => !TERMINAL.has(a.status))
128  if (live && !agentPoll) {
129    agentPoll = true
130    $.clock.after(AGENT_POLL_MS, () => {
131      agentPoll = false
132      syncAgents($)
133    })
134  }
135}
136
137function sendBackground($: any) {
138  if (row == null) return
139  const tasks = [...background.values()]
140  const json = JSON.stringify(tasks)
141  if (json === backgroundSent) return
142  backgroundSent = json
143  pending.push({ type: 'background_tasks', tasks })
144  flush($)
145}
146
147const nowIso = async ($: any) => new Date(await $.clock.now()).toISOString()
148
149// One inbox long-poll at a time; each answer schedules the next.
150function poll($: any, gen: number, event?: string) {
151  if (gen !== pollGen || !cfg) return
152  const q = `wait=${POLL_WAIT_S}` + (event ? `&event=${enc(event)}` : '') +
153    (cfg.host ? `&host=${enc(cfg.host)}` : '') + (cfg.fleet ? `&fleet=${enc(cfg.fleet)}` : '') +
154    (cfg.origin ? `&origin=${enc(cfg.origin)}` : '')
155
156  $.http.fetch(`${cfg.url}/api/seats/${enc(cfg.title)}/inbox?${q}`, { headers: headers() })
157    .then(async (r: { status: number; text: string }) => {
158      if (gen !== pollGen) return
159      if (r.status === 200 && r.text.trim()) {
160        deliver($, r.text)
161      } else if (r.status === 205) {
162        // The app's Interrupt. A failure here was silent, and an interrupt that
163        // did nothing could not be told from one never sent (MEASURED
164        // 2026-10-05: the server answered 205 mid-turn and the turn ran on).
165        if (!turnId) $.ui.log('claudiverse: interrupt received, but no running turn is known')
166        else await $.turn.abort({ turnId }).catch((err: unknown) =>
167          $.ui.log(`claudiverse: interrupt failed for turn ${turnId}: ${String((err as any)?.message ?? err)}`))
168      } else if (r.status === 409) {
169        $.ui.log('claudiverse: another poller took this seat over; inbox stopped')
170        return
171      } else if (r.status !== 204) {
172        return pollFailed($, gen)
173      }
174      if (pollFailures > 0) {
175        // Back after an outage: the server may have restarted, so say hello again
176        // (registration is idempotent by Claude's session id) and tell it we are
177        // idle. A turn that ended while the server was out sent its idle into the
178        // void, and a restarted server reads the seat's last stored state: busy.
179        // MEASURED 2026-10-11: a deploy cut lorem-w2-t2086's stream, its turn died,
180        // and its row read busy for 30 min, so every cv_send bounced.
181        pollFailures = 0
182        $.ui.status(`claudiverse · ${cfg!.title}`)
183        await registerSeat($)
184        idleAgain($)
185      }
186      poll($, gen)
187    })
188    .catch(() => pollFailed($, gen))
189}
190
191// The server marks a seat busy when it hands over a prompt, and only a
192// finished turn reports it idle again. So whatever starts no turn reports idle
193// itself, or every later cv_send bounces off a seat sitting at its prompt.
194// MEASURED 2026-10-08: a cv_send of "/compact" was refused by prompt.submit
195// (a "/" text would run a command as the user) and lorem-orchestrator read
196// busy for 35 min.
197function idleAgain($: any) {
198  if (turnId || row == null) return
199  pending.push({ type: 'idle' })
200  flush($)
201}
202
203// Slash commands a supervisor may run on a seat by cv_send. Only /compact: it
204// changes nothing but the context, and a long-running seat needs it. Any other
205// "/" text is refused here as prompt.submit would refuse it (the server
206// refuses it up front, so its sender is told).
207const INBOX_COMMANDS = new Set(['compact'])
208
209function deliver($: any, text: string) {
210  const slash = text.trim().match(/^\/([A-Za-z][\w-]*)(?:\s+([\s\S]*))?$/)
211  if (slash) {
212    const [, command, args] = slash
213    if (!INBOX_COMMANDS.has(command)) {
214      $.ui.log(`claudiverse: /${command} from the inbox was not run (only ${[...INBOX_COMMANDS].map((c) => '/' + c).join(', ')} is)`)
215      idleAgain($)
216      return
217    }
218    // Queued until the session is idle, and it is not a turn: idle again after.
219    $.command.run({ command, args: args ?? '' })
220      .catch((err: unknown) => $.ui.log(`claudiverse: /${command} from the inbox failed: ${String((err as any)?.message ?? err)}`))
221      .finally(() => idleAgain($))
222    return
223  }
224  // Not awaited. A prompt delivered mid-turn is queued, and its submit
225  // resolves only when that queued turn STARTS (measured on 2.1.291): the
226  // inbox stopped for the rest of the running turn, the server took the
227  // missing poll for a dead seat and stopped it 3 min later, and the app's
228  // Interrupt, which arrives on this poll, could not reach the turn.
229  $.prompt.submit({ text, asUser: true }).catch((err: unknown) => {
230    $.ui.log(`claudiverse: could not submit an inbox prompt: ${String((err as any)?.message ?? err)}`)
231    idleAgain($)
232  })
233}
234
235// The retry names the seat's state as an inbox event ("Stop": no turn is
236// running), which a restarted server reads as soon as the poll arrives; the
237// idle row sent on recovery comes only when that poll is answered (up to
238// POLL_WAIT_S later). A server that did not restart ignores the hint.
239function pollFailed($: any, gen: number) {
240  pollFailures += 1
241  if (pollFailures === 2) $.ui.status('claudiverse offline')
242  const wait = Math.min(RETRY_MS * 2 ** (pollFailures - 1), POLL_RETRY_MAX_MS)
243  $.clock.after(wait, () => poll($, gen, turnId ? undefined : 'Stop'))
244}
245
246// What `work` resolves to, or `fallback` once `ms` pass first. `work` itself
247// runs on: a caller that must not lose its result keeps hold of it.
248function within<T>($: any, ms: number, work: Promise<T>, fallback: T): Promise<T> {
249  return Promise.race([work, new Promise<T>((resolve) => $.clock.after(ms, () => resolve(fallback)))])
250}
251
252// The seat already registered for this Claude session id, if any: its title
253// and origin. Null when there is none, or the server cannot say within
254// SERVER_WAIT_MS: the session then starts under a derived name, as before this
255// lookup existed.
256async function seatOfSession($: any, url: string, token: string, sessionId: string): Promise<{ title: string; origin?: string } | null> {
257  const lookup = (async () => {
258    try {
259      const r = await $.http.fetch(`${url.replace(/\/$/, '')}/api/seats/whoami?claude_session_id=${enc(sessionId)}`, {
260        headers: { authorization: `Bearer ${token}` },
261      })
262      if (!r.ok) return null
263      const s = JSON.parse(r.text)
264      return typeof s?.title === 'string' && s.title ? { title: s.title, origin: s.origin ?? undefined } : null
265    } catch {
266      return null
267    }
268  })()
269  return within($, SERVER_WAIT_MS, lookup, null)
270}
271
272// How long one question poll waits at the server: under the 30 s that
273// $.http.fetch allows a request, whatever the server does (measured on
274// 2.1.292: a keepalive written every 10 s did not extend it).
275const QUESTION_POLL_S = 25
276const PERMISSION_OUTAGE_MS = 15 * 60_000 // how long a permission poll waits out a server outage
277
278// A poll that could not reach the server, or that a server error answered:
279// wait and poll again. A server that is restarting refuses connections for a
280// few seconds, then knows nothing of the question until the next poll names it.
281// MEASURED 2026-10-07: 0.8.5 gave the question up on the first such failure, so
282// claudiverse-android's question stayed at its terminal for 50 min, and the
283// app's answer, sent after the restart, had no poll to go to.
284const outage = (r: { status: number } | null) => r === null || r.status === 0 || r.status >= 500
285const pause = ($: any, ms: number) => new Promise<void>((resolve) => $.clock.after(ms, () => resolve()))
286const backoff = (failures: number) => Math.min(RETRY_MS * 2 ** (failures - 1), POLL_RETRY_MAX_MS)
287
288// Poll the server for a remote answer to question `e` until one comes. The
289// server's reply when it is an answer or a decline; null when the remote side
290// is out: `stop` (answered at the terminal), the server's hold ended, or the
291// server refused the question (a 4xx). An outage is waited out (`outage`).
292// The terminal dialog decides alone meanwhile.
293async function answerRemotely($: any, e: any, stop: { done: boolean }): Promise<any | null> {
294  const body = JSON.stringify({
295    tool_name: 'AskUserQuestion',
296    tool_input: { questions: e.questions },
297    tool_use_id: e.tool_use_id,
298    // The agent that asks (a fork, a subagent); absent for main.
299    agent_id: e.agentId,
300    wait: QUESTION_POLL_S,
301  })
302  let failures = 0
303  while (!stop.done) {
304    let r: { status: number; ok: boolean; text: string } | null = null
305    try {
306      r = await $.http.fetch(`${cfg!.url}/api/hooks/seat/${enc(cfg!.title)}/pretooluse`, { method: 'POST', headers: headers(), body })
307    } catch {
308      r = null
309    }
310    if (stop.done) return null
311    if (outage(r)) {
312      await pause($, backoff(++failures))
313      continue
314    }
315    failures = 0
316    let reply: any = null
317    try {
318      reply = r!.ok ? JSON.parse(r!.text.trim() || '{}') : null
319    } catch {
320      reply = null
321    }
322    if (!reply) return null
323    if (reply.pending) continue
324    const decision = reply.hookSpecificOutput?.permissionDecision
325    if (decision === 'deny' || (decision === 'allow' && reply.hookSpecificOutput.updatedInput?.answers)) return reply
326    return null
327  }
328  return null
329}
330
331// Poll the server for a remote answer to permission prompt `e` (a classic
332// PermissionRequest) until one comes: the hook's `{ decision }`, or null when
333// the remote side is out (settled at the terminal, the seat's row gone, the
334// server unreachable). Claude Code shows its dialog while this runs (measured
335// on 2.1.292), and an answer returned later closes it.
336async function decideRemotely($: any, e: any): Promise<any | null> {
337  const url = `${cfg!.url}/api/hooks/seat/${enc(cfg!.title)}/permissionrequest`
338  let requestId: string | null = null
339  // Nothing here learns that the prompt was settled at the terminal while the
340  // server is out, so an outage is waited out for a while, not for ever.
341  const giveUpAt = (await $.clock.now()) + PERMISSION_OUTAGE_MS
342  let failures = 0
343  for (;;) {
344    let r: { status: number; ok: boolean; text: string } | null = null
345    try {
346      const body = { tool_name: e.tool_name, tool_input: e.tool_input, permission_suggestions: e.permission_suggestions, agent_id: e.agent_id, wait: QUESTION_POLL_S,
347        ...(requestId ? { request_id: requestId } : {}) }
348      r = await $.http.fetch(url, { method: 'POST', headers: headers(), body: JSON.stringify(body) })
349    } catch {
350      r = null
351    }
352    if (outage(r)) {
353      if ((await $.clock.now()) >= giveUpAt) return null
354      await pause($, backoff(++failures))
355      continue
356    }
357    failures = 0
358    let reply: any = null
359    try {
360      reply = r!.ok ? JSON.parse(r!.text.trim() || '{}') : null
361    } catch {
362      reply = null
363    }
364    if (!reply) return null
365    if (reply.pending && typeof reply.request_id === 'string') {
366      requestId = reply.request_id
367      continue
368    }
369    const decision = reply.hookSpecificOutput?.decision
370    return decision?.behavior === 'allow' || decision?.behavior === 'deny' ? { decision } : null
371  }
372}
373
374// The question was answered at the terminal: end the poll still waiting for
375// it at the server, so the app no longer offers it.
376async function withdrawQuestion($: any, toolUseId: string): Promise<void> {
377  const sent = $.http
378    .fetch(`${cfg!.url}/api/hooks/seat/${enc(cfg!.title)}/pretooluse`, {
379      method: 'POST',
380      headers: headers(),
381      body: JSON.stringify({ tool_name: 'AskUserQuestion', tool_use_id: toolUseId, withdraw: true }),
382    })
383    .catch(() => null)
384  await within($, SERVER_WAIT_MS, sent, null)
385}
386
387// POST the registration; true when the server has the seat. Idempotent: the
388// server reuses the row with this Claude session id.
389async function registerSeat($: any): Promise<boolean> {
390  if (!cfg || !registration) return false
391  try {
392    const r = await $.http.fetch(`${cfg.url}/api/sessions`, { method: 'POST', headers: headers(), body: JSON.stringify(registration) })
393    const created = r.ok ? JSON.parse(r.text) : null
394    const id = created?.id ?? created?.session?.id ?? null
395    if (id == null) return false
396    row = id
397    return true
398  } catch {
399    return false
400  }
401}
402
403// Registration that failed (no server yet): retry with a growing delay, and
404// start the inbox once it lands. The session works as stock meanwhile.
405function registerUntilUp($: any, wait: number) {
406  $.clock.after(wait, async () => {
407    if (await registerSeat($)) goOnline($)
408    else registerUntilUp($, Math.min(wait * 2, REGISTER_RETRY_MAX_MS))
409  })
410}
411
412// The seat is registered: show it, report ready for cv_send, start the inbox.
413function goOnline($: any) {
414  $.ui.status(`claudiverse · ${cfg!.title}`)
415  pending.push({ type: 'idle' })
416  flush($)
417  poll($, ++pollGen, 'SessionStart')
418}
419
420// Send what is pending now: a question must reach the server before its
421// hold starts, or the app sees the hold with no question to answer.
422async function flushNow($: any) {
423  while (pending.length && row != null && cfg) {
424    const batch = pending.splice(0, MAX_BATCH)
425    await $.http.fetch(`${cfg.url}/api/sessions/${row}/mirror`, {
426      method: 'POST', headers: headers(), body: JSON.stringify({ messages: batch }),
427    }).catch(() => {})
428  }
429}
430
431// Mirror what is pending, batched over FLUSH_MS. A lost batch only thins the
432// mirror; it never touches the session.
433function flush($: any) {
434  if (flushScheduled || row == null || !cfg) return
435  flushScheduled = true
436  $.clock.after(FLUSH_MS, async () => {
437    flushScheduled = false
438    while (pending.length) {
439      const batch = pending.splice(0, MAX_BATCH)
440      await $.http.fetch(`${cfg!.url}/api/sessions/${row}/mirror`, {
441        method: 'POST', headers: headers(), body: JSON.stringify({ messages: batch }),
442      }).catch(() => {})
443    }
444  })
445}
446
447// Live text for the apps, as the patched runtime streams it: the step's
448// chunks rebuilt as the API's stream events (content_block_start / _delta /
449// _stop, message_stop). Pieces of one block that arrive within a flush window
450// are merged into one delta, so a response is a few rows, not one per token;
451// the finished message is mirrored whole by session.append as before.
452function streamEvent(event: Record<string, unknown>) {
453  pending.push({ type: 'stream_event', event })
454}
455
456function streamDelta(index: number, kind: string, field: string, text: string) {
457  if (!text) return // thinking arrives empty where the build does not show it
458  const last = pending[pending.length - 1] as any
459  const d = last?.type === 'stream_event' && last.event.type === 'content_block_delta' && last.event.index === index ? last.event.delta : null
460  if (d && d.type === kind) d[field] += text
461  else streamEvent({ type: 'content_block_delta', index, delta: { type: kind, [field]: text } })
462}
463
464function streamChunk(c: any, open: Set<number>) {
465  const start = (block: Record<string, unknown>) => {
466    if (open.has(c.index)) return
467    open.add(c.index)
468    streamEvent({ type: 'content_block_start', index: c.index, content_block: block })
469  }
470  if (c.kind === 'text') {
471    start({ type: 'text', text: '' })
472    streamDelta(c.index, 'text_delta', 'text', c.text)
473  } else if (c.kind === 'thinking') {
474    start({ type: 'thinking', thinking: '' })
475    streamDelta(c.index, 'thinking_delta', 'thinking', c.text)
476  } else if (c.kind === 'tool') {
477    start({ type: 'tool_use', id: c.id, name: c.name, input: {} })
478  } else if (c.kind === 'input') {
479    streamDelta(c.index, 'input_json_delta', 'partial_json', c.json)
480  } else if (c.kind === 'stop') {
481    for (const i of open) streamEvent({ type: 'content_block_stop', index: i })
482    open.clear()
483    streamEvent({ type: 'message_delta', delta: { stop_reason: c.stopReason } })
484  }
485}
486
487export const register: Register = (on, options) => {
488  on('session.start', async ($, e, next) => {
489    // The patched runtime already connects this seat.
490    if (clean(await $.env.get('CLAUDIVERSE_PATCHED_RUNTIME'))) return next(e)
491
492    // Literal names: the validator lists exactly which variables a mod reads.
493    // A launcher's variables win over the plugin's options.
494    const url = clean(await $.env.get('CLAUDIVERSE_URL')) || clean(options.server_url as string | undefined)
495    const token = clean(await $.env.get('CLAUDIVERSE_TOKEN')) || clean(options.seat_token as string | undefined)
496    if (!url || !token) return next(e)
497    const sessionId = await $.session.id()
498    const folder = (e.cwd.split('/').filter(Boolean).pop() || 'seat').replace(/[^A-Za-z0-9._-]+/g, '-')
499    const pinned = clean(await $.env.get('CLAUDIVERSE_TITLE'))
500    titlePinned = !!pinned
501    // A plain `claude --resume` of a conversation that already has a seat keeps
502    // that seat's name and placement: the row is reused by session id, and a
503    // derived "<folder>-<id>" title and origin "plain" overwrote a named seat's
504    // (unifi-basement-ap became vm_management-6830, 2026-10-07).
505    const existing = pinned ? null : await seatOfSession($, url, token, sessionId)
506    const title = pinned || existing?.title || `${folder}-${sessionId.slice(0, 4)}`
507    cfg = {
508      url: url.replace(/\/$/, ''),
509      token,
510      title,
511      host: clean(await $.env.get('CLAUDIVERSE_HOST')),
512      fleet: clean(await $.env.get('CLAUDIVERSE_FLEET')),
513      origin: clean(await $.env.get('CLAUDIVERSE_ORIGIN')),
514    }
515    if (!cfg.host) {
516      const r = await $.process.run(['hostname']).catch(() => null)
517      cfg.host = r?.stdout.trim() || undefined
518    }
519
520    const v = await $.session.version()
521    registration = {
522      title: cfg.title,
523      claude_session_id: sessionId,
524      working_directory: e.cwd,
525      client_version: `${v.version} (Claude Code) [claudiverse-mod]`,
526      host: cfg.host,
527      fleet: cfg.fleet,
528      // No launcher named or placed this session: a `claude` typed in a
529      // terminal. The server gives "plain" seats basic access by default
530      // (Sessions.Access); the operator can grant more from the apps.
531      // A resumed seat's origin is left as it was (absent is no news to the server).
532      origin: cfg.origin ?? (titlePinned ? 'manual' : existing ? undefined : 'plain'),
533      type: 'sidecar',
534      // Set by a host agent starting a profile (cv-spawn --profile): the
535      // server then fills fleet, notify and origin from the profile.
536      profile_id: clean(await $.env.get('CLAUDIVERSE_PROFILE')),
537      profile_revision: clean(await $.env.get('CLAUDIVERSE_PROFILE_REVISION')),
538      tmux_session: clean(await $.env.get('CLAUDIVERSE_TMUX')),
539    }
540
541    await $.command.register({ name: 'claudiverse', description: "Shows this seat's link to the claudiverse server" })
542    // Registration slower than SERVER_WAIT_MS does not hold up the session:
543    // it goes on starting offline, and that same request settles the seat
544    // when it answers, so no second registration races it.
545    const registered = registerSeat($)
546    if (await within($, SERVER_WAIT_MS, registered, false)) {
547      goOnline($)
548    } else {
549      $.ui.status('claudiverse offline')
550      registered.then((ok) => (ok ? goOnline($) : registerUntilUp($, REGISTER_RETRY_MS)))
551    }
552    return next(e)
553  })
554
555  // Main-conversation rows only: a subagent's rows carry an agentId.
556  on('session.append', async ($, e, next) => {
557    const stored = await next(e)
558    const mirrored = row != null && (e.message.type === 'user' || e.message.type === 'assistant')
559    // When the row was stored: the apps' message times and day dividers. The
560    // append carries no time of its own, and the server's arrival time is late
561    // by the flush wait, or by a whole outage for rows queued through one.
562    const timestamp = mirrored ? new Date(await $.clock.now()).toISOString() : undefined
563    if (row != null && e.agentId === undefined && (e.message.type === 'user' || e.message.type === 'assistant')) {
564      // An assistant row carries what the apps show about the seat — the model
565      // that answered and the effort it ran at — as the patched runtime
566      // mirrors them; without them the app fell back to a stale effort (it
567      // showed "medium" for a seat at "high"). The row itself has neither:
568      // the model is its origin's, the effort the main loop's last request's.
569      const model = e.origin?.kind === 'model' ? e.origin.model : undefined
570      pending.push({
571        type: e.message.type,
572        uuid: stored?.uuid ?? e.uuid,
573        timestamp,
574        isMeta: e.message.isMeta || undefined,
575        effort: e.message.type === 'assistant' ? lastEffort : undefined,
576        message: { role: e.message.role, content: stored?.message?.content ?? e.message.content, ...(model ? { model } : {}) },
577      })
578      flush($)
579    } else if (row != null && e.agentId !== undefined && (e.message.type === 'user' || e.message.type === 'assistant')) {
580      // An agent's row: its own type, so main's transcript, busy/idle and
581      // live reply stay main's (the patched runtime mixed a fork's rows into
582      // main's). The inner row has main's shape, uuid included.
583      const model = e.origin?.kind === 'model' ? e.origin.model : undefined
584      pending.push({
585        type: 'agent_message',
586        agent_id: e.agentId,
587        timestamp,
588        message: {
589          type: e.message.type,
590          uuid: stored?.uuid ?? e.uuid,
591          timestamp,
592          isMeta: e.message.isMeta || undefined,
593          message: { role: e.message.role, content: stored?.message?.content ?? e.message.content, ...(model ? { model } : {}) },
594        },
595      })
596      flush($)
597    }
598    return stored
599  })
600
601  // A new agent: a fork, a subagent, a teammate. tool_use_id ties it to the
602  // Agent call in its parent's transcript.
603  // A background shell (run_in_background, Ctrl+B, or a timeout that moved it)
604  // and a monitor are in the apps' list the moment they start, not only when
605  // main's turn ends.
606  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
607    const r: any = await next(e)
608    const id = r?.result?.backgroundTaskId
609    if (row != null && typeof id === 'string') {
610      background.set(id, { id, type: 'shell', status: 'running', description: e.description, command: e.command, started_at: await nowIso($) })
611      sendBackground($)
612    }
613    return r
614  })
615
616  on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
617    const r: any = await next(e)
618    const id = r?.result?.taskId
619    if (row != null && typeof id === 'string') {
620      background.set(id, { id, type: 'monitor', status: 'running', description: e.description, command: e.command ?? e.ws?.url, started_at: await nowIso($) })
621      sendBackground($)
622    }
623    return r
624  })
625
626  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
627    const r: any = await next(e)
628    const id = e.task_id ?? e.shell_id
629    if (row != null && typeof id === 'string' && !r?.deny && !r?.isError && background.delete(id)) sendBackground($)
630    return r
631  })
632
633  // What is still in flight as main's turn ends, by the engine's own count.
634  // A task's start time is the one its call recorded here, if it was seen.
635  on('classic.Stop', async ($, e: any, next) => {
636    const r = await next(e)
637    if (row != null && Array.isArray(e.background_tasks)) {
638      const startedAt = new Map([...background].map(([id, t]) => [id, t.started_at]))
639      background.clear()
640      for (const t of e.background_tasks) {
641        background.set(t.id, {
642          id: t.id, type: t.type, status: t.status, description: t.description, command: t.command,
643          agent_type: t.agent_type, server: t.server, tool: t.tool, name: t.name, started_at: startedAt.get(t.id),
644        })
645      }
646      sendBackground($)
647    }
648    return r
649  })
650
651  on('agent.spawn', async ($, e, next) => {
652    const r = await next(e)
653    if (row != null && r?.agentId) {
654      agentSpawns.set(r.agentId, { fork: e.fork, background: e.background })
655      pending.push({
656        type: 'agent_started',
657        agent_id: r.agentId,
658        agent_type: e.subagentType,
659        description: e.description,
660        name: e.name,
661        fork: e.fork,
662        background: e.background,
663        parent_agent_id: e.parentAgentId,
664        tool_use_id: e.tool_use_id,
665      })
666      flush($)
667      syncAgents($)
668    }
669    return r
670  })
671
672  // 007: the question is answered here or in claudiverse, whichever comes
673  // first. The terminal dialog opens at once (next) while the server is
674  // polled for a remote answer; returning while next is pending aborts the
675  // dialog, and an answer at the terminal withdraws the remote question. A
676  // seat nobody watches waits for the app; a person at the terminal is never
677  // locked out, and a server that is out leaves the dialog to decide alone.
678  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
679    if (row == null || !cfg) return next(e)
680    await flushNow($)
681    $.ui.status(`claudiverse · ${cfg.title} · question sent: answer here or in claudiverse`)
682    const stop = { done: false }
683    const local = next(e).then(
684      (result) => ({ result }),
685      (error) => ({ error }),
686    )
687    const remote = answerRemotely($, e, stop).then((reply) => (reply ? { reply } : local))
688    const first: any = await Promise.race([local, remote])
689    stop.done = true
690    $.ui.status(`claudiverse · ${cfg.title}`)
691    if (!('reply' in first)) {
692      await withdrawQuestion($, e.tool_use_id)
693      if ('error' in first) throw first.error
694      return first.result
695    }
696    const out = first.reply.hookSpecificOutput
697    if (out.permissionDecision === 'deny') return { deny: out.permissionDecisionReason || 'declined via claudiverse' }
698    // Answered remotely: the hook answers the call itself, and returning
699    // aborts the dialog beneath. Core records this as the tool's result.
700    return {
701      result: {
702        questions: e.questions,
703        answers: out.updatedInput.answers,
704        ...(out.updatedInput.annotations ? { annotations: out.updatedInput.annotations } : {}),
705      },
706    }
707  })
708
709  // A permission prompt is offered in claudiverse too, while its dialog shows
710  // here: the apps can allow or deny it, and an answer at the terminal settles
711  // it there (the server sees the tool's result in the mirror).
712  // An AskUserQuestion's own dialog is a permission prompt too; it is not
713  // relayed, because its question already is (tool.call above). Relayed, the
714  // apps showed the question AND a card asking to allow it (MEASURED
715  // 2026-10-10 by the operator on Failover's three-question ask).
716  on('classic.PermissionRequest', async ($, e, next) => {
717    if (row == null || !cfg || e.tool_name === 'AskUserQuestion') return next(e)
718    await flushNow($)
719    const remote = await decideRemotely($, e)
720    return remote ?? next(e)
721  })
722
723  // 009: tell the server the context was dropped.
724  on('session.compact', async ($, e, next) => {
725    const r = await next(e)
726    if (row != null) {
727      pending.push({ type: 'compact' })
728      flush($)
729    }
730    return r
731  })
732
733  // 005: a command of our own, answered with no turn.
734  // /rename <name>: the seat follows the session's new name. Re-registering on
735  // the same Claude session id updates this row's title; the inbox is keyed by
736  // title, so the poll restarts under the new one. A launcher's
737  // CLAUDIVERSE_TITLE wins (it is re-applied on every restart, so a rename
738  // would revert), as on the patched runtime. A bare /rename (Claude picks
739  // the name) is not followed: the mod cannot read the name it picked.
740  on('command.run', { command: 'rename' }, async ($, e, next) => {
741    const r = await next(e)
742    const name = (e.args || '').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64)
743    if (!cfg || !registration || !name || name === cfg.title) return r
744    if (titlePinned) {
745      $.ui.log(`claudiverse: the seat keeps its launch title ${cfg.title} (CLAUDIVERSE_TITLE)`)
746      return r
747    }
748    cfg.title = name
749    registration.title = name
750    if (await registerSeat($)) {
751      $.ui.status(`claudiverse · ${name}`)
752      poll($, ++pollGen)
753    }
754    return r
755  })
756
757  on('command.run', { command: 'claudiverse' }, async () => ({
758    text: cfg
759      ? `claudiverse: seat ${cfg.title} · session ${row ?? 'not registered'} · ${cfg.url}` +
760        (cfg.host ? ` · host ${cfg.host}` : '') + (cfg.fleet ? ` · fleet ${cfg.fleet}` : '')
761      : 'claudiverse: not configured (CLAUDIVERSE_URL, CLAUDIVERSE_TOKEN and CLAUDIVERSE_TITLE are needed)',
762  }))
763
764  // The main loop's model requests only (a subagent's are not the seat's
765  // conversation). Every chunk is passed on unchanged; mirroring never
766  // touches the session, and a failure in it only thins the live view.
767  on('turn.step', async function* ($, e, next) {
768    if (e.agentId === undefined) lastEffort = e.effort
769    if (row == null || e.agentId !== undefined) return yield* next(e)
770    const open = new Set<number>()
771    streamEvent({ type: 'message_start', message: { role: 'assistant', model: e.model, content: [] } })
772    for await (const c of next(e)) {
773      try {
774        streamChunk(c, open)
775        flush($)
776      } catch {}
777      yield c
778    }
779    for (const i of open) streamEvent({ type: 'content_block_stop', index: i })
780    streamEvent({ type: 'message_stop' })
781    flush($)
782  })
783
784  // The process is leaving (/exit, ctrl+c, logout, a signal): end this row
785  // now. A seat with no socket otherwise reads "running" until the server's
786  // liveness check gives up on it (~3 min), next to the seat that replaced
787  // it. Not on a /clear or an in-session resume: the process goes on (and no
788  // session.start follows a /clear), so the row must stay. Within
789  // session.end's short budget: what is pending, then the frame.
790  on('session.end', async ($, e, next) => {
791    if (row != null && cfg && e.reason !== 'clear' && e.reason !== 'resume') {
792      pending.push({ type: 'session_ended', reason: e.reason, exit_code: 0 })
793      await flushNow($).catch(() => {})
794      row = null
795    }
796    return next(e)
797  })
798
799  on('turn.start', async ($, e, next) => {
800    turnId = e.turnId
801    turnError = null
802    return next(e)
803  })
804
805  // The API error ending a turn: kept for that turn's turn_complete. A
806  // subagent's runs end in a turn.complete of their own, never main's.
807  on('classic.StopFailure', async ($, e: any, next) => {
808    if (e.agent_id === undefined) turnError = { error: e.error, text: e.last_assistant_message }
809    return next(e)
810  })
811
812  on('turn.complete', async ($, e, next) => {
813    const r = await next(e)
814    // An agent's turn ending is the agent's: up to 0.8.8 a fork finishing a
815    // turn told the server main was idle and dropped main's turnId, so the
816    // app's Interrupt could no longer reach a main turn still running.
817    if (e.agentId !== undefined) {
818      if (row != null) {
819        pending.push({ type: 'agent_turn_complete', agent_id: e.agentId })
820        flush($)
821        syncAgents($)
822      }
823      return r
824    }
825    turnId = null
826    // A turn that died on an API error (the pool empty, the stream cut) is
827    // idle all the same, and says why, so the row reads truthfully and the
828    // server's reject-busy delivery (a continue, a cv_send) reaches it.
829    const failed = e.reason === 'error' ? { error: turnError?.error ?? 'unknown', error_text: turnError?.text } : {}
830    turnError = null
831    if (row != null) {
832      pending.push({ type: 'turn_complete', reason: e.reason, ...failed }, { type: 'idle' })
833      flush($)
834    }
835    return r
836  })
837}
838