A live pane for what surrounds the session: cost, context, git, PR checks, your own gauges, an inbox for messages from other sessions, and an away-mode summary

A live pane for what surrounds the session. /cockpit opens it.
| Section | What it shows | Source |
|---|---|---|
| cost, context | session cost, context fill | session.measure and $.session.usage(), pushed by the engine |
| git | branch and number of changed paths | git status --porcelain -b, every refreshSeconds and after each turn |
| pr checks | pass, fail and pending counts for the current branch's PR | gh pr checks --json name,state; the row is absent without gh, a PR or checks |
| your gauges | whatever you configure | MCP tool, command or HTTP URL |
| Inbox | messages other sessions or agents sent you | session.receive |
Every row has a ↻ button; ↻ refresh all and /cockpit refresh everything. Numeric readings keep the last 40 values and draw a sparkline: a Raster on the terminal, block characters (▁▃█) on other surfaces.
gauges is a JSON array:
[
{ "label": "Queue", "kind": "mcp", "server": "ops", "tool": "queue_depth", "args": { "queue": "main" }, "every": 30, "pick": "data.depth" },
{ "label": "Load", "kind": "command", "argv": ["uptime"], "every": 15, "pick": "/load averages?: ([\\d.]+)/" },
{ "label": "Health", "kind": "http", "url": "https://example.com/health.json", "every": 60, "pick": "status" }
]
every is seconds (default 60, minimum 5). Each gauge has its own timer.pick is a dotted path into JSON (data.items.0.count) or a /regex/ (first capture group, else the match). Without pick, the first non-empty line is shown.42, 12.5%, 1,200) is numeric and gets history. Anything else is shown as text.Messages from peers and other sessions (peer, peer-send-message, projects-relay, slack-ping deliveries) are listed in the pane (the last 30) and raise a toast. With quietInbox on they are consumed: they do not reach the transcript or the model, so you must look at the pane. Your own prompts (including Remote Control), harness wakes and messages inside a subagent are never touched.
When a turn ends with an answer and you have been idle longer than awayMinutes, a summary is sent.
ioreg -c IOHIDSystem (HIDIdleTime). Elsewhere the idle time is unknown, and an unknown idle time counts as present, so nothing is sent. Set presenceServer and presenceTool to read the idle seconds from an MCP tool instead (it must answer a number, bare or in JSON).answer (default) is the first 300 characters of the final answer. haiku makes one small model call per away turn for a one-sentence summary and falls back to answer if it fails.$.ui.notify by default. Set awayServer, awayTool and optionally awayArgs to send through an MCP tool, for instance a chat bot. awayArgs is a JSON object; every string containing {{text}} gets the summary (the value is inserted after parsing, so quotes in the summary cannot break the JSON). Empty means {"text": "{{text}}"}. If the MCP call fails, a notification is used.| Setting | Default | |
|---|---|---|
gauges | [] | custom gauges, JSON |
refreshSeconds | 30 | git and PR refresh period |
quietInbox | off | consume inbox messages |
awayMode | on | summary when you are away |
awayMinutes | 5 | idle time that counts as away |
awaySummary | answer | answer or haiku |
presenceServer, presenceTool, presenceArgs | empty | presence from an MCP tool |
awayServer, awayTool, awayArgs | empty | away message through an MCP tool |
No model calls unless awaySummary is haiku (one Haiku call per turn that ends while you are away). Timers run git status and gh pr checks every refreshSeconds, plus your gauges at their own rates; command gauges run whatever argv you configured, so only put your own commands there.
ioreg presence is macOS only.origin; deliveries the engine cannot classify (unclassified) are left alone.hooks/register.tsx 328 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AwayInfo, InboxItem, Reading } from '../types'
5import {
6 clip,
7 fillTemplate,
8 formatUsd,
9 mcpText,
10 parseChecks,
11 parseGauges,
12 parseGitStatus,
13 parseIdleSeconds,
14 parseIdleText,
15 parseJsonObject,
16 pickValue,
17 pushHistory,
18 sparkCells,
19 summarize,
20 textSparkline,
21 toNumber,
22} from './lib'
23import type { GaugeSpec } from './lib'
24
25const PANE = 'cockpit'
26const INBOX_LIMIT = 30
27const INBOX_SHOWN = 6
28// Deliveries from other sessions and agents; the user's own prompts (bridge) and the harness's wakes stay out.
29const INBOX_KINDS = new Set(['peer', 'peer-send-message', 'projects-relay', 'slack-ping'])
30
31const readings = atom({ plugin: 'cockpit', key: 'readings' } as const, {} as Record<string, Reading>)
32const inbox = atom({ plugin: 'cockpit', key: 'inbox' } as const, [] as InboxItem[])
33const away = atom({ plugin: 'cockpit', key: 'away' } as const, {} as AwayInfo)
34const configError = atom({ plugin: 'cockpit', key: 'configError' } as const, '')
35
36type AwayConfig = {
37 isOn: boolean
38 idleSeconds: number
39 summary: string
40 presenceServer: string
41 presenceTool: string
42 presenceArgs: string
43 awayServer: string
44 awayTool: string
45 awayArgs: string
46}
47
48const str = (v: unknown): string => (typeof v === 'string' ? v : '')
49
50async function setReading($: EngineInterface, id: string, text: string, num?: number) {
51 const at = await $.clock.now()
52 await update($, readings, all => {
53 const prev = all[id]
54 const history = num === undefined ? (prev?.history ?? []) : pushHistory(prev?.history ?? [], num)
55 return { ...all, [id]: { text, num, history, at } }
56 })
57}
58
59async function failReading($: EngineInterface, id: string, error: string) {
60 await update($, readings, all => {
61 const prev = all[id]
62 return { ...all, [id]: { text: prev?.text ?? '–', num: prev?.num, history: prev?.history ?? [], at: prev?.at ?? 0, error } }
63 })
64}
65
66async function dropReading($: EngineInterface, id: string) {
67 await update($, readings, all => {
68 const { [id]: _gone, ...rest } = all
69 return rest
70 })
71}
72
73async function refreshSession($: EngineInterface) {
74 const u = await $.session.usage().catch(() => undefined)
75 if (!u) return
76 await applyUsage($, u.cost?.usd, u.context.percent, u.context.tokens, u.context.window)
77}
78
79async function applyUsage($: EngineInterface, usd: number | undefined, percent: number | undefined, tokens: number | undefined, window: number) {
80 if (usd !== undefined) await setReading($, 'cost', formatUsd(usd), usd)
81 if (percent !== undefined) {
82 const k = tokens === undefined ? '' : ` (${Math.round(tokens / 1000)}k of ${Math.round(window / 1000)}k)`
83 await setReading($, 'context', `${Math.round(percent)}%${k}`, percent)
84 }
85}
86
87async function refreshGit($: EngineInterface) {
88 const r = await $.process.run(['git', 'status', '--porcelain', '-b'], { timeoutMs: 10000 }).catch(() => undefined)
89 if (!r || r.exitCode !== 0) return dropReading($, 'git')
90 const { branch, dirty } = parseGitStatus(r.stdout)
91 await setReading($, 'git', `${branch}${dirty ? ` · ${dirty} changed` : ' · clean'}`, dirty)
92}
93
94// No gh, no PR for this branch or no checks: the gauge simply is not there.
95async function refreshPr($: EngineInterface) {
96 const r = await $.process.run(['gh', 'pr', 'checks', '--json', 'name,state'], { timeoutMs: 20000 }).catch(() => undefined)
97 const summary = r ? parseChecks(r.stdout) : undefined
98 if (!summary) return dropReading($, 'pr')
99 await setReading($, 'pr', summary.text, summary.failing)
100}
101
102async function refreshBuiltins($: EngineInterface) {
103 await Promise.all([refreshGit($), refreshPr($)])
104}
105
106async function fetchRaw($: EngineInterface, spec: GaugeSpec): Promise<string> {
107 if (spec.kind === 'mcp') {
108 const r = await $.mcp.call(spec.server ?? '', spec.tool ?? '', spec.args)
109 const text = mcpText(r)
110 if (r.isError) throw new Error(clip(text, 80) || 'the tool reported an error')
111 return text
112 }
113 if (spec.kind === 'command') {
114 const r = await $.process.run(spec.argv ?? [], { timeoutMs: 20000 })
115 if (r.exitCode !== 0) throw new Error(`exit ${r.exitCode}: ${clip(r.stderr, 80)}`)
116 return r.stdout
117 }
118 const r = await $.http.fetch(spec.url ?? '')
119 if (!r.ok) throw new Error(`HTTP ${r.status}`)
120 return r.text
121}
122
123// A gauge whose source hangs (an MCP or HTTP call has no timeout) must not stack one more call every period.
124const inFlight = new Set<string>()
125// A gauge whose source is down skips 1, 3, 7, then 15 scheduled ticks between tries, so an outage is not polled from every session.
126const failing = new Map<string, { count: number; skip: number }>()
127
128async function refreshGauge($: EngineInterface, id: string, spec: GaugeSpec, isScheduled = false) {
129 const down = failing.get(id)
130 if (isScheduled && down && down.skip > 0) {
131 down.skip -= 1
132 return
133 }
134 if (inFlight.has(id)) return
135 inFlight.add(id)
136 try {
137 const picked = pickValue(await fetchRaw($, spec), spec.pick)
138 if (picked === undefined) return await failReading($, id, spec.pick ? `nothing matched ${spec.pick}` : 'empty answer')
139 failing.delete(id)
140 await setReading($, id, clip(picked, 60), toNumber(picked))
141 } catch (err) {
142 const count = (failing.get(id)?.count ?? 0) + 1
143 failing.set(id, { count, skip: 2 ** Math.min(count, 4) - 1 })
144 await failReading($, id, clip(err instanceof Error ? err.message : String(err), 80))
145 } finally {
146 inFlight.delete(id)
147 }
148}
149
150async function refreshAll($: EngineInterface, gauges: readonly GaugeSpec[]) {
151 await Promise.all([refreshSession($), refreshBuiltins($), ...gauges.map((g, i) => refreshGauge($, `g${i}`, g))])
152}
153
154// Seconds since the last keyboard or mouse event; undefined when it cannot be known (then the person counts as present).
155async function idleSeconds($: EngineInterface, cfg: AwayConfig): Promise<number | undefined> {
156 if (cfg.presenceServer && cfg.presenceTool) {
157 const args = parseJsonObject(cfg.presenceArgs).value
158 const r = await $.mcp.call(cfg.presenceServer, cfg.presenceTool, args).catch(() => undefined)
159 return r && !r.isError ? parseIdleText(mcpText(r)) : undefined
160 }
161 const r = await $.process.run(['ioreg', '-c', 'IOHIDSystem'], { timeoutMs: 5000 }).catch(() => undefined)
162 return r && r.exitCode === 0 ? parseIdleSeconds(r.stdout) : undefined
163}
164
165async function sendAway($: EngineInterface, cfg: AwayConfig, text: string) {
166 if (cfg.awayServer && cfg.awayTool) {
167 const template = cfg.awayArgs.trim() ? parseJsonObject(cfg.awayArgs).value : { text: '{{text}}' }
168 const args = fillTemplate(template ?? { text: '{{text}}' }, text) as Record<string, unknown>
169 const r = await $.mcp.call(cfg.awayServer, cfg.awayTool, args).catch(() => undefined)
170 if (r && !r.isError) return
171 $.ui.toast('cockpit: the away message did not go through the MCP tool; using a notification')
172 }
173 const sent = await $.ui.notify(text, { title: 'Claude Code' })
174 if (!sent.isSent) $.ui.toast(text)
175}
176
177async function awayCheck($: EngineInterface, cfg: AwayConfig, answer: string) {
178 if (!cfg.isOn || !answer.trim()) return
179 const idle = await idleSeconds($, cfg)
180 await update($, away, a => ({ ...a, idleSeconds: idle, note: idle === undefined ? 'idle time unknown; treated as present' : undefined }))
181 if (idle === undefined || idle < cfg.idleSeconds) return
182 let text = summarize(answer)
183 if (cfg.summary === 'haiku') {
184 const r = await $.model
185 .complete({
186 model: 'haiku',
187 prompt: `Summarize this final answer from a coding assistant in one plain sentence of at most 200 characters. Say what was done and whether anything needs the person. Reply with the sentence only.\n\n${answer.slice(0, 4000)}`,
188 })
189 .catch(() => undefined)
190 if (r?.isAnswered && r.text.trim()) text = clip(r.text, 300)
191 }
192 await sendAway($, cfg, text)
193 const sentAt = await $.clock.now()
194 await update($, away, a => ({ ...a, lastSentAt: sentAt, lastText: text }))
195}
196
197export const register: Register = (on, options) => {
198 const parsed = parseGauges(str(options.gauges))
199 const gauges = parsed.gauges
200 const refreshMs = Math.max(5, typeof options.refreshSeconds === 'number' ? options.refreshSeconds : 30) * 1000
201 const isQuiet = options.quietInbox === true
202 const awayCfg: AwayConfig = {
203 isOn: options.awayMode !== false,
204 idleSeconds: Math.max(0, (typeof options.awayMinutes === 'number' ? options.awayMinutes : 5) * 60),
205 summary: str(options.awaySummary) || 'answer',
206 presenceServer: str(options.presenceServer),
207 presenceTool: str(options.presenceTool),
208 presenceArgs: str(options.presenceArgs),
209 awayServer: str(options.awayServer),
210 awayTool: str(options.awayTool),
211 awayArgs: str(options.awayArgs),
212 }
213 let timers: Timer[] = []
214
215 on('session.start', async ($, e, next) => {
216 for (const t of timers) t.cancel()
217 failing.clear()
218 await update($, readings, () => ({}))
219 await update($, configError, () => parsed.errors.join('; '))
220 await $.command.register({ name: 'cockpit', description: 'Open the Cockpit pane (cockpit refresh: refresh every gauge now)' })
221 timers = [
222 $.clock.every(refreshMs, () => void refreshBuiltins($)),
223 ...gauges.map((g, i) => $.clock.every(g.every * 1000, () => void refreshGauge($, `g${i}`, g, true))),
224 ]
225 void refreshAll($, gauges)
226 return next(e)
227 })
228
229 on('session.end', ($, e, next) => {
230 for (const t of timers) t.cancel()
231 timers = []
232 return next(e)
233 })
234
235 on('command.run', { command: 'cockpit' }, async $ => {
236 await $.ui.open({ id: PANE, title: 'Cockpit' })
237 await refreshAll($, gauges)
238 return { text: 'Cockpit opened.' }
239 })
240
241 // Cost and context arrive as the engine measures them.
242 on('session.measure', async ($, e, next) => {
243 await applyUsage($, e.cost?.usd, e.context.percent, e.context.tokens, e.context.window)
244 return next(e)
245 })
246
247 on('session.receive', async ($, e, next) => {
248 if (e.agentId !== undefined || !INBOX_KINDS.has(e.origin.kind)) return next(e)
249 const from = e.event?.from ?? e.origin.kind
250 const item: InboxItem = { at: await $.clock.now(), from, text: e.text }
251 await update($, inbox, list => [...list, item].slice(-INBOX_LIMIT))
252 $.ui.toast(`inbox · ${from}: ${clip(e.text, 80)}`)
253 return isQuiet ? { consumed: 'cockpit: quiet inbox' } : next(e)
254 }).catch(($, e, next) => next(e)) // a failure here must never swallow a message
255
256 on('turn.complete', async ($, e, next) => {
257 const done = await next(e)
258 if (e.agentId === undefined) {
259 void refreshSession($)
260 void refreshGit($)
261 if (e.reason === 'answer') void awayCheck($, awayCfg, e.answer).catch(() => undefined)
262 }
263 return done
264 })
265
266 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
267 const { Box, Text, Button } = $.ui.resolve(e)
268 const all = await read($, readings)
269 const mail = await read($, inbox)
270 const awayInfo = await read($, away)
271 const problem = await read($, configError)
272 const width = Math.max(10, Math.min(24, (e.props.bodyColumns ?? 40) - 40))
273
274 const row = (id: string, label: string, onRefresh: () => void) => {
275 const r = all[id]
276 if (!r) return null
277 const showSpark = r.history.length > 1
278 let spark = null
279 if (showSpark) {
280 if (e.surface === 'terminal') {
281 const { Raster } = $.ui.resolve(e)
282 const s = sparkCells(r.history, width, 0x6a9fb5)
283 spark = <Raster key={`spark-${id}`} columns={s.columns} rows={1} cells={s.cells} />
284 } else {
285 spark = <Text dimColor>{textSparkline(r.history, width)}</Text>
286 }
287 }
288 return (
289 <Box flexDirection="row">
290 <Text dimColor>{label.padEnd(9)}</Text>
291 <Text color={r.error ? undefined : id === 'pr' && r.num ? 'red' : undefined}>{r.text} </Text>
292 {spark}
293 <Button key={`refresh-${id}`} plain label=" ↻" onPress={onRefresh} />
294 {r.error ? <Text dimColor> {r.error}</Text> : null}
295 </Box>
296 )
297 }
298
299 return (
300 <Box flexDirection="column">
301 {row('cost', 'cost', () => void refreshSession($))}
302 {row('context', 'context', () => void refreshSession($))}
303 {row('git', 'git', () => void refreshGit($))}
304 {row('pr', 'pr checks', () => void refreshPr($))}
305 {gauges.map((g, i) => row(`g${i}`, g.label.slice(0, 9), () => void refreshGauge($, `g${i}`, g)))}
306 {problem ? <Text color="red">{problem}</Text> : null}
307 <Box flexDirection="row">
308 <Button key="refresh-all" plain label="↻ refresh all" onPress={() => void refreshAll($, gauges)} />
309 </Box>
310 <Text bold>Inbox {mail.length ? `(${mail.length})` : ''}</Text>
311 {mail.length === 0 ? <Text dimColor>No messages from other sessions.</Text> : null}
312 {mail.slice(-INBOX_SHOWN).map(m => (
313 <Text>
314 <Text dimColor>{m.from}: </Text>
315 {clip(m.text, 120)}
316 </Text>
317 ))}
318 {mail.length ? <Button key="inbox-clear" plain label="clear inbox" onPress={() => void update($, inbox, () => [])} /> : null}
319 <Text dimColor>
320 {awayCfg.isOn
321 ? `away mode: after ${Math.round(awayCfg.idleSeconds / 60)} min idle${awayInfo.idleSeconds === undefined ? '' : ` · idle ${awayInfo.idleSeconds}s at last turn`}${awayInfo.note ? ` · ${awayInfo.note}` : ''}`
322 : 'away mode: off'}
323 </Text>
324 </Box>
325 )
326 })
327}
328hooks/lib.ts 248 lines1// Pure helpers of the cockpit mod: parsing, extraction, drawing math. No engine calls here.
2
3export type GaugeSpec = {
4 label: string
5 kind: 'mcp' | 'command' | 'http'
6 server?: string
7 tool?: string
8 args?: Record<string, unknown>
9 argv?: string[]
10 url?: string
11 every: number
12 pick?: string
13}
14
15export const MIN_EVERY = 5
16export const DEFAULT_EVERY = 60
17export const HISTORY = 40
18
19const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
20
21// A bad entry is reported and skipped; the rest still load.
22export function parseGauges(raw: string): { gauges: GaugeSpec[]; errors: string[] } {
23 const text = raw.trim()
24 if (!text) return { gauges: [], errors: [] }
25 let json: unknown
26 try {
27 json = JSON.parse(text)
28 } catch {
29 return { gauges: [], errors: ['gauges: not valid JSON'] }
30 }
31 if (!Array.isArray(json)) return { gauges: [], errors: ['gauges: expected a JSON array'] }
32 const gauges: GaugeSpec[] = []
33 const errors: string[] = []
34 json.forEach((item: unknown, i) => {
35 const at = `gauges[${i}]`
36 if (!isRecord(item)) return void errors.push(`${at}: not an object`)
37 const label = typeof item.label === 'string' ? item.label.trim() : ''
38 if (!label) return void errors.push(`${at}: label is required`)
39 const every = typeof item.every === 'number' && Number.isFinite(item.every) ? Math.max(MIN_EVERY, item.every) : DEFAULT_EVERY
40 const pick = typeof item.pick === 'string' && item.pick !== '' ? item.pick : undefined
41 const base = { label, every, pick }
42 if (item.kind === 'mcp') {
43 if (typeof item.server !== 'string' || typeof item.tool !== 'string' || !item.server || !item.tool)
44 return void errors.push(`${at} (${label}): mcp needs server and tool`)
45 const args = isRecord(item.args) ? item.args : undefined
46 gauges.push({ ...base, kind: 'mcp', server: item.server, tool: item.tool, args })
47 } else if (item.kind === 'command') {
48 if (!Array.isArray(item.argv) || item.argv.length === 0 || !item.argv.every(a => typeof a === 'string'))
49 return void errors.push(`${at} (${label}): command needs argv, an array of strings`)
50 gauges.push({ ...base, kind: 'command', argv: item.argv as string[] })
51 } else if (item.kind === 'http') {
52 if (typeof item.url !== 'string' || !/^https?:\/\//i.test(item.url)) return void errors.push(`${at} (${label}): http needs an http(s) url`)
53 gauges.push({ ...base, kind: 'http', url: item.url })
54 } else {
55 errors.push(`${at} (${label}): kind must be mcp, command or http`)
56 }
57 })
58 return { gauges, errors }
59}
60
61function walk(value: unknown, path: string): unknown {
62 let cur: unknown = value
63 for (const part of path.split('.').filter(Boolean)) {
64 if (Array.isArray(cur)) cur = cur[Number(part)]
65 else if (isRecord(cur)) cur = cur[part]
66 else return undefined
67 }
68 return cur
69}
70
71// `pick` is /regex/ (first capture group, else the match) or a dotted path into JSON (`data.items.0.count`).
72// No pick: the first non-empty line. Returns undefined when nothing matched.
73export function pickValue(raw: string, pick: string | undefined): string | undefined {
74 if (!pick) return raw.split('\n').find(l => l.trim())?.trim() || undefined
75 const re = /^\/(.+)\/([a-z]*)$/s.exec(pick)
76 if (re) {
77 let rx: RegExp
78 try {
79 rx = new RegExp(re[1] ?? '', (re[2] ?? '').replace(/[^imsu]/g, ''))
80 } catch {
81 return undefined
82 }
83 const m = rx.exec(raw)
84 return m ? (m[1] ?? m[0]) : undefined
85 }
86 let json: unknown
87 try {
88 json = JSON.parse(raw)
89 } catch {
90 return undefined
91 }
92 const found = walk(json, pick)
93 if (found === undefined || found === null) return undefined
94 return typeof found === 'object' ? JSON.stringify(found) : String(found)
95}
96
97// A strict number: "42", "-3.5", "12%" and "1,200" count; "v2 ok" does not.
98export function toNumber(text: string): number | undefined {
99 const m = /^\s*(-?\d[\d,]*(?:\.\d+)?)\s*%?\s*$/.exec(text)
100 if (!m) return undefined
101 const n = Number((m[1] ?? '').replace(/,/g, ''))
102 return Number.isFinite(n) ? n : undefined
103}
104
105export function clip(text: string, max: number): string {
106 const one = text.replace(/\s+/g, ' ').trim()
107 return one.length > max ? `${one.slice(0, max - 1)}…` : one
108}
109
110export function pushHistory(history: readonly number[], n: number): number[] {
111 return [...history, n].slice(-HISTORY)
112}
113
114const BARS = '▁▂▃▄▅▆▇█'
115
116// Eight levels per cell, scaled to the series' own min and max; a flat series draws the lowest bar.
117export function levels(values: readonly number[], width: number): number[] {
118 const v = values.slice(-width)
119 if (v.length === 0) return []
120 const lo = Math.min(...v)
121 const hi = Math.max(...v)
122 return v.map(n => (hi === lo ? 0 : Math.min(7, Math.floor(((n - lo) / (hi - lo)) * 8))))
123}
124
125export function textSparkline(values: readonly number[], width = 20): string {
126 return levels(values, width)
127 .map(l => BARS[l] ?? '▁')
128 .join('')
129}
130
131const DEFAULT_COLOR = 0x01000000
132
133// One row of cells for a Raster: [codePoint, foreground, background] u32 triplets, base64.
134export function sparkCells(values: readonly number[], width: number, color: number): { columns: number; cells: string } {
135 const ls = levels(values, width)
136 const columns = Math.max(1, ls.length)
137 const words = new Uint32Array(columns * 3)
138 for (let i = 0; i < columns; i += 1) {
139 words[i * 3] = (ls.length ? (BARS[ls[i] ?? 0] ?? '▁') : ' ').codePointAt(0) ?? 0x20
140 words[i * 3 + 1] = color
141 words[i * 3 + 2] = DEFAULT_COLOR
142 }
143 return { columns, cells: base64(new Uint8Array(words.buffer)) }
144}
145
146const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
147export function base64(bytes: Uint8Array): string {
148 let out = ''
149 for (let i = 0; i < bytes.length; i += 3) {
150 const a = bytes[i] ?? 0
151 const b = bytes[i + 1] ?? 0
152 const c = bytes[i + 2] ?? 0
153 const n = (a << 16) | (b << 8) | c
154 out += (B64[(n >> 18) & 63] ?? '') + (B64[(n >> 12) & 63] ?? '')
155 out += i + 1 < bytes.length ? (B64[(n >> 6) & 63] ?? '') : '='
156 out += i + 2 < bytes.length ? (B64[n & 63] ?? '') : '='
157 }
158 return out
159}
160
161// `git status --porcelain -b`: the branch line, then one line per changed path.
162export function parseGitStatus(out: string): { branch: string; dirty: number } {
163 const lines = out.split('\n').filter(Boolean)
164 const head = lines[0]?.startsWith('## ') ? (lines.shift() ?? '') : ''
165 let branch = head.slice(3).split('...')[0] ?? ''
166 branch = branch.replace(/^No commits yet on /, '').replace(/^Initial commit on /, '')
167 if (!branch || branch.startsWith('HEAD')) branch = 'detached'
168 return { branch, dirty: lines.length }
169}
170
171export type CheckSummary = { text: string; failing: number; pending: number; total: number }
172
173// `gh pr checks --json name,state`: states are SUCCESS, FAILURE, PENDING, SKIPPED, CANCELLED, ...
174export function parseChecks(out: string): CheckSummary | undefined {
175 let json: unknown
176 try {
177 json = JSON.parse(out)
178 } catch {
179 return undefined
180 }
181 if (!Array.isArray(json) || json.length === 0) return undefined
182 let ok = 0
183 let failing = 0
184 let pending = 0
185 let other = 0
186 for (const c of json) {
187 const state = String(isRecord(c) ? c.state : '').toUpperCase()
188 if (state === 'SUCCESS') ok += 1
189 else if (['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(state)) failing += 1
190 else if (['PENDING', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED'].includes(state)) pending += 1
191 else other += 1
192 }
193 const parts = [`${ok}✓`]
194 if (failing) parts.push(`${failing}✗`)
195 if (pending) parts.push(`${pending}…`)
196 if (other) parts.push(`${other} skipped`)
197 return { text: parts.join(' '), failing, pending, total: json.length }
198}
199
200// `ioreg -c IOHIDSystem`: HIDIdleTime is nanoseconds since the last keyboard or mouse event.
201export function parseIdleSeconds(ioreg: string): number | undefined {
202 const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ioreg)
203 return m ? Math.floor(Number(m[1]) / 1e9) : undefined
204}
205
206// A presence tool answers a bare number of seconds, or JSON with the number somewhere.
207export function parseIdleText(text: string): number | undefined {
208 const n = toNumber(text)
209 if (n !== undefined) return n
210 const m = /-?\d+(?:\.\d+)?/.exec(text)
211 return m ? Number(m[0]) : undefined
212}
213
214// Fills "{{text}}" in every string of a JSON template; walking the value avoids breaking the JSON with quotes in the text.
215export function fillTemplate(template: unknown, text: string): unknown {
216 if (typeof template === 'string') return template.split('{{text}}').join(text)
217 if (Array.isArray(template)) return template.map(t => fillTemplate(t, text))
218 if (isRecord(template)) return Object.fromEntries(Object.entries(template).map(([k, v]) => [k, fillTemplate(v, text)]))
219 return template
220}
221
222export function parseJsonObject(raw: string): { value?: Record<string, unknown>; error?: string } {
223 if (!raw.trim()) return {}
224 try {
225 const v: unknown = JSON.parse(raw)
226 return isRecord(v) ? { value: v } : { error: 'expected a JSON object' }
227 } catch {
228 return { error: 'not valid JSON' }
229 }
230}
231
232export function summarize(answer: string, max = 300): string {
233 return clip(answer, max)
234}
235
236export function formatUsd(usd: number): string {
237 return usd < 1 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`
238}
239
240export function mcpText(result: { content: readonly { type: string; text?: string }[]; structuredContent?: unknown }): string {
241 const text = result.content
242 .filter(b => b.type === 'text' && typeof b.text === 'string')
243 .map(b => b.text)
244 .join('\n')
245 if (text) return text
246 return result.structuredContent === undefined ? '' : JSON.stringify(result.structuredContent)
247}
248types/index.d.ts 27 lines1export type Reading = {
2 /** What the pane shows. */
3 text: string
4 /** The numeric value, when the reading is a number; history and the sparkline follow it. */
5 num?: number
6 history: number[]
7 /** `$.clock.now()` of the last successful reading. */
8 at: number
9 /** The last failure, shown dimmed beside the old value. */
10 error?: string
11}
12
13export type InboxItem = { at: number; from: string; text: string }
14
15export type AwayInfo = { idleSeconds?: number; lastSentAt?: number; lastText?: string; note?: string }
16
17declare module 'claude-code' {
18 interface PluginState {
19 cockpit: {
20 readings: Record<string, Reading>
21 inbox: InboxItem[]
22 away: AwayInfo
23 configError: string
24 }
25 }
26}
27