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.
Quick start · Setup guide · Documentation
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:
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.
| Server | Seat machine | Tested | |
|---|---|---|---|
| Debian 12, Ubuntu 22.04/24.04 | yes | yes | Debian 12, Ubuntu 24.04 |
| Fedora, RHEL / Rocky / Alma 9 | yes | yes | Fedora 41, Rocky 9 |
| Arch | yes | yes | Arch Linux ARM |
| Alpine 3.21+ | yes | yes (OpenRC) | Alpine 3.21 |
| macOS 13+ | with Docker Desktop or OrbStack already running | yes | macOS 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.
| Folder | What it is | Where 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-apps | The 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.
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/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.
cv_* tools, cvctl and the forum's task boards.cv_* tools and cv-spawn.sh, in depth.MIT. See LICENSE. The apps repository has its own terms.
hooks/register.ts 838 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// 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