A non-blocking decision queue: Claude posts a decision with its context and keeps working; you answer from a pane with one key.

Bridge gives Claude a tool for decisions that are really yours: Claude queues the question with everything needed to judge it and keeps working on what doesn't depend on the answer. You answer from a pane with one key, and the answer reaches Claude as a message, waking it if it was idle.
/plugin marketplace add bloknayrb/claudestuff
/plugin install bridge@claudestuff-marketplace
Requires Claude Code 2.1.292 or newer (function-hook mods).
Claude queues a decision with the mcp__bridge__decide tool. The call returns at once ("Logged as decision #N…") and the turn goes on.
⚑ N decisions pending · /bridge. It only counts; it answers nothing. The band is the notice of a new decision: Bridge raises no toast for one./bridge opens it (it works mid-turn). In fullscreen mode with at least 144 columns, the pane also opens by itself, without taking focus, when a decision arrives. Each card shows the question, the context, 2 to 4 options, Claude's recommendation and why, and why Claude thinks the call is yours.1-4 picks an option, m ("Make it so") takes the recommended one, and o opens Other for your own words (Enter sends). If the pane lacks the keys, ctrl+x tab gives them to it. Later cards' buttons work by Tab or click./bridge with nothing pending prints "Bridge: no decisions pending."Decisions are answered only from the pane. Typing "make it so" or "engage" at the prompt does not answer one: it reaches Claude as an ordinary message, and the decision stays pending. (An earlier build let a typed go-ahead resolve the one pending decision, but the row it appended reached the model only after the turn's first tool call, about 4 s after the prompt, so the path was removed.)
Each answer is appended to the conversation as one row, in exactly one of these shapes:
Bridge decision: {"id":3,"question":"Which database?","choice":"label","label":"SQLite"}
Bridge decision: {"id":3,"question":"Which database?","choice":"other","text":"Neither; use the existing file store"}
The object is written by JSON.stringify with the keys in this order. Only text, present when choice is "other", is the user's own words; question and label are Claude's. A strict consumer can rely on that.
If Claude was idle, Bridge then submits a wake, <bridge-wake/>, which arrives framed as "The bridge plugin sent a message". Answers given together share one wake. An answer given during a turn needs no wake if a later step of that turn reads it; otherwise one wake follows when the turn ends with an answer. After a turn that was interrupted, refused or ended in an error, nothing wakes, and the answer reaches Claude with your next prompt. Bridge never adds anything to a prompt you type.
If a wake cannot be submitted, a toast says "Bridge: answer saved; it reaches Claude with your next prompt." If the row itself cannot be appended, a toast names the decision and it is pending again. These two toasts are the only notice of a lost answer.
/clear and /resume empty the queue (ids restart at 1) and close the pane.m answered two cards at the old 400 ms pause, with a 500 ms repeat delay) and has not yet been re-run live.hooks/delivery.ts (when a row is read, and when an idle wake is due) is shared byte for byte with the Red Team mod, which is not yet published; the copies were checked identical on 2026-10-08. Change both together.
A heartbeat at ~/.claude/state/mods/bridge/<session-id>.json: {"loadedAt": …, "lastError": null}, or lastError: {ts, message} after a hook failure. It is written at session start, on a failure, and at the first main turn after /clear or /resume (the session id changes and no session start fires).
claude plugin test plugins/bridge
claude plugin validate plugins/bridge
tsconfig.json extends .claude-plugin/types/tsconfig.json, which the engine writes when the mod loads (it is gitignored). types/index.d.ts holds the mod's own types (its state, the tool input, answers).
hooks/register.tsx 469 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderViewport } from 'claude-code'
3
4import type { Answer, BridgeSession, DecideInput } from '../types'
5import { addDecision, idForUse, isAnsweredBy, markAnswered, pending, recommendedAnswer, reopen } from './book'
6import { mainTurnEnded, mainTurnStarted, rowAppended, stepBegan } from './delivery'
7import { APPEND_MARK, formatRow, isBlankText } from './row'
8import { denialFor, INPUT_SCHEMA, isSubagentCall, receipt, SUBAGENT_DENIAL, TOOL_DESCRIPTION, TOOL_NAME, validateDecide } from './decide'
9import { freshSession, normalize, resetSession } from './session'
10import { ARM_DELAY_MS, bandTree, PANE_ID, paneTree, PANE_TITLE } from './ui'
11import type { CardActions } from './ui'
12
13// A new decision opens the pane unasked only from this width (a fixed rule); the engine's own
14// floor is 110 for a pane id the person has opened before, so the mod checks 144 itself too.
15const UNASKED_PANE_COLUMNS = 144
16
17// A minimal wake starting with '<'. It is stored plugin-framed; never submit it asUser, or it would
18// pass for typed text.
19const WAKE_TEXT = '<bridge-wake/>'
20const WAKE_RETRY_MS = 500
21const WAKE_FAIL_TOAST = 'Bridge: answer saved; it reaches Claude with your next prompt.'
22
23// The delivery module's row tag for an answer row.
24const ROW_TAG = 'decision'
25
26// The test kit's refusal of a plugin append; a live engine always serves one.
27const KIT_NO_APPEND = 'no implementation for session.append'
28
29// The one state value. Defined here, beside every read/update of it: the 2.1.292 validator refuses a
30// state library read of an atom imported from another file.
31const SESSION = atom({ plugin: 'bridge', key: 'session' } as const, freshSession())
32
33// Module variables are lost on hot reload and refilled by the next start or draw. Nothing the
34// pane depends on lives here; that is in $.state. lastViewport comes from the band's draws, since a
35// tool.call hook has no viewport; unknown means "not fullscreen".
36let lastViewport: RenderViewport | undefined
37let loadedAt = 0
38let heartbeatFor = ''
39
40// Every get of one dispatch reads one moment, so nothing reads SESSION after writing it in
41// the same dispatch. A change computes its own result from the value update hands it, and that result
42// is what the caller acts on. update retries on a version miss; the last run is the one written.
43async function transact<T>($: EngineInterface, change: (s: BridgeSession) => { session: BridgeSession; out: T }): Promise<T> {
44 const box: { out?: T; isRun: boolean } = { isRun: false }
45 await update($, SESSION, value => {
46 const r = change(normalize(value))
47 box.out = r.out
48 box.isRun = true
49 return r.session
50 })
51 if (!box.isRun) throw new Error('bridge: the state update did not run')
52 return box.out as T
53}
54
55async function readSession($: EngineInterface): Promise<BridgeSession> {
56 return normalize(await read($, SESSION))
57}
58
59async function writeHealth($: EngineInterface, error?: string): Promise<void> {
60 try {
61 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
62 if (home === undefined || home === '') return
63 const sessionId = await $.session.id()
64 const now = await $.clock.now()
65 if (loadedAt === 0) loadedAt = now
66 const body = { loadedAt, lastError: error === undefined ? null : { ts: now, message: error } }
67 await $.fs.write(`${home.replace(/\\/g, '/')}/.claude/state/mods/bridge/${sessionId}.json`, JSON.stringify(body))
68 heartbeatFor = sessionId
69 } catch {
70 // Best effort: a failed health write is ignored.
71 }
72}
73
74async function fail($: EngineInterface, where: string, err: unknown): Promise<void> {
75 const message = `${where}: ${err instanceof Error ? err.message : String(err)}`
76 try {
77 $.ui.log(`bridge: ${message}`, { to: 'debug' })
78 } catch {
79 // A .catch handler's own $ calls can reject (2.1.292 re-entry); the health write below is best effort too.
80 }
81 await writeHealth($, message)
82}
83
84async function refreshHeartbeat($: EngineInterface): Promise<void> {
85 // After /clear or /resume the session id changes and no session.start fires; write the new id's
86 // heartbeat once.
87 if (heartbeatFor !== (await $.session.id())) await writeHealth($)
88}
89
90// Runs work that nothing awaits, or that must not fail its caller, so a failure is logged and recorded.
91async function guard($: EngineInterface, where: string, work: Promise<void>): Promise<void> {
92 try {
93 await work
94 } catch (err) {
95 await fail($, where, err)
96 }
97}
98
99async function queueDecision($: EngineInterface, input: DecideInput, useId: string): Promise<number> {
100 const now = await $.clock.now()
101 // The engine always supplies tool_use_id for a tool call, so the random fallback only runs in tests;
102 // a retried call there would not match its first key and could double-queue.
103 const key = useId !== '' ? useId : `local-${now}-${Math.random()}`
104 const id = await transact($, s => {
105 const book = addDecision(s.book, input, key, now)
106 return { session: { ...s, book }, out: idForUse(book, key) }
107 })
108 if (id === undefined) throw new Error('the queued decision is missing from the book')
109 return id
110}
111
112// The engine's record, not the module's (ui.panes): a pane closed by an unload or a refused draw
113// runs none of this plugin's hooks, so the stored isPaneUp can be stale.
114async function isPaneOpen($: EngineInterface): Promise<boolean> {
115 return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
116}
117
118// Open and also the pane the surface shows: a placed pane can sit as a background tab behind another
119// plugin's pane, where the user would see neither it nor, if the band hid, any sign of a decision.
120async function isPaneVisible($: EngineInterface): Promise<boolean> {
121 return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced && pane.isShown)
122}
123
124// The band is the notice of a queued decision (it draws whenever the pane is not visible); this only
125// opens the pane unasked when the screen is wide enough. No toast: none was ever seen from this
126// tool.call path in the dogfood (D2, D4) while the band showed every time, so the band alone is kept.
127async function surfaceDecision($: EngineInterface): Promise<void> {
128 const isUp = await isPaneOpen($)
129 await transact($, s => {
130 if (s.view.isPaneUp === isUp) return { session: s, out: null }
131 return { session: { ...s, view: { ...s.view, isPaneUp: isUp, otherFor: isUp ? s.view.otherFor : null } }, out: null }
132 })
133 // Already open, shown or behind another pane: no reopen. When it is behind, the band shows the count.
134 if (isUp) return
135 const canDock = lastViewport?.isFullscreen === true && lastViewport.columns >= UNASKED_PANE_COLUMNS
136 if (!canDock) return
137 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
138 if (opened.isPlaced) {
139 await transact($, s => ({ session: { ...s, view: { ...s.view, isPaneUp: true } }, out: null }))
140 return
141 }
142 await $.ui.close({ id: PANE_ID })
143}
144
145// The key pause after an answer is a debounce. A press that lands while it runs is taken for a held
146// key's repeat: it does nothing but push the end of the pause out, so a held key never answers the next
147// card until it has been let go for ARM_DELAY_MS. Each pause has its own deadline and its timer clears
148// only that one, so an earlier answer's timer cannot lift a later pause early.
149function startKeysPause($: EngineInterface, until: number): void {
150 $.clock.after(ARM_DELAY_MS, () => void guard($, 'keys', resumeKeys($, until)))
151}
152
153async function resumeKeys($: EngineInterface, until: number): Promise<void> {
154 await transact($, s => (s.view.keysPausedUntil === until ? { session: { ...s, view: { ...s.view, keysPausedUntil: null } }, out: null } : { session: s, out: null }))
155}
156
157// True when the press was swallowed (and the pause restarted).
158async function isDebounced($: EngineInterface): Promise<boolean> {
159 const until = (await $.clock.now()) + ARM_DELAY_MS
160 const isPaused = await transact($, s => (s.view.keysPausedUntil === null ? { session: s, out: false } : { session: { ...s, view: { ...s.view, keysPausedUntil: until } }, out: true }))
161 if (isPaused) startKeysPause($, until)
162 return isPaused
163}
164
165// Takes the session a transact just wrote (never a re-read in the same dispatch). The plugin's own close
166// runs none of its own ui.close hook, so the flag is cleared here, before the close.
167async function closePaneIfDone($: EngineInterface, s: BridgeSession): Promise<void> {
168 if (pending(s.book).length > 0 || !s.view.isPaneUp) return
169 await transact($, cur => ({ session: { ...cur, view: { ...cur.view, isPaneUp: false, otherFor: null } }, out: null }))
170 await $.ui.close({ id: PANE_ID })
171}
172
173async function toggleOther($: EngineInterface, id: number): Promise<void> {
174 const isOpening = await transact($, s => {
175 const otherFor = s.view.otherFor === id ? null : id
176 return { session: { ...s, view: { ...s.view, otherFor } }, out: otherFor === id }
177 })
178 if (!isOpening) return
179 // autoFocus only applies when the site takes the keyboard; a pane already holding it keeps its ring
180 // on the Other button, so move it. A deny (the pane does not hold the keys) is fine: then the user
181 // clicks or tabs into the field, and the hotkeys are off meanwhile anyway.
182 const moved = await $.ui.focus({ requestId: PANE_ID, key: `other-text-${id}` })
183 if (moved.deny !== undefined) $.ui.log(`bridge: focus not moved: ${moved.deny}`, { to: 'debug' })
184}
185
186// The one place this mod appends a row. A row that landed is also logged under APPEND_MARK, which is
187// what the tests read (the kit serves no plugin append). Only the kit's exact refusal counts as landed;
188// a real deny or any other throw is a failed delivery, which must never wake Claude.
189async function appendRow($: EngineInterface, text: string): Promise<{ isAppended: boolean; why: string }> {
190 let result: { isAppended: boolean; why: string }
191 try {
192 const r = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
193 result = r.deny === undefined ? { isAppended: true, why: '' } : { isAppended: false, why: r.deny }
194 } catch (err) {
195 const why = err instanceof Error ? err.message : String(err)
196 result = { isAppended: why.includes(KIT_NO_APPEND), why }
197 }
198 if (result.isAppended) $.ui.log(`${APPEND_MARK}${text}`, { to: 'debug' })
199 return result
200}
201
202// '' when the wake entered, else why not.
203async function submitWake($: EngineInterface): Promise<string> {
204 try {
205 const sent = await $.prompt.submit({ text: WAKE_TEXT })
206 return sent.drop !== undefined ? `dropped: ${sent.drop}` : ''
207 } catch (err) {
208 return `refused: ${err instanceof Error ? err.message : String(err)}`
209 }
210}
211
212// Fire and forget, from whatever dispatch decided to wake. If an engine refuses a direct submit from a
213// press, this could be routed through a timer instead.
214function startWake($: EngineInterface): void {
215 void guard($, 'wake', wakeOnce($))
216}
217
218async function wakeOnce($: EngineInterface): Promise<void> {
219 const why = await submitWake($)
220 if (why === '') return
221 $.ui.log(`bridge: wake not submitted (${why}); retrying once`, { to: 'debug' })
222 $.clock.after(WAKE_RETRY_MS, () => void guard($, 'wake retry', retryWake($)))
223}
224
225// A timer is a dispatch of its own, so this read is fresh. The row's one wake is already spent in the
226// shared module either way; if this fails too, the rows wait for the user's next prompt.
227async function retryWake($: EngineInterface): Promise<void> {
228 const s = await readSession($)
229 // A turn that started meanwhile reads the rows at its first step.
230 if (s.delivery.isMainTurnRunning || !s.isWakeQueued) return
231 const why = await submitWake($)
232 if (why === '') return
233 await transact($, cur => ({ session: { ...cur, isWakeQueued: false }, out: null }))
234 $.ui.log(`bridge: wake not submitted (${why})`, { to: 'debug' })
235 $.ui.toast(WAKE_FAIL_TOAST)
236}
237
238// A press answers while Claude may be idle, so the row may need a wake.
239async function answerWith($: EngineInterface, id: number, answer: Answer): Promise<boolean> {
240 const now = await $.clock.now()
241 const nonce = `${now}-${Math.random()}`
242 const until = now + ARM_DELAY_MS
243 // Claim it. Only the press whose nonce lands owns the delivery; a second press finds it answered.
244 // The same write starts the key pause (a debounce: see isDebounced), under this answer's own deadline.
245 const claimed = await transact($, s => {
246 const book = markAnswered(s.book, id, answer, nonce)
247 if (!isAnsweredBy(book, id, nonce)) return { session: s, out: undefined }
248 return { session: { ...s, book, view: { ...s.view, keysPausedUntil: until } }, out: book.decisions.find(d => d.id === id) }
249 })
250 if (claimed === undefined) return false
251 startKeysPause($, until)
252 const appended = await appendRow($, formatRow(claimed, answer))
253 if (!appended.isAppended) {
254 await transact($, s => ({ session: { ...s, book: reopen(s.book, id, nonce) }, out: null }))
255 $.ui.toast(`Bridge: decision #${id} could not be delivered (${appended.why}); it is pending again.`)
256 await fail($, 'append', appended.why)
257 return false
258 }
259 // Recorded after the append lands, so a step that begins between the two can only cause an extra
260 // wake, never a missed one. The shared module decides whether this row wakes; Bridge only declines
261 // a second submit while one is already queued (its turn then reads this row too).
262 const done = await transact($, s => {
263 const r = rowAppended(s.delivery, ROW_TAG)
264 const isWake = r.isWake && !s.isWakeQueued
265 const view = s.view.otherFor === id ? { ...s.view, otherFor: null } : s.view
266 const after: BridgeSession = { ...s, delivery: r.delivery, isWakeQueued: s.isWakeQueued || isWake, view }
267 return { session: after, out: { isWake, session: after } }
268 })
269 if (done.isWake) startWake($)
270 await closePaneIfDone($, done.session)
271 return true
272}
273
274async function pickOption($: EngineInterface, id: number, index: number): Promise<void> {
275 const decision = (await readSession($)).book.decisions.find(d => d.id === id)
276 const option = decision?.options[index]
277 if (option === undefined) return
278 await answerWith($, id, { choice: 'label', label: option.label })
279}
280
281async function makeItSo($: EngineInterface, id: number): Promise<void> {
282 const decision = (await readSession($)).book.decisions.find(d => d.id === id)
283 if (decision === undefined || decision.status !== 'pending') return
284 await answerWith($, id, recommendedAnswer(decision))
285}
286
287// Blank by Python's str.strip(), which a strict consumer applies: such a row would carry no words of the user's.
288async function sendOther($: EngineInterface, id: number, text: string): Promise<void> {
289 if (isBlankText(text)) return
290 await answerWith($, id, { choice: 'other', text })
291}
292
293// Runs a card press unless the key pause swallows it. sendOther is not gated: it comes from the field's
294// Enter, which is not a hotkey, and only one card has a field.
295async function pressed($: EngineInterface, work: () => Promise<void>): Promise<void> {
296 if (await isDebounced($)) return
297 await work()
298}
299
300function cardActions($: EngineInterface): CardActions {
301 return {
302 pick: (id, index) => void guard($, 'pick', pressed($, () => pickOption($, id, index))),
303 makeItSo: id => void guard($, 'make it so', pressed($, () => makeItSo($, id))),
304 toggleOther: id => void guard($, 'other', pressed($, () => toggleOther($, id))),
305 sendOther: (id, text) => void guard($, 'other', sendOther($, id, text)),
306 }
307}
308
309export const register: Register = on => {
310 on('session.end', async ($, e, next) => {
311 if (e.reason === 'clear' || e.reason === 'resume') {
312 try {
313 const wasUp = await transact($, s => ({ session: resetSession(), out: s.view.isPaneUp }))
314 if (wasUp || (await isPaneOpen($))) await $.ui.close({ id: PANE_ID })
315 } catch (err) {
316 await fail($, 'session.end', err)
317 }
318 }
319 return next(e)
320 })
321
322 // turn.start fires for the main loop only (a subagent's run raises none).
323 on('turn.start', async ($, e, next) => {
324 try {
325 await transact($, s => ({
326 session: { ...s, delivery: mainTurnStarted(s.delivery), isWakeQueued: false },
327 out: null,
328 }))
329 void guard($, 'heartbeat', refreshHeartbeat($))
330 } catch (err) {
331 await fail($, 'turn.start', err)
332 }
333 return next(e)
334 })
335
336 // The shared race guard: "a main step began" before next, so rows appended from here on are not
337 // marked read by this step.
338 on('turn.step', async function* ($, e, next) {
339 if (e.agentId === undefined) {
340 try {
341 await transact($, s => ({ session: { ...s, delivery: stepBegan(s.delivery) }, out: null }))
342 } catch (err) {
343 await fail($, 'turn.step', err)
344 }
345 }
346 return yield* next(e)
347 })
348
349 on('turn.complete', async ($, e, next) => {
350 const done = await next(e)
351 if (e.agentId !== undefined) return done
352 try {
353 const isWake = await transact($, s => {
354 const r = mainTurnEnded(s.delivery, e.reason)
355 const wake = r.isWake && !s.isWakeQueued
356 return {
357 session: { ...s, delivery: r.delivery, isWakeQueued: s.isWakeQueued || wake },
358 out: wake,
359 }
360 })
361 // Not awaited: a submit from here may wait on this very turn ending.
362 if (isWake) startWake($)
363 } catch (err) {
364 await fail($, 'turn.complete', err)
365 }
366 return done
367 })
368
369 on('ui.render', { component: 'Pane', requestId: 'bridge' }, async ($, e, next) => {
370 try {
371 const s = await readSession($)
372 return paneTree($.ui.resolve(e), pending(s.book), s.view, e.surface !== 'mobile', cardActions($))
373 } catch (err) {
374 void fail($, 'pane', err)
375 return next(e)
376 }
377 })
378
379 on('command.run', { command: 'bridge' }, async ($, e, next) => {
380 try {
381 const count = pending((await readSession($)).book).length
382 if (count === 0) return { text: 'Bridge: no decisions pending.' }
383 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true })
384 if (!opened.isPlaced) {
385 await $.ui.close({ id: PANE_ID })
386 return { text: `Bridge: the pane could not be placed (${opened.reason}).` }
387 }
388 // An explicit open: the keys are live at once.
389 await transact($, s => ({ session: { ...s, view: { ...s.view, isPaneUp: true, keysPausedUntil: null } }, out: null }))
390 return { text: `Bridge: ${count} pending. Keys answer the first card; ctrl+x tab gives the pane the keys if it lacks them.` }
391 } catch (err) {
392 await fail($, 'command', err)
393 return next(e)
394 }
395 }).catch(async ($, e, next) => {
396 if (next.error.kind !== 're-entry') await fail($, 'command', next.error.message ?? next.error.kind)
397 return { text: 'Bridge: the command failed; the debug log has the reason.' }
398 })
399
400 // A close by the person (the plugin's own closes set the flag themselves; see closePaneIfDone).
401 on('ui.close', { id: 'bridge' }, async ($, e, next) => {
402 try {
403 await transact($, s => ({ session: { ...s, view: { ...s.view, isPaneUp: false, otherFor: null } }, out: null }))
404 } catch (err) {
405 await fail($, 'ui.close', err)
406 }
407 return next(e)
408 }).catch(async ($, e, next) => {
409 if (next.error.kind !== 're-entry') await fail($, 'ui.close', next.error.message ?? next.error.kind)
410 return next(e)
411 })
412
413 on('session.start', async ($, e, next) => {
414 try {
415 await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
416 await $.command.register({
417 name: 'bridge',
418 description: 'Open the Bridge pane of pending decisions',
419 immediate: true,
420 })
421 // A hot reload keeps $.state but drops timers and may have lost the pane: clear a stranded key
422 // pause, and re-sync isPaneUp from the engine's record before anything trusts it.
423 const isUp = await isPaneOpen($)
424 await transact($, s => ({
425 session: { ...s, view: { ...s.view, isPaneUp: isUp, otherFor: isUp ? s.view.otherFor : null, keysPausedUntil: null } },
426 out: null,
427 }))
428 loadedAt = await $.clock.now()
429 } catch (err) {
430 await fail($, 'session.start', err)
431 }
432 await writeHealth($)
433 return next(e)
434 })
435
436 on('tool.call', { tool: 'mcp__bridge__decide' }, async ($, e) => {
437 if (isSubagentCall(e)) return { deny: SUBAGENT_DENIAL }
438 const checked = validateDecide(e as unknown as Record<string, unknown>)
439 if (!checked.isValid) return { deny: denialFor(checked.problems) }
440 const id = await queueDecision($, checked.input, e.tool_use_id ?? '')
441 // Queued: from here the receipt always goes back, whatever surfacing does.
442 await guard($, 'surface', surfaceDecision($))
443 return { result: receipt(id) }
444 }).catch(async ($, e, next) => {
445 if (next.error.kind !== 're-entry') await fail($, 'tool.call', next.error.message ?? next.error.kind)
446 return next.called
447 ? next(e)
448 : { deny: 'Bridge could not queue the decision (its hook failed). Ask with AskUserQuestion instead.' }
449 })
450
451 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
452 try {
453 if (e.viewport !== undefined) lastViewport = e.viewport
454 const s = await readSession($)
455 const count = pending(s.book).length
456 if (count === 0 || e.props.hasSurvey) return next(e)
457 // The engine's record, not the stored flag: an unload close runs none of our hooks, and a draw may
458 // read the pane list though it may not write state.
459 if (await isPaneVisible($)) return next(e)
460 return bandTree($.ui.resolve(e), count)
461 } catch (err) {
462 // Not awaited: a draw must not wait on the health write (which may be refused mid-draw; it is
463 // best effort). The engine's band draws instead.
464 void fail($, 'band', err)
465 return next(e)
466 }
467 })
468}
469hooks/book.ts 48 lines1import type { Answer, Book, DecideInput, Decision, LabelAnswer } from '../types'
2
3export const EMPTY_BOOK: Book = { decisions: [], nextId: 1 }
4
5export function addDecision(book: Book, input: DecideInput, useId: string, now: number): Book {
6 if (book.decisions.some(d => d.useId === useId)) return book
7 const decision: Decision = { ...input, id: book.nextId, useId, status: 'pending', createdAt: now }
8 return { ...book, decisions: [...book.decisions, decision], nextId: book.nextId + 1 }
9}
10
11export function idForUse(book: Book, useId: string): number | undefined {
12 return book.decisions.find(d => d.useId === useId)?.id
13}
14
15export function pending(book: Book): Decision[] {
16 return book.decisions.filter(d => d.status === 'pending')
17}
18
19export function markAnswered(book: Book, id: number, answer: Answer, nonce: string): Book {
20 return {
21 ...book,
22 decisions: book.decisions.map(d =>
23 d.id === id && d.status === 'pending' ? { ...d, status: 'answered', answer, answerNonce: nonce } : d,
24 ),
25 }
26}
27
28export function isAnsweredBy(book: Book, id: number, nonce: string): boolean {
29 return book.decisions.some(d => d.id === id && d.status === 'answered' && d.answerNonce === nonce)
30}
31
32export function reopen(book: Book, id: number, nonce: string): Book {
33 return {
34 ...book,
35 decisions: book.decisions.map(d => {
36 if (d.id !== id || d.answerNonce !== nonce) return d
37 const { answer: _answer, answerNonce: _nonce, ...rest } = d
38 return { ...rest, status: 'pending' }
39 }),
40 }
41}
42
43export function recommendedAnswer(decision: Decision): LabelAnswer {
44 const option = decision.options[decision.recommend]
45 if (option === undefined) throw new Error(`decision #${decision.id} recommends a missing option`)
46 return { choice: 'label', label: option.label }
47}
48hooks/delivery.ts 79 lines1/**
2 * Delivery bookkeeping for a mod that appends rows for the main loop to read, and may wake it to read them.
3 * Pure and `$`-free, and imports nothing, so another mod
4 * copies this file whole; the `$` calls (the append, the wake prompt) stay in the mod's register file.
5 *
6 * - A main-loop step marks read only the rows appended before that step began.
7 * - Each row causes at most one wake in its life. A turn end that finds several unwoken rows wakes once
8 * for all of them.
9 * - Nothing wakes after a main turn that ended aborted, error or refusal, and that is remembered: a row
10 * appended later, while idle, waits for the next main turn (the user's next prompt) to read it.
11 */
12
13export type EndReason = 'answer' | 'aborted' | 'error' | 'refusal'
14
15export type DeliveryRow = {
16 id: number
17 /** What the row carries, for the mod's own checks (Red Team: 'findings'). */
18 tag: string
19 /** The count of main-loop steps begun when the row was appended; a step that begins later reads it. */
20 step: number
21 /** Whether this row has had its one wake. */
22 hasWoken: boolean
23}
24
25export type DeliveryState = {
26 /** Rows appended and not yet read by a main-loop step, oldest first. */
27 unread: DeliveryRow[]
28 nextId: number
29 /** Main-loop steps begun so far. */
30 steps: number
31 isMainTurnRunning: boolean
32 /** How the last main turn ended; null before any, and again once a new main turn starts. */
33 lastMainEnd: EndReason | null
34}
35
36export const UNREAD_CAP = 32
37
38export function freshDelivery(): DeliveryState {
39 return { unread: [], nextId: 1, steps: 0, isMainTurnRunning: false, lastMainEnd: null }
40}
41
42function canWakeNow(d: DeliveryState): boolean {
43 return !d.isMainTurnRunning && (d.lastMainEnd === null || d.lastMainEnd === 'answer')
44}
45
46/** Records a row the mod has just appended. `isWake`: the mod submits its wake prompt now. */
47export function rowAppended(d: DeliveryState, tag: string): { delivery: DeliveryState; isWake: boolean } {
48 const isWake = canWakeNow(d)
49 const row: DeliveryRow = { id: d.nextId, tag, step: d.steps, hasWoken: isWake }
50 return { delivery: { ...d, nextId: d.nextId + 1, unread: [...d.unread, row].slice(-UNREAD_CAP) }, isWake }
51}
52
53/**
54 * A main-loop step began: every row recorded so far is in its request, so it is read. A row appended later is
55 * not in this step's unread list because it is not recorded yet: the guarantee comes from call order (the mod's
56 * `turn.step` hook runs this before `next(e)`, and `deliver` appends before it records the row), not from
57 * `step`, which is kept as a record of when the row arrived.
58 */
59export function stepBegan(d: DeliveryState): DeliveryState {
60 const steps = d.steps + 1
61 return { ...d, steps, unread: d.unread.filter(r => r.step >= steps) }
62}
63
64export function mainTurnStarted(d: DeliveryState): DeliveryState {
65 return { ...d, isMainTurnRunning: true, lastMainEnd: null }
66}
67
68/** A main turn ended. An answered turn that left unwoken rows unread wakes once for all of them. */
69export function mainTurnEnded(d: DeliveryState, reason: EndReason): { delivery: DeliveryState; isWake: boolean } {
70 const ended: DeliveryState = { ...d, isMainTurnRunning: false, lastMainEnd: reason }
71 if (reason !== 'answer') return { delivery: ended, isWake: false }
72 const isWake = d.unread.some(r => !r.hasWoken)
73 return { delivery: { ...ended, unread: d.unread.map(r => ({ ...r, hasWoken: true })) }, isWake }
74}
75
76export function hasUnread(d: DeliveryState, tag: string): boolean {
77 return d.unread.some(r => r.tag === tag)
78}
79hooks/row.ts 28 lines1import type { Answer, Decision } from '../types'
2
3// The row format is a contract: a strict consumer parses this row and counts only `text` under
4// "other" as the user's words; change both together. The whole row is this prefix plus one object
5// written by JSON.stringify, keys in this order. Every field is encoded, so nothing in a question or
6// label can stand outside its own field or pass for the user's words.
7export const ROW_PREFIX = 'Bridge decision: '
8
9// The append seam's debug-log prefix: every row this mod appends is logged under it once it lands
10// (the test kit serves no plugin append, so this line is what tests read).
11export const APPEND_MARK = 'bridge: append: '
12
13// Python's str.isspace() set, which a strict consumer strips with str.strip(); JS trim() differs (U+0085,
14// U+001C-U+001F, U+FEFF), so Bridge uses this set to refuse an Other text a strict consumer would discard.
15const PY_BLANK = /^[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]*$/
16
17export function isBlankText(text: string): boolean {
18 return PY_BLANK.test(text)
19}
20
21export function formatRow(decision: Pick<Decision, 'id' | 'question'>, answer: Answer): string {
22 const object =
23 answer.choice === 'label'
24 ? { id: decision.id, question: decision.question, choice: 'label', label: answer.label }
25 : { id: decision.id, question: decision.question, choice: 'other', text: answer.text }
26 return ROW_PREFIX + JSON.stringify(object)
27}
28hooks/decide.ts 115 lines1import type { BridgeOption, DecideInput } from '../types'
2
3export const TOOL_NAME = 'decide'
4
5export const TOOL_DESCRIPTION = [
6 'Queue a decision for the user and keep working: the call returns at once with the decision number,',
7 'and the answer arrives later as a user message `Bridge decision: {json}`.',
8 'The user answers only from the Bridge pane (`/bridge`); the band above the prompt only shows how many are pending.',
9 'A prompt the user types, such as "make it so", does not answer a decision: treat it as an ordinary message.',
10 'Use it only for a call that is really the user\'s. Procedural calls are yours: make them.',
11 'If you cannot go on at all without the answer, use AskUserQuestion instead; use this tool when other work does not depend on it.',
12 'Put everything needed to judge on the card, since the user should not have to recall anything:',
13 '`context` (Markdown), 2 to 4 `options` (a short `label`, an optional `detail`),',
14 '`recommend` (the 0-based index of the option you recommend), `why` (why that one),',
15 'and `why_yours` (why this is the user\'s call and not yours).',
16 'In the answer, only `text` (present when `choice` is "other") is the user\'s own words; `question` and `label` are yours.',
17 'Main loop only: a subagent\'s call is refused.',
18].join(' ')
19
20export const INPUT_SCHEMA = {
21 type: 'object',
22 additionalProperties: false,
23 required: ['question', 'context', 'options', 'recommend', 'why', 'why_yours'],
24 properties: {
25 question: { type: 'string', minLength: 1, description: 'The decision, as one question.' },
26 context: { type: 'string', minLength: 1, description: 'Everything needed to judge it, as Markdown.' },
27 options: {
28 type: 'array',
29 minItems: 2,
30 maxItems: 4,
31 items: {
32 type: 'object',
33 additionalProperties: false,
34 required: ['label'],
35 properties: { label: { type: 'string', minLength: 1 }, detail: { type: 'string' } },
36 },
37 },
38 recommend: { type: 'integer', minimum: 0, maximum: 3, description: '0-based index into options.' },
39 why: { type: 'string', minLength: 1, description: 'Why you recommend that option.' },
40 why_yours: { type: 'string', minLength: 1, description: "Why this is the user's call and not yours." },
41 },
42}
43
44// Caps on text length, so one call cannot put an unbounded card in the pane or the stored book.
45const MAX_CHARS = { question: 500, label: 120, detail: 500, context: 4000, why: 1000, why_yours: 1000 } as const
46
47export type Checked = { isValid: true; input: DecideInput } | { isValid: false; problems: string[] }
48
49function isText(value: unknown): value is string {
50 return typeof value === 'string' && value.trim() !== ''
51}
52
53// The engine documents no check of a plugin tool's input against its schema, so the hook checks every
54// field itself.
55export function validateDecide(raw: Record<string, unknown>): Checked {
56 const problems: string[] = []
57 for (const key of ['question', 'context', 'why', 'why_yours'] as const) {
58 const value = raw[key]
59 if (!isText(value)) problems.push(`${key} must be a non-empty string`)
60 else if (value.length > MAX_CHARS[key]) problems.push(`${key} is ${value.length} characters; the limit is ${MAX_CHARS[key]}`)
61 }
62 const options: BridgeOption[] = []
63 const given = raw.options
64 if (!Array.isArray(given) || given.length < 2 || given.length > 4) {
65 problems.push('options must be an array of 2 to 4 items')
66 } else {
67 given.forEach((option: unknown, i) => {
68 const o = (option ?? {}) as Record<string, unknown>
69 if (!isText(o.label)) problems.push(`options[${i}].label must be a non-empty string`)
70 else if (o.label.trim().length > MAX_CHARS.label) {
71 problems.push(`options[${i}].label is ${o.label.trim().length} characters; the limit is ${MAX_CHARS.label}`)
72 }
73 if (o.detail !== undefined && typeof o.detail !== 'string') problems.push(`options[${i}].detail must be a string`)
74 else if (typeof o.detail === 'string' && o.detail.length > MAX_CHARS.detail) {
75 problems.push(`options[${i}].detail is ${o.detail.length} characters; the limit is ${MAX_CHARS.detail}`)
76 }
77 // Stored trimmed, so the uniqueness check below and the answer's `label` agree on what the label is.
78 options.push({ label: String(o.label ?? '').trim(), ...(typeof o.detail === 'string' ? { detail: o.detail } : {}) })
79 })
80 if (new Set(options.map(o => o.label.trim())).size !== options.length) problems.push('option labels must differ')
81 }
82 const count = Array.isArray(given) ? given.length : 0
83 const recommend = raw.recommend
84 if (typeof recommend !== 'number' || !Number.isInteger(recommend) || recommend < 0 || recommend >= Math.max(count, 1)) {
85 problems.push(`recommend must be 0 to ${Math.max(count - 1, 0)}, the 0-based index of an option`)
86 }
87 if (problems.length > 0) return { isValid: false, problems }
88 return {
89 isValid: true,
90 input: {
91 question: raw.question as string,
92 context: raw.context as string,
93 options,
94 recommend: recommend as number,
95 why: raw.why as string,
96 why_yours: raw.why_yours as string,
97 },
98 }
99}
100
101export function denialFor(problems: string[]): string {
102 return `Bridge did not queue the decision: ${problems.join('; ')}. Fix the input and call again.`
103}
104
105export const SUBAGENT_DENIAL =
106 'Bridge: only the main loop can queue decisions, since a subagent may be gone when the answer arrives. Put the question in your report and let the main loop queue it.'
107
108export function isSubagentCall(e: { agentId?: string }): boolean {
109 return e.agentId !== undefined
110}
111
112export function receipt(id: number): string {
113 return `Logged as decision #${id}. Keep going on what doesn't depend on it; the answer arrives as a message.`
114}
115hooks/session.ts 31 lines1import type { BridgeSession } from '../types'
2import { EMPTY_BOOK } from './book'
3import { freshDelivery } from './delivery'
4
5export function freshSession(): BridgeSession {
6 return {
7 book: EMPTY_BOOK,
8 delivery: freshDelivery(),
9 isWakeQueued: false,
10 view: { isPaneUp: false, otherFor: null, keysPausedUntil: null },
11 }
12}
13
14// Fills fields a value written by an older build of this module lacks (a hot reload keeps $.state), and
15// drops any it no longer has, since only the fields named here are copied.
16export function normalize(value: Partial<BridgeSession> | undefined): BridgeSession {
17 const fresh = freshSession()
18 if (value === undefined) return fresh
19 return {
20 book: value.book ?? fresh.book,
21 delivery: value.delivery ?? fresh.delivery,
22 isWakeQueued: value.isWakeQueued ?? false,
23 view: { ...fresh.view, ...(value.view ?? {}) },
24 }
25}
26
27// /clear and an in-process /resume: nothing of the old conversation survives.
28export function resetSession(): BridgeSession {
29 return freshSession()
30}
31hooks/ui.tsx 123 lines1import type { Elements } from 'claude-code'
2
3import type { Decision, ViewState } from '../types'
4
5export const PANE_ID = 'bridge'
6export const PANE_TITLE = 'Bridge'
7
8// After an answer, the keys are paused for this long, and each press during the pause restarts it (a
9// debounce), so a double-tap or a held key's repeat cannot answer a card the user has not read.
10// 1100 ms because a held key's first repeat comes only after the OS repeat delay, and Windows allows
11// 250-1000 ms, so this is above its largest. A pause shorter than the delay ends before the first repeat,
12// which then lands as a fresh press on the next card (seen live at 400 ms with a 500 ms delay). macOS and
13// X11 can be set slower than 1100 ms, and there a held key can still answer the next card.
14export const ARM_DELAY_MS = 1100
15
16// What the trees need from a surface's table. Typing UI is gated on the surface, never on whether the
17// table has an Input: every surface's table hands one out, and on mobile it draws nothing.
18export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Markdown'> & {
19 Input?: Elements['terminal']['Input']
20}
21
22export function bandTree(els: Pick<Els, 'Box' | 'Text'>, count: number) {
23 const { Box, Text } = els
24 const noun = count === 1 ? 'decision' : 'decisions'
25 return (
26 <Box key="bridge-band">
27 <Text>{`⚑ ${count} ${noun} pending · /bridge`}</Text>
28 </Box>
29 )
30}
31
32export type CardActions = {
33 pick: (id: number, index: number) => void
34 makeItSo: (id: number) => void
35 toggleOther: (id: number) => void
36 sendOther: (id: number, text: string) => void
37}
38
39function hotkey(isArmed: boolean, key: string): { hotkey?: string } {
40 return isArmed ? { hotkey: key } : {}
41}
42
43function card(els: Els, d: Decision, isArmed: boolean, canType: boolean, isOtherOpen: boolean, act: CardActions) {
44 const { Box, Text, Button, Markdown, Input } = els
45 return (
46 <Box key={`card-${d.id}`} flexDirection="column" borderStyle="round" paddingX={1}>
47 <Text bold>{`#${d.id} ${d.question}`}</Text>
48 <Markdown key={`context-${d.id}`} text={d.context} />
49 {d.options.map((option, i) => (
50 <Box key={`option-${d.id}-${i}`} flexDirection="column">
51 <Box flexDirection="row" gap={1}>
52 <Button
53 key={`pick-${d.id}-${i}`}
54 plain
55 label={option.label}
56 {...hotkey(isArmed, String(i + 1))}
57 onPress={() => act.pick(d.id, i)}
58 />
59 {i === d.recommend && <Text bold>(recommended)</Text>}
60 </Box>
61 {option.detail !== undefined && option.detail !== '' && <Text dimColor>{option.detail}</Text>}
62 </Box>
63 ))}
64 <Text>{`Why ${d.recommend + 1}: ${d.why}`}</Text>
65 <Text dimColor>{`Why it is yours: ${d.why_yours}`}</Text>
66 <Box flexDirection="row" gap={2}>
67 <Button
68 key={`make-${d.id}`}
69 variant="primary"
70 label="Make it so"
71 {...hotkey(isArmed, 'm')}
72 onPress={() => act.makeItSo(d.id)}
73 />
74 {canType && (
75 <Button key={`other-${d.id}`} label="Other" {...hotkey(isArmed, 'o')} onPress={() => act.toggleOther(d.id)} />
76 )}
77 </Box>
78 {canType && isOtherOpen && Input !== undefined && (
79 <Input
80 key={`other-text-${d.id}`}
81 placeholder="Your answer; Enter sends it"
82 submitLabel="send"
83 autoFocus
84 onSubmit={value => act.sendOther(d.id, value)}
85 />
86 )}
87 </Box>
88 )
89}
90
91// canType: false on mobile, where an Input draws nothing (gate on the surface, not the table).
92export function paneTree(
93 els: Els,
94 open: readonly Decision[],
95 view: Pick<ViewState, 'otherFor' | 'keysPausedUntil'>,
96 canType: boolean,
97 act: CardActions,
98) {
99 const { Box, Text } = els
100 const first = open[0]
101 if (first === undefined) {
102 return (
103 <Box key="bridge-empty">
104 <Text dimColor>No decisions pending.</Text>
105 </Box>
106 )
107 }
108 // The hotkeys stay bound during a pause: a press then only restarts the pause (see register.tsx).
109 const isArmed = view.otherFor === null
110 const keysLine = view.otherFor !== null
111 ? 'Typing an Other answer: Enter sends it; Tab to Other and press Enter (or click it) to cancel.'
112 : view.keysPausedUntil !== null
113 ? 'Keys paused a moment: let go of the key.'
114 : `Keys answer #${first.id}: 1-${first.options.length} pick · m make it so${canType ? ' · o other' : ''}`
115 return (
116 <Box key="bridge-pane" flexDirection="column">
117 <Text bold>{`${open.length} pending`}</Text>
118 <Text dimColor>{keysLine}</Text>
119 {open.map((d, n) => card(els, d, n === 0 && isArmed, canType, view.otherFor === d.id, act))}
120 </Box>
121 )
122}
123types/index.d.ts 74 lines1// Copied from the shared delivery module (Red Team's hooks/delivery.ts) because a types contract may
2// not import. If that module's DeliveryRow/DeliveryState change, change these to match; tsc then checks
3// that register.tsx passes the module's values through unchanged.
4export type EndReason = 'answer' | 'aborted' | 'error' | 'refusal'
5export type DeliveryRow = { id: number; tag: string; step: number; hasWoken: boolean }
6export type DeliveryState = {
7 unread: DeliveryRow[]
8 nextId: number
9 steps: number
10 isMainTurnRunning: boolean
11 lastMainEnd: EndReason | null
12}
13
14export type BridgeOption = { label: string; detail?: string }
15
16export type DecideInput = {
17 question: string
18 context: string
19 options: BridgeOption[]
20 recommend: number
21 why: string
22 why_yours: string
23}
24
25export type LabelAnswer = { choice: 'label'; label: string }
26export type OtherAnswer = { choice: 'other'; text: string }
27export type Answer = LabelAnswer | OtherAnswer
28
29// 'answered' covers every way a decision closes from the pane: a pick, Other, and Make it so (button or
30// the m key). Each one appends a row, so there is no separate 'resolved' state.
31export type DecisionStatus = 'pending' | 'answered'
32
33export type Decision = DecideInput & {
34 id: number
35 // The tool_use_id that queued it: makes queueing idempotent and lets the hook find its own id.
36 useId: string
37 status: DecisionStatus
38 answer?: Answer
39 // Set with the answer so the press that set it (and only that one) delivers it.
40 answerNonce?: string
41 createdAt: number
42}
43
44export type Book = { decisions: Decision[]; nextId: number }
45
46// isPaneUp: Bridge's own record of whether its pane is open; re-synced from $.ui.panes() before it is
47// trusted for surfacing. otherFor: the card whose Other field is open (its hotkeys are off meanwhile).
48// keysPausedUntil: the clock time the key pause after an answer ends, or null. During it a press on a
49// card is a no-op that pushes the end out again (a debounce), so a held key never answers the next card
50// until it has been let go for the whole delay. Each pause's timer clears only its own deadline.
51export type ViewState = { isPaneUp: boolean; otherFor: number | null; keysPausedUntil: number | null }
52
53export type BridgeSession = {
54 book: Book
55 // The shared module's state (copied from Red Team, hooks/delivery.ts).
56 delivery: DeliveryState
57 // A wake was submitted and its turn has not started yet: a second idle answer rides on it.
58 isWakeQueued: boolean
59 view: ViewState
60}
61
62declare module 'claude-code' {
63 interface PluginState {
64 bridge: { session: BridgeSession }
65 }
66 // The plugin's own tool. When MCP servers are connected the engine-written claude-code-mcp types fill
67 // McpToolInputs, the loose fallback goes away, and a tool.call matcher naming a tool missing from the
68 // table no longer type-checks. If a later engine writes this tool
69 // into claude-code-mcp itself with another type, the merge conflicts: then delete this entry.
70 interface McpToolInputs {
71 mcp__bridge__decide: Record<string, unknown>
72 }
73}
74