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

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

Quick start

You need Docker on the server, and Node.js 22+, tmux and Claude Code 2.1.287+ on each machine that runs seats. The setup guide walks through every step. In short:

# The server
git clone https://github.com/Promises/claudiverse-oss.git claudiverse
cd claudiverse
cp .env.example .env    # set SECRET_KEY_BASE, CV_VAULT_KEY and the URLs
docker compose -f compose.prod.yml --env-file .env up -d --build
docker compose -f compose.prod.yml logs server | grep -A3 "setup code"

Then open the web panel, create your account with the setup code, and add your Claude accounts under Accounts. On each machine, install the claudiverse-seat mod and the host agent, and start a seat.

Documentation

What's in this repository

PathWhat it is
claudiverse_server/The server: Elixir 1.19, Phoenix 1.8, SQLite
web/The web panel: Vite, React, TypeScript
mods/claudiverse-seat/The Claude Code mod that makes a session a seat
orchestrator/The cv_* MCP server, cvctl, cv-spawn.sh and cv-runner
docs/The setup guide, deployment and design notes
compose.prod.ymlThe production stack: the server and the web panel
docker-compose.ymlA development stack with live reload

The other repositories

Licence

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

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