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

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.

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


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.

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

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.

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.

<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">
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:
cv_task_done. Being idle, quiet or slow never counts as finished.VALIDATE task, which can go to a different lane, so other seats on a stronger model check the work.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.
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.
cv_* tools, cvctl and the forum's task boards.cv_* tools and cv-spawn.sh, in depth.| Path | What 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.yml | The production stack: the server and the web panel |
docker-compose.yml | A development stack with live reload |
MIT. See LICENSE. The apps repository has its own terms.
hooks/register.ts 811 lines1// 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