Feeds the Gander dashboard exact per-session telemetry (context fill, rate-limit windows, cost, every model request, sub-agent spawns) and draws a one-line…

A Claude Code mod (a plugin of in-process function hooks, Claude Code 2.1.287+) that feeds the Gander dashboard what the classic hooks never report, and draws a one-line Gander band above the prompt.
Every event goes to POST http://127.0.0.1:3131/api/mod as JSON with kind, session_id and cwd:
| Hook | kind | Carries |
|---|---|---|
session.start | hello | Claude Code version, model, surface, interactive or not |
session.measure | measure | context tokens / window / percent, rate-limit windows (five_hour, seven_day), session cost in USD |
turn.start | turn-start | turn id |
turn.step | step | one model request: model, effort, message count, the sub-agent id when inside a sub-agent loop |
turn.complete | turn | how the turn ended and its usage (input, output, cache read, cache write, model) |
agent.spawn | spawn | sub-agent type, description, model, agent id |
session.end | bye | why the session ended |
While a session's feed is fresh (under two minutes old), the dashboard shows these figures badged exact in place of the transcript estimate. After that, or after bye, the estimate takes over again.
/ganderAbove the prompt: Gander: 2 need you · $1.25 · ctx 40% · http://localhost:3131/ with a Hide button. It polls the bridge every 15 seconds (configurable) and hides itself while the bridge is unreachable. /gander prints the same line, or how to start the bridge when it is down.
From the dashboard: ⚙ Settings → App configuration → Install mod. The button only appears when the CLI Gander launches is 2.1.287 or newer.
From a shell: node install.js --mods (same gate, prints the reason when it skips).
By hand, in any terminal session:
/plugin marketplace add <path to this checkout>
/plugin install gander-feed@gander
Open sessions load it after /reload-plugins; new sessions load it on start. Sessions hosted by the VS Code extension do not load mods yet, and other providers (Codex, Desktop) never do; Gander's classic hooks keep covering them.
/config, or --config on install)| Field | Default | Meaning |
|---|---|---|
bridgeUrl | http://127.0.0.1:3131 | where the bridge listens |
band | true | draw the band above the prompt |
pollSeconds | 15 | how often the band asks the bridge for the needs-you count |
claude plugin validate mods/gander-feed
claude plugin test mods/gander-feed
claude --plugin-dir mods/gander-feed # load it for one session without installing
Everything the mod does is best-effort: a bridge that is down costs the session nothing and blocks nothing. The hooks that could gate the engine (agent.spawn) carry a fallback that always lets the engine continue.
hooks/register.tsx 181 lines1// gander-feed: the in-process companion to Gander's classic hooks.
2//
3// Classic hooks never see per-request usage, cache state, context fill or the
4// rate-limit windows, so the bridge reconstructs those from transcript files.
5// This module sees them live and POSTs them to the bridge's /api/mod, where
6// they replace the estimates for this session. It also draws a one-line band
7// above the prompt (needs-you count, spend, context fill) and answers /gander.
8//
9// Every network call is best-effort: a bridge that is down costs nothing and
10// blocks nothing; the band hides and the next poll tries again.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { Band } from '../types'
16
17const VERSION = '0.1.0'
18
19const bandAtom = atom({ plugin: 'gander-feed', key: 'band' } as const, null as Band | null)
20const bridgeDown = atom({ plugin: 'gander-feed', key: 'bridgeDown' } as const, false)
21const isHidden = atom({ plugin: 'gander-feed', key: 'isHidden' } as const, false)
22
23// Set by register(); read by the helpers below.
24let bridgeUrl = 'http://127.0.0.1:3131'
25let sessionId: string | null = null
26let cwd = ''
27
28async function sid($: EngineInterface): Promise<string | null> {
29 if (!sessionId) {
30 try { sessionId = await $.session.id() } catch { sessionId = null }
31 }
32 return sessionId
33}
34
35async function markBridge($: EngineInterface, down: boolean): Promise<void> {
36 try {
37 if ((await read($, bridgeDown)) !== down) await update($, bridgeDown, () => down)
38 } catch { /* the environment is gone (a reload, the session's end): nothing to record */ }
39}
40
41async function post($: EngineInterface, kind: string, payload: Record<string, unknown>): Promise<void> {
42 const session_id = await sid($)
43 if (!session_id) return
44 try {
45 const res = await $.http.fetch(`${bridgeUrl}/api/mod`, {
46 method: 'POST',
47 headers: { 'content-type': 'application/json' },
48 body: JSON.stringify({ kind, session_id, cwd, mod: VERSION, at: Date.now(), ...payload }),
49 })
50 await markBridge($, !res.ok)
51 } catch {
52 await markBridge($, true)
53 }
54}
55
56async function poll($: EngineInterface): Promise<void> {
57 const session_id = await sid($)
58 if (!session_id) return
59 try {
60 const res = await $.http.fetch(`${bridgeUrl}/api/mod/band?session_id=${encodeURIComponent(session_id)}`)
61 if (!res.ok) throw new Error(String(res.status))
62 const band = JSON.parse(res.text) as Band
63 try { await update($, bandAtom, () => band) } catch { return }
64 await markBridge($, false)
65 } catch {
66 await markBridge($, true)
67 }
68}
69
70export const register: Register = (on, options) => {
71 bridgeUrl = String(options.bridgeUrl || 'http://127.0.0.1:3131').replace(/\/+$/, '')
72 const wantBand = options.band !== false
73 const pollMs = Math.max(5, Number(options.pollSeconds) || 15) * 1000
74
75 on('session.start', async ($, e, next) => {
76 cwd = e.cwd
77 sessionId = null
78 await sid($)
79 let version = ''
80 let model = ''
81 try { version = (await $.session.version()).version } catch { /* an older engine */ }
82 try { model = await $.session.model() } catch { /* not bound yet */ }
83 void post($, 'hello', { version, model, surface: e.surface, isInteractive: e.isInteractive })
84
85 try {
86 await $.command.register({ name: 'gander', description: 'Show where the Gander dashboard is and whether the bridge is reachable.' })
87 } catch { /* registered by an earlier load */ }
88
89 void poll($)
90 $.clock.every(pollMs, () => { void poll($) })
91
92 return next(e)
93 })
94
95 on('command.run', { command: 'gander' }, async $ => {
96 await poll($)
97 const down = await read($, bridgeDown)
98 const band = await read($, bandAtom)
99 const url = band?.url || bridgeUrl
100 if (down) return { text: `Gander bridge is not answering at ${bridgeUrl}. Start it with: node bridge/server.js --port 3131` }
101 const parts = [`Gander: ${url}`]
102 if (band) {
103 parts.push(`${band.needsYou} session${band.needsYou === 1 ? '' : 's'} need you`)
104 if (band.spendUsd != null) parts.push(`this session $${band.spendUsd.toFixed(2)}`)
105 if (band.ctxPercent != null) parts.push(`context ${band.ctxPercent}%`)
106 }
107 return { text: parts.join(' · ') }
108 })
109
110 // The status line's figures, the moment they move.
111 on('session.measure', async ($, e, next) => {
112 void post($, 'measure', {
113 context: { tokens: e.context.tokens ?? null, window: e.context.window, percent: e.context.percent ?? null },
114 rateLimits: e.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null })),
115 costUsd: e.cost?.usd ?? null,
116 changed: e.changed,
117 })
118 return next(e)
119 })
120
121 on('turn.start', async ($, e, next) => {
122 void post($, 'turn-start', { turnId: e.turnId, textLength: e.text.length })
123 return next(e)
124 })
125
126 // One model request; inside a sub-agent's loop e.agentId names it. The
127 // stream beneath is forwarded untouched.
128 on('turn.step', async function* ($, e, next) {
129 void post($, 'step', {
130 turnId: e.turnId, index: e.index, model: e.model, effort: e.effort ?? null,
131 messageCount: e.messageCount, agentId: e.agentId ?? null,
132 })
133 return yield* next(e)
134 })
135
136 on('turn.complete', async ($, e, next) => {
137 const result = await next(e)
138 const u = result.usage
139 void post($, 'turn', {
140 turnId: e.turnId, reason: e.reason,
141 usage: u ? {
142 model: u.model, input: u.input_tokens, output: u.output_tokens,
143 cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens,
144 } : null,
145 })
146 return result
147 })
148
149 on('agent.spawn', async ($, e, next) => {
150 const result = await next(e)
151 void post($, 'spawn', {
152 toolUseId: e.tool_use_id, subagentType: e.subagentType, description: e.description,
153 model: result.model, agentId: result.agentId ?? null, teammateId: result.teammateId ?? null,
154 })
155 return result
156 }).catch(($, e, next) => next(e)) // an observer: a failure here never stops a spawn
157
158 on('session.end', async ($, e, next) => {
159 await post($, 'bye', { reason: e.reason })
160 return next(e)
161 })
162
163 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
164 if (!wantBand || e.props.hasSurvey) return next(e)
165 const band = await read($, bandAtom)
166 if (band === null || (await read($, bridgeDown)) || (await read($, isHidden))) return next(e)
167
168 const { Box, Button, Text } = $.ui.resolve(e)
169 const needs = band.needsYou > 0 ? `${band.needsYou} need you` : 'nobody waiting'
170 const spend = band.spendUsd != null ? ` · $${band.spendUsd.toFixed(2)}` : ''
171 const ctx = band.ctxPercent != null ? ` · ctx ${band.ctxPercent}%` : ''
172
173 return (
174 <Box>
175 <Text dimColor>Gander: {needs}{spend}{ctx} · {band.url} </Text>
176 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
177 </Box>
178 )
179 })
180}
181types/index.d.ts 25 lines1/** What the Gander band draws: the bridge's answer to GET /api/mod/band. */
2export type Band = {
3 /** Sessions waiting on a person, across every project (the needs-you rail). */
4 needsYou: number
5 /** This session's spend in US dollars, as the engine reports it. */
6 spendUsd: number | null
7 /** This session's context fill, 0 to 100. */
8 ctxPercent: number | null
9 /** The dashboard's address, for the band's hint. */
10 url: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'gander-feed': {
16 /** The last band the bridge answered; null until the first poll. */
17 band: Band | null
18 /** True once a POST to the bridge failed; the band hides, the poll keeps trying. */
19 bridgeDown: boolean
20 /** The person pressed Hide; stays for the session. */
21 isHidden: boolean
22 }
23 }
24}
25