SLOPSHOPPER

cockpit-relay

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

newtoastprocesstimer
★ 13v0.1.0MITupdated 2026-10-16bks-lab/open-bridge/mods/cockpit-relay
A shopper browsing a rack in a slop shop
README

cockpit-relay

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:

  • writes a heartbeat every 5 seconds to .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;
  • reads .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/);
  • submits one question at a time, never during a running turn, never twice, and writes what became of it to .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.

Switch

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.

Install and test

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 only
Source 3 files
hooks/register.tsx 323 lines
1// 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}
323
hooks/relay.ts 389 lines
1// 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 }
389
types/index.d.ts 37 lines
1/** 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