Lets the Bridge cockpit (scripts/bridge-cockpit.py) ask this Claude Code session: heartbeats, takes the questions the cockpit signs and addresses to this…

A Claude Code mod that lets the Bridge cockpit (scripts/bridge-cockpit.py) ask this Claude Code session. "To the control session" on the cockpit page reaches the session through it. Contract and limits: docs/bridge-cockpit.md, section 5.
What it does, and nothing else:
.bridge/cockpit/relay/heartbeat/<session>.json: alive, busy, the question in flight, and where the session runs (the cmux surface, else the tmux pane such as %3, else null), so the server can pick the control session;.bridge/cockpit/relay/inbox/ every 2 seconds and submits a question only when the server addressed it to this session and signed it with relay.key (kept outside the Bridge in $BRIDGE_COCKPIT_KEY_DIR, else ~/.local/state/bridge-cockpit/);.bridge/cockpit/relay/done/<id>.json (answered, aborted, error, expired, stuck, rejected).It came out of briefing-ui, so the cockpit keeps reaching the control session when the briefing card retires. Both may be installed side by side.
cockpit:
enabled: true
relay: true # off unless true: without it the page works, no session takes questions
Only the top-level cockpit: block counts. The relay never runs in an agent tab (BRIDGE_TAB_SLUG set), in a claude -p or SDK run, or outside a Bridge.
From the Bridge folder:
claude plugin marketplace add ./mods --scope local
claude plugin install cockpit-relay@open-bridge-mods --scope local
npx -y -p typescript@5.6.3 -c 'node mods/cockpit-relay/tests/pure.test.mjs' # pure functions
claude plugin test mods/cockpit-relay # engine tests, local onlyhooks/register.tsx 323 lines1// SPDX-License-Identifier: MIT
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { RelaySettings } from '../types'
6import {
7 answerSinceLastUser, doneIdsOf, doneRecord, heartbeat, HEARTBEAT_MS, isFlightTurn, isRelayId, isRelayKey,
8 isSessionId, parseRelayConfig, pickNext, POLL_MS, reasonToStatus, RELAY_DONE, RELAY_HEARTBEAT, RELAY_INBOX,
9 relayKeyPath, rememberId, STORE_RELAY_DONE, STUCK_MS, surfaceOf, TABLES, verifyMac,
10} from './relay'
11import type { InboxRecord, RelayEntry, RelayStatus, Strings } from './relay'
12
13/**
14 * cockpit-relay: the cockpit (scripts/bridge-cockpit.py) reaches this Claude Code session.
15 *
16 * The cockpit server writes a signed question for exactly one session into .bridge/cockpit/relay/inbox;
17 * this mod heartbeats so the server can pick a session, submits the question as a prompt of its own,
18 * and writes what became of it to done/. It does nothing else: no card, no command, no terminal
19 * control. It came out of briefing-ui so the relay outlives the briefing card, and it needs no cmux:
20 * the heartbeat names the cmux surface or the tmux pane when there is one, and null otherwise.
21 *
22 * Early access: written against the function hooks API of Claude Code 2.1.291, which may change
23 * between versions.
24 */
25
26/** The relay settings: null outside a Bridge, in agent tabs, in a -p run and when the config is unreadable */
27const settings = atom({ plugin: 'cockpit-relay', key: 'settings' } as const, null as RelaySettings | null)
28/** A main turn runs in this session: the relay never submits into it */
29const busy = atom({ plugin: 'cockpit-relay', key: 'busy' } as const, false)
30/** The one cockpit question in flight in this session */
31const flight = atom({ plugin: 'cockpit-relay', key: 'flight' } as const, null)
32/** Questions marked stuck while their turn still ran: turn.complete still writes their answer */
33const late = atom({ plugin: 'cockpit-relay', key: 'late' } as const, [])
34/** The session id this session heartbeats under; null while the relay is off */
35const relaySession = atom({ plugin: 'cockpit-relay', key: 'session' } as const, null)
36
37/**
38 * Reads only what the relay needs; which block counts is decided in relay.ts (relayBlock), so the rule
39 * is tested without Python.
40 */
41const READ_CONFIG = [
42 'import json, yaml',
43 "c = yaml.safe_load(open('bridge-config.yaml', encoding='utf-8')) or {}",
44 'c = c if isinstance(c, dict) else {}',
45 "b = c.get('briefing') if isinstance(c.get('briefing'), dict) else {}",
46 "u = b.get('claude_code_ui') if isinstance(b.get('claude_code_ui'), dict) else {}",
47 "l = c.get('language')",
48 "print(json.dumps({'cockpit': c.get('cockpit'), 'ui_language': u.get('language'),",
49 " 'language': l.get('conversation') if isinstance(l, dict) else l}, default=str))",
50].join('\n')
51
52/** The interpreters tried in turn for the config reader */
53const PYTHONS = ['python3', 'python3.14', 'python3.13', 'python3.12', 'python3.11', '/opt/homebrew/bin/python3.14', '/usr/bin/python3']
54
55let words: Strings = TABLES.en
56
57/** Feedback as a brief toast: the mod has no surface of its own */
58function note($: EngineInterface, text: string): void {
59 $.ui.toast(text)
60}
61
62/** The relay settings of this Bridge; null outside a Bridge or when the config cannot be read */
63async function loadSettings($: EngineInterface): Promise<RelaySettings | null> {
64 const cwd = await $.session.cwd()
65 const isBridge =
66 (await $.fs.exists(`${cwd}/bridge-config.yaml`)) && (await $.fs.exists(`${cwd}/scripts/bridge-cockpit.py`))
67 if (!isBridge) return null
68 // python3 first; when it has no PyYAML (a new Python version comes without it until it is installed
69 // there), the next Python that does reads the config, so the relay does not fall silent on an update
70 for (const python of PYTHONS) {
71 try {
72 const run = await $.process.run([python, '-c', READ_CONFIG], { cwd, timeoutMs: 15000 })
73 if (run.exitCode === 0) return parseRelayConfig(JSON.parse(run.stdout))
74 } catch {
75 // not installed, or no answer: try the next
76 }
77 }
78 return null
79}
80
81// ---------------------------------------------------------------- heartbeat and poll
82
83/** A poll is running: a slow file system must not pile polls up */
84let polling = false
85/** Inbox files addressed to another session: read once, never again */
86const foreignRecords = new Set<string>()
87
88/** Where this session runs: the cmux surface, else the tmux pane, else null (relay.ts surfaceOf) */
89async function surface($: EngineInterface): Promise<string | null> {
90 return surfaceOf(await $.env.get('CMUX_SURFACE_ID'), await $.env.get('TMUX_PANE'))
91}
92
93/** The relay of one session: a heartbeat every 5 s, a look into the inbox every 2 s. */
94function startRelay($: EngineInterface, session: string): void {
95 const beat = async () => {
96 try {
97 const cur = await read($, flight)
98 await $.fs.write(`${RELAY_HEARTBEAT}/${session}.json`, JSON.stringify(
99 heartbeat(session, await surface($), await $.clock.now(), await read($, busy), cur?.id ?? null)))
100 } catch {
101 // no heartbeat: the page says the relay is offline and offers the text to copy
102 }
103 }
104 $.clock.after(0, () => void beat())
105 $.clock.every(HEARTBEAT_MS, () => void beat())
106 $.clock.every(POLL_MS, () => void poll($, session))
107}
108
109async function poll($: EngineInterface, session: string): Promise<void> {
110 if (polling) return
111 polling = true
112 try {
113 await pollOnce($, session)
114 } catch {
115 // the next poll tries again
116 } finally {
117 polling = false
118 }
119}
120
121/** Writes done/<id>.json (the mod is its only writer) and remembers the id as handled. */
122async function relayDone($: EngineInterface, session: string, id: string, status: RelayStatus,
123 turnId: string | null, answer: string | null): Promise<void> {
124 if (!isRelayId(id)) return
125 await $.store.set(STORE_RELAY_DONE, rememberId(await $.store.get(STORE_RELAY_DONE), id))
126 try {
127 await $.fs.write(`${RELAY_DONE}/${id}.json`,
128 JSON.stringify(doneRecord(id, session, status, turnId, answer, await $.clock.now())))
129 } catch {
130 // the server shows the record as queued until it expires
131 }
132}
133
134/** relay.key, where the server keeps it (outside the Bridge); null when it cannot be read */
135async function relayKey($: EngineInterface): Promise<string | null> {
136 const path = relayKeyPath(await $.env.get('BRIDGE_COCKPIT_KEY_DIR'), await $.env.get('HOME'))
137 if (!path) return null
138 try {
139 const key = (await $.fs.read(path)).trim()
140 return isRelayKey(key) ? key : null
141 } catch {
142 return null
143 }
144}
145
146async function pollOnce($: EngineInterface, session: string): Promise<void> {
147 const now = await $.clock.now()
148 const cur = await read($, flight)
149 if (cur && now - cur.at > STUCK_MS) {
150 // Free the slot. A turn that was seen and still runs (a long plan, a permission prompt answered
151 // late) keeps its claim: its turn.complete still writes the answer over `stuck`.
152 await relayDone($, session, cur.id, 'stuck', cur.turnId, null)
153 if (cur.turnId !== null) {
154 const claim = { id: cur.id, turnId: cur.turnId }
155 await update($, late, list => [...list.filter(x => x.id !== claim.id), claim].slice(-20))
156 }
157 await update($, flight, f => (f?.id === cur.id ? null : f))
158 note($, words.stuck)
159 }
160 let files: string[]
161 try {
162 files = (await $.fs.list(RELAY_INBOX)).filter(e => e.kind === 'file' && e.name.endsWith('.json')).map(e => e.name)
163 } catch {
164 return
165 }
166 if (files.length === 0) return
167 let doneNames: string[] = []
168 try {
169 doneNames = (await $.fs.list(RELAY_DONE)).map(e => e.name)
170 } catch {
171 // no done/ yet
172 }
173 const stored = await $.store.get(STORE_RELAY_DONE)
174 const handled = new Set([...doneIdsOf(doneNames), ...(Array.isArray(stored) ? stored.filter(isRelayId) : [])])
175 const entries: RelayEntry[] = []
176 let key: string | null | undefined
177 for (const name of files) {
178 if (handled.has(name.slice(0, -5)) || foreignRecords.has(name)) continue
179 let record: unknown
180 try {
181 record = JSON.parse(await $.fs.read(`${RELAY_INBOX}/${name}`))
182 } catch {
183 continue
184 }
185 if (typeof record === 'object' && record !== null && (record as { target_session?: unknown }).target_session !== session) {
186 foreignRecords.add(name)
187 continue
188 }
189 // Read the key only when a record for this session waits; a record it cannot verify is rejected.
190 if (key === undefined) key = await relayKey($)
191 entries.push({ name, record, verified: verifyMac(key, record) })
192 }
193 const inFlight = await read($, flight)
194 const pick = pickNext(entries, handled, session, now, await read($, busy), inFlight?.id ?? null)
195 for (const r of pick.expire) await relayDone($, session, r.id, 'expired', null, null)
196 for (const r of pick.reject) await relayDone($, session, r.id, 'rejected', null, null)
197 if (pick.expire.length) note($, words.expired(pick.expire.length))
198 if (pick.reject.length) note($, words.rejected(pick.reject.length))
199 if (pick.submit) await submit($, session, pick.submit)
200}
201
202/**
203 * Submits one cockpit question as a prompt of its own, framed (never asUser: the model reads that the
204 * cockpit-relay plugin sent it, and the text says it came from the cockpit). Everything that marks it
205 * as handled happens BEFORE the submit: a reload or crash in between can lose a question, never ask it twice.
206 */
207async function submit($: EngineInterface, session: string, rec: InboxRecord): Promise<void> {
208 const at = await $.clock.now()
209 await update($, flight, () => ({ id: rec.id, at, turnId: null }))
210 await relayDone($, session, rec.id, 'submitted', null, null)
211 let refused = false
212 try {
213 // A hook that refuses the prompt resolves with { drop }: the question never entered.
214 const res = await $.prompt.submit({ text: rec.text })
215 refused = typeof res === 'object' && res !== null && typeof (res as { drop?: unknown }).drop === 'string'
216 } catch {
217 refused = true
218 }
219 if (refused) {
220 await relayDone($, session, rec.id, 'error', null, null)
221 await update($, flight, f => (f?.id === rec.id ? null : f))
222 }
223}
224
225/**
226 * What the cockpit turn itself said, when turn.complete brought no answer: the last assistant text after
227 * the last user message (the cockpit prompt), never an earlier answer of the conversation.
228 */
229async function lastAnswer($: EngineInterface): Promise<string> {
230 try {
231 return answerSinceLastUser(await $.session.messages())
232 } catch {
233 return ''
234 }
235}
236
237/** The relay runs in this session (settings is null wherever it must not) */
238async function relayOn($: EngineInterface): Promise<boolean> {
239 const s = await read($, settings)
240 return s !== null && s.enabled && s.relay
241}
242
243// ---------------------------------------------------------------- Module
244
245export const register: Register = on => {
246 on('session.start', async ($, e, next) => {
247 const started = await next(e)
248 // A tab the Bridge started for an item never relays: the cockpit addresses the control session.
249 // A -p or SDK run is never addressed either: it inherits the control tab's surface and would
250 // claim its questions.
251 if (!e.isInteractive || (await $.env.get('BRIDGE_TAB_SLUG'))) return started
252 const found = await loadSettings($)
253 if (!found) return started
254 words = TABLES[found.language]
255 await update($, settings, () => found)
256 if (!found.enabled || !found.relay) return started
257 // The server addresses a question to one session by this id; one it cannot name gets none.
258 const session = await $.session.id().catch(() => '')
259 if (!isSessionId(session)) return started
260 await update($, relaySession, () => session)
261 startRelay($, session)
262 return started
263 })
264
265 // The relay follows the main loop: busy from its turn.start to its turn.complete (a subagent
266 // raises no turn.start, and its turn.complete carries agentId).
267 on('turn.start', async ($, e, next) => {
268 try {
269 if (await relayOn($)) {
270 await update($, busy, () => true)
271 const cur = await read($, flight)
272 if (cur && cur.turnId === null) {
273 // The marker, when the turn shows the text; else a turn with no text right after the submit.
274 // A turn with other text is never the cockpit turn (docs/bridge-cockpit.md, known limits).
275 const isOurs = isFlightTurn(e.text, cur.id, cur.at, await $.clock.now())
276 if (isOurs) await update($, flight, f => (f?.id === cur.id ? { ...f, turnId: e.turnId } : f))
277 }
278 }
279 } catch {
280 // bookkeeping only: the turn runs either way
281 }
282 return next(e)
283 })
284
285 on('turn.complete', async ($, e, next) => {
286 try {
287 if (e.agentId === undefined && (await relayOn($))) {
288 await update($, busy, () => false)
289 const cur = await read($, flight)
290 const held = (await read($, late)).find(x => x.turnId === e.turnId)
291 const claim = cur && cur.turnId !== null && cur.turnId === e.turnId
292 ? { id: cur.id, turnId: cur.turnId } : held ?? null
293 if (claim) {
294 // The transcript only stands in for a turn that ended with an answer; an aborted or failed
295 // turn has none of its own.
296 const answer = e.answer.trim() ? e.answer : e.reason === 'answer' ? await lastAnswer($) : ''
297 if (cur?.id === claim.id) await update($, flight, () => null)
298 if (held) await update($, late, list => list.filter(x => x.id !== held.id))
299 await relayDone($, await $.session.id(), claim.id, reasonToStatus(e.reason), claim.turnId, answer)
300 }
301 }
302 } catch {
303 // bookkeeping only
304 }
305 return next(e)
306 })
307
308 // The session ends: its heartbeat says so at once (at_ms 0), so the server stops addressing it
309 // instead of waiting 15 s for the heartbeat to go stale. The mod stays its only writer.
310 on('session.end', async ($, e, next) => {
311 try {
312 const session = await read($, relaySession)
313 if (session !== null) {
314 await update($, relaySession, () => null)
315 await $.fs.write(`${RELAY_HEARTBEAT}/${session}.json`, JSON.stringify(heartbeat(session, null, 0, false, null)))
316 }
317 } catch {
318 // the heartbeat goes stale after 15 s anyway
319 }
320 return next(e)
321 })
322}
323hooks/relay.ts 389 lines1// SPDX-License-Identifier: MIT
2import type { RelaySettings } from '../types'
3
4/**
5 * The relay between the cockpit (scripts/bridge-cockpit.py, scripts/lib/bridge_cockpit/relay.py) and
6 * this Claude Code session, as pure functions: no `$`, so pure.test.mjs checks them without Claude Code.
7 *
8 * Files (docs/bridge-cockpit.md section 6), each with exactly one writer:
9 * .bridge/cockpit/relay/inbox/<id>.json the server: a question, framed, for ONE session
10 * .bridge/cockpit/relay/done/<id>.json the mod of the target session: what became of it
11 * .bridge/cockpit/relay/heartbeat/<session>.json the mod of that session: alive, busy, in flight
12 *
13 * Why only the server's questions are submitted: each inbox record carries `mac`, an HMAC-SHA256 over
14 * `id|target_session|text` keyed by relay.key, which the server keeps OUTSIDE the Bridge in a 0700
15 * directory ($BRIDGE_COCKPIT_KEY_DIR, else ~/.local/state/bridge-cockpit). A record this session cannot
16 * verify is rejected: a process that can only write inside the repo (an agent tab) cannot hand this
17 * session a prompt.
18 *
19 * Why a question is submitted at most once: the server names one target session per record
20 * (the heartbeat whose surface is the control tab); every other session ignores the record
21 * without writing anything. The target session marks the id as handled (in flight, in its
22 * store, in done/) BEFORE it submits, never submits while a turn runs or another question is
23 * in flight, and skips every id it has a done file or a store entry for.
24 *
25 * This file came over from briefing-ui (hooks/relay.ts) when the relay became a mod of its own, so
26 * the cockpit keeps reaching the control session after the briefing card retires.
27 */
28
29export const RELAY_DIR = '.bridge/cockpit/relay'
30export const RELAY_INBOX = `${RELAY_DIR}/inbox`
31export const RELAY_DONE = `${RELAY_DIR}/done`
32export const RELAY_HEARTBEAT = `${RELAY_DIR}/heartbeat`
33/** Store key: the ids this session has handled, newest last */
34export const STORE_RELAY_DONE = 'cockpit-relay:relay-done'
35/** How many handled ids the store keeps */
36export const DONE_KEEP = 200
37/** The answer written back is cut to this many characters (relay.py ANSWER_MAX) */
38export const ANSWER_MAX = 20000
39/** A framed question longer than this is refused (the server caps the text at 4000) */
40export const TEXT_MAX = 12000
41export const HEARTBEAT_MS = 5000
42export const POLL_MS = 2000
43/** A turn that starts this soon after the submit, with no text at all, carries the question */
44export const CORRELATE_MS = 60000
45/** The key file's name in the key directory (relay.py KEY_FILE) */
46export const KEY_FILE = 'relay.key'
47/** Where the server keeps the key without $BRIDGE_COCKPIT_KEY_DIR, below $HOME (relay.py default_key_dir) */
48export const KEY_HOME_DIR = '.local/state/bridge-cockpit'
49/** In flight for longer than this: done `stuck`, the slot is free again (relay.py STUCK_MS) */
50export const STUCK_MS = 30 * 60000
51/** Must equal .claude-plugin/plugin.json version (pure.test.mjs checks it) */
52export const MOD_VERSION = '0.1.0'
53
54const ID = /^\d{8}T\d{6}-[0-9a-f]{8}$/
55const SESSION = /^[A-Za-z0-9-]{8,64}$/
56
57export type RelayStatus = 'submitted' | 'answered' | 'aborted' | 'error' | 'expired' | 'stuck' | 'rejected'
58/** Every status ends the mod's part except `submitted`, which a turn later advances; none allows a second submit */
59export const STATUSES: readonly RelayStatus[] = ['submitted', 'answered', 'aborted', 'error', 'expired', 'stuck', 'rejected']
60
61/** inbox/<id>.json as the server writes it */
62export type InboxRecord = {
63 v: number
64 id: string
65 created_ms: number
66 expires_ms: number
67 target_session: string
68 kind: string
69 text: string
70 chars: number
71}
72
73/** done/<id>.json as the mod writes it */
74export type DoneRecord = {
75 v: 1
76 id: string
77 session: string
78 status: RelayStatus
79 turn_id: string | null
80 answer: string | null
81 at_ms: number
82}
83
84/** heartbeat/<session>.json as the mod writes it */
85export type Heartbeat = {
86 v: 1
87 session: string
88 surface: string | null
89 at_ms: number
90 busy: boolean
91 in_flight: string | null
92 mod_version: string
93}
94
95/**
96 * One file of relay/inbox: its name, what JSON.parse made of it (null when unreadable), and whether
97 * its mac verified with the server's key (only true lets it be submitted)
98 */
99export type RelayEntry = { name: string; record: unknown; verified?: boolean }
100
101/** What one poll does: write `expired` / `rejected` for these, and submit at most one */
102export type RelayPick = { expire: InboxRecord[]; reject: InboxRecord[]; submit: InboxRecord | null }
103
104/**
105 * The block the relay reads: `cockpit:`, as scripts/lib/bridge_cockpit/config.py load_web_config reads it.
106 * `raw` is what the config reader printed.
107 */
108export function relayBlock(raw: unknown): unknown {
109 const r = isObject(raw) ? raw : {}
110 return r.cockpit
111}
112
113/**
114 * The relay's settings: enabled and relay each only when true (the server's rule), and the language
115 * of its notes: the block's own, else briefing.claude_code_ui.language, else language.conversation.
116 */
117export function parseRelayConfig(raw: unknown): RelaySettings {
118 const r = isObject(raw) ? raw : {}
119 const w = relayBlock(r)
120 const b = isObject(w) ? w : {}
121 const lang = [b.language, r.ui_language, r.language].find(v => typeof v === 'string' && v !== '')
122 return { enabled: b.enabled === true, relay: b.relay === true,
123 language: typeof lang === 'string' && lang.toLowerCase().startsWith('de') ? 'de' : 'en' }
124}
125
126function isObject(v: unknown): v is Record<string, unknown> {
127 return typeof v === 'object' && v !== null && !Array.isArray(v)
128}
129
130/** A tmux pane id as $TMUX_PANE carries it */
131const TMUX_PANE = /^%\d{1,9}$/
132
133/**
134 * The terminal place this session runs in, for the heartbeat: the cmux surface when cmux runs it, else
135 * the tmux pane ("%3"), else null. The server matches it against the control tab's id; a session it
136 * cannot place still heartbeats and is picked when it is the only fresh one.
137 */
138export function surfaceOf(cmuxSurface: string | null | undefined, tmuxPane: string | null | undefined): string | null {
139 if (typeof cmuxSurface === 'string' && cmuxSurface.trim()) return cmuxSurface.trim()
140 if (typeof tmuxPane === 'string' && TMUX_PANE.test(tmuxPane.trim())) return tmuxPane.trim()
141 return null
142}
143
144export function isRelayId(id: unknown): id is string {
145 return typeof id === 'string' && ID.test(id)
146}
147
148/** A session id the server accepts as a heartbeat file name */
149export function isSessionId(id: unknown): id is string {
150 return typeof id === 'string' && SESSION.test(id)
151}
152
153/** The text carries the marker of exactly this record up front */
154export function isFramed(text: unknown, id: string): boolean {
155 return typeof text === 'string' && text.startsWith(`[web:${id}]`)
156}
157
158/** The marker of any record in a turn's text, or null */
159export function markerIn(text: unknown): string | null {
160 if (typeof text !== 'string') return null
161 const hit = /\[web:(\d{8}T\d{6}-[0-9a-f]{8})\]/.exec(text)
162 return hit?.[1] ?? null
163}
164
165/**
166 * Sort the inbox of this session into what to write and what to submit.
167 *
168 * Ignored without a trace: files that are not `<id>.json` of a valid id, records for another
169 * session (its own mod answers them), ids already handled (`doneIds`: done files and the store).
170 * For this session, in file name order: past `expires_ms` gives `expire`; no matching marker,
171 * a version other than 1, no expiry, an oversized text or a mac that did not verify gives `reject`; the first remaining record is
172 * submitted, but only while no turn runs and nothing is in flight. The rest waits for a later poll.
173 */
174export function pickNext(entries: readonly RelayEntry[], doneIds: Iterable<string>, session: string, now: number,
175 busy: boolean, inFlight: string | null): RelayPick {
176 const handled = new Set(doneIds)
177 const out: RelayPick = { expire: [], reject: [], submit: null }
178 const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
179 for (const { name, record, verified } of sorted) {
180 if (typeof record !== 'object' || record === null) continue
181 const r = record as Record<string, unknown>
182 if (!isRelayId(r.id) || name !== `${r.id}.json`) continue
183 if (r.target_session !== session || handled.has(r.id) || r.id === inFlight) continue
184 const rec = r as unknown as InboxRecord
185 if (typeof r.expires_ms === 'number' && now > r.expires_ms) {
186 out.expire.push(rec)
187 continue
188 }
189 if (r.v !== 1 || typeof r.expires_ms !== 'number' || !isFramed(r.text, r.id)
190 || (r.text as string).length > TEXT_MAX || verified !== true) {
191 out.reject.push(rec)
192 continue
193 }
194 if (out.submit === null && !busy && inFlight === null) out.submit = rec
195 }
196 return out
197}
198
199/** The answer as the done file carries it: cut to ANSWER_MAX characters, null when there is none */
200export function clipAnswer(text: unknown): string | null {
201 if (typeof text !== 'string' || !text.trim()) return null
202 return text.length > ANSWER_MAX ? text.slice(0, ANSWER_MAX) : text
203}
204
205/** turn.complete's reason as a done status */
206export function reasonToStatus(reason: unknown): RelayStatus {
207 if (reason === 'answer') return 'answered'
208 if (reason === 'aborted') return 'aborted'
209 return 'error'
210}
211
212export function doneRecord(id: string, session: string, status: RelayStatus, turnId: string | null,
213 answer: unknown, now: number): DoneRecord {
214 return { v: 1, id, session, status, turn_id: turnId, answer: clipAnswer(answer), at_ms: now }
215}
216
217export function heartbeat(session: string, surface: string | null, now: number, busy: boolean,
218 inFlight: string | null): Heartbeat {
219 return { v: 1, session, surface: surface || null, at_ms: now, busy, in_flight: inFlight, mod_version: MOD_VERSION }
220}
221
222/** The ids of a done/ listing: `<id>.json` of valid ids only */
223export function doneIdsOf(names: readonly string[]): string[] {
224 return names.filter(n => n.endsWith('.json')).map(n => n.slice(0, -5)).filter(isRelayId)
225}
226
227/** Remember an id as handled: newest last, at most DONE_KEEP, no duplicates */
228export function rememberId(list: unknown, id: string): string[] {
229 const prev = Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string' && x !== id) : []
230 return [...prev, id].slice(-DONE_KEEP)
231}
232
233// ---------------------------------------------------------------- authenticity (the server's key)
234
235/** Where the server keeps relay.key: $BRIDGE_COCKPIT_KEY_DIR, else ~/.local/state/bridge-cockpit; null without either */
236export function relayKeyPath(keyDir: string | null | undefined, home: string | null | undefined): string | null {
237 if (keyDir) return `${keyDir.replace(/\/$/, '')}/${KEY_FILE}`
238 if (home) return `${home.replace(/\/$/, '')}/${KEY_HOME_DIR}/${KEY_FILE}`
239 return null
240}
241
242/** The key as relay.key holds it: 64 lowercase hex characters */
243export function isRelayKey(key: unknown): key is string {
244 return typeof key === 'string' && /^[0-9a-f]{64}$/.test(key)
245}
246
247const BLOCK = 64
248const K = new Uint32Array([
249 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
250 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
251 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
252 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
253 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
254 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
255 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
256 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
257])
258
259/**
260 * SHA-256 (FIPS 180-4) of the concatenated parts, synchronous: crypto.subtle.digest would settle
261 * outside the session's clock, and a poll has to finish within it.
262 */
263export function sha256(...parts: Uint8Array[]): Uint8Array {
264 const len = parts.reduce((n, p) => n + p.length, 0)
265 const total = Math.ceil((len + 9) / BLOCK) * BLOCK
266 const msg = new Uint8Array(total)
267 let at = 0
268 for (const p of parts) {
269 msg.set(p, at)
270 at += p.length
271 }
272 msg[len] = 0x80
273 const bits = len * 8
274 const view = new DataView(msg.buffer)
275 view.setUint32(total - 8, Math.floor(bits / 0x100000000))
276 view.setUint32(total - 4, bits >>> 0)
277 const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
278 const w = new Uint32Array(64)
279 const rot = (x: number, n: number) => (x >>> n) | (x << (32 - n))
280 for (let off = 0; off < total; off += BLOCK) {
281 for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4)
282 for (let i = 16; i < 64; i++) {
283 const a = w[i - 15] ?? 0
284 const b = w[i - 2] ?? 0
285 const s0 = rot(a, 7) ^ rot(a, 18) ^ (a >>> 3)
286 const s1 = rot(b, 17) ^ rot(b, 19) ^ (b >>> 10)
287 w[i] = ((w[i - 16] ?? 0) + s0 + (w[i - 7] ?? 0) + s1) >>> 0
288 }
289 let [a, b, c, d, e, f, g, hh] = [h[0] ?? 0, h[1] ?? 0, h[2] ?? 0, h[3] ?? 0, h[4] ?? 0, h[5] ?? 0, h[6] ?? 0, h[7] ?? 0]
290 for (let i = 0; i < 64; i++) {
291 const t1 = (hh + (rot(e, 6) ^ rot(e, 11) ^ rot(e, 25)) + ((e & f) ^ (~e & g)) + (K[i] ?? 0) + (w[i] ?? 0)) >>> 0
292 const t2 = ((rot(a, 2) ^ rot(a, 13) ^ rot(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0
293 hh = g
294 g = f
295 f = e
296 e = (d + t1) >>> 0
297 d = c
298 c = b
299 b = a
300 a = (t1 + t2) >>> 0
301 }
302 const add = [a, b, c, d, e, f, g, hh]
303 for (let i = 0; i < 8; i++) h[i] = ((h[i] ?? 0) + (add[i] ?? 0)) >>> 0
304 }
305 const out = new Uint8Array(32)
306 const ov = new DataView(out.buffer)
307 for (let i = 0; i < 8; i++) ov.setUint32(i * 4, h[i] ?? 0)
308 return out
309}
310
311/** HMAC-SHA256 (RFC 2104, hex) over `id|session|text` with the key's ASCII bytes, as the server computes it */
312export function relayMac(key: string, id: string, session: string, text: string): string {
313 const enc = new TextEncoder()
314 let k = enc.encode(key)
315 if (k.length > BLOCK) k = sha256(k)
316 const ipad = new Uint8Array(BLOCK)
317 const opad = new Uint8Array(BLOCK)
318 for (let i = 0; i < BLOCK; i++) {
319 const b = k[i] ?? 0
320 ipad[i] = b ^ 0x36
321 opad[i] = b ^ 0x5c
322 }
323 const inner = sha256(ipad, enc.encode(`${id}|${session}|${text}`))
324 const outer = sha256(opad, inner)
325 return [...outer].map(b => b.toString(16).padStart(2, '0')).join('')
326}
327
328/** The record's mac matches the server's key (compared in full, without an early exit) */
329export function verifyMac(key: string | null, record: unknown): boolean {
330 if (!isRelayKey(key) || typeof record !== 'object' || record === null) return false
331 const r = record as Record<string, unknown>
332 if (typeof r.mac !== 'string' || typeof r.id !== 'string' || typeof r.target_session !== 'string'
333 || typeof r.text !== 'string') return false
334 const want = relayMac(key, r.id, r.target_session, r.text)
335 if (want.length !== r.mac.length) return false
336 let diff = 0
337 for (let i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ r.mac.charCodeAt(i)
338 return diff === 0
339}
340
341// ---------------------------------------------------------------- which turn carries the question
342
343/**
344 * The turn that starts now carries the question in flight: its text shows the record's marker, or it
345 * has no text at all and starts within CORRELATE_MS of the submit. A turn with any other text is never
346 * the web turn (the plugin's prompt always carries the marker): a prompt typed in that moment keeps
347 * its own answer.
348 */
349export function isFlightTurn(text: unknown, flightId: string, flightAt: number, now: number): boolean {
350 const marker = markerIn(text)
351 if (marker !== null) return marker === flightId
352 const empty = typeof text !== 'string' || text.trim() === ''
353 return empty && now - flightAt <= CORRELATE_MS
354}
355
356/** The last assistant text after the last user message: what the web turn itself said, '' when nothing */
357export function answerSinceLastUser(rows: readonly { role: string; text: string }[]): string {
358 for (let i = rows.length - 1; i >= 0; i--) {
359 const row = rows[i]
360 if (!row) continue
361 if (row.role === 'user' && row.text.trim()) return ''
362 if (row.role === 'assistant' && row.text.trim()) return row.text
363 }
364 return ''
365}
366
367// ---------------------------------------------------------------- words
368
369/** The mod's own words: one table per language, identical keys, English the default */
370export type Strings = {
371 expired: (n: number) => string
372 rejected: (n: number) => string
373 stuck: string
374}
375
376const en: Strings = {
377 expired: n => `Cockpit: ${n} question(s) expired, not asked`,
378 rejected: n => `Cockpit: ${n} question(s) without a valid signature refused`,
379 stuck: 'Cockpit: a question has had no answer for 30 min, the next one may come',
380}
381
382const de: Strings = {
383 expired: n => `Cockpit: ${n} Frage(n) abgelaufen, nicht gestellt`,
384 rejected: n => `Cockpit: ${n} Frage(n) ohne gültige Signatur abgewiesen`,
385 stuck: 'Cockpit: eine Frage hängt seit 30 Min. ohne Antwort, die nächste darf kommen',
386}
387
388export const TABLES: Record<'de' | 'en', Strings> = { de, en }
389types/index.d.ts 37 lines1/** The relay's switch as the mod reads it from `cockpit:` */
2export type RelaySettings = {
3 /** cockpit.enabled is true */
4 enabled: boolean
5 /** cockpit.relay is not false: this session heartbeats and takes cockpit questions addressed to it */
6 relay: boolean
7 /** language of the mod's notes: cockpit.language, else briefing.claude_code_ui.language, else language.conversation */
8 language: 'de' | 'en'
9}
10
11/** The cockpit question this session submitted and has not seen answered yet */
12export type RelayFlight = {
13 /** Record id, YYYYMMDDTHHMMSS-<8 hex> */
14 id: string
15 /** When it was submitted (ms) */
16 at: number
17 /** The main turn that carries it, once turn.start named it */
18 turnId: string | null
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'cockpit-relay': {
24 /** The relay settings; null outside a Bridge, in agent tabs, in a -p run or when the config is unreadable */
25 settings: RelaySettings | null
26 /** A main turn of this session runs (turn.start seen, its turn.complete not yet): the relay waits */
27 busy: boolean
28 /** The cockpit question in flight in this session, at most one */
29 flight: RelayFlight | null
30 /** Questions marked stuck while their known turn still ran: its turn.complete still writes the answer */
31 late: { id: string; turnId: string }[]
32 /** The session id this session heartbeats under; null while the relay is off */
33 session: string | null
34 }
35 }
36}
37