SLOPSHOPPER

cockpit

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

newpanecommandtoastmodelprocess
v0.1.0MITupdated 2026-10-26Egoushka/claude-mods/mods/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ cost $0.420 ↻ │ ┃ context 49% (97k of 200k) ▁▁▁ ↻ ⏺ Read(src/auth.ts) │ ┃ pr checks1✓ 1… ▁▁ ↻ ⎿ Read 6 lines │ ┃ ↻ refresh all ⏺ Update(src/auth.ts) │ ┃ Inbox ⎿ Added 2 lines, removed 1 line │ ┃ No messages from other sessions. ⏺ Bash(bun test) │ ┃ away mode: after 5 min idle · idle time ⎿ 3 pass, 1 fail │ ┃ unknown; treated as present │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cockpit │ ⎿ cockpit: Cockpit opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cockpit
cost $0.420 ↻ context 49% (97k of 200k) ▁▁▁ ↻ pr checks1✓ 1… ▁▁ ↻ ↻ refresh all Inbox No messages from other sessions. away mode: after 5 min idle · idle time unknown; treated as present
README

cockpit

A live pane for what surrounds the session. /cockpit opens it.

SectionWhat it showsSource
cost, contextsession cost, context fillsession.measure and $.session.usage(), pushed by the engine
gitbranch and number of changed pathsgit status --porcelain -b, every refreshSeconds and after each turn
pr checkspass, fail and pending counts for the current branch's PRgh pr checks --json name,state; the row is absent without gh, a PR or checks
your gaugeswhatever you configureMCP tool, command or HTTP URL
Inboxmessages other sessions or agents sent yousession.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.

Custom gauges

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.
  • A reading that is a plain number (42, 12.5%, 1,200) is numeric and gets history. Anything else is shown as text.
  • A failing gauge keeps its last value and shows the error dimmed next to it. Bad entries in the JSON are skipped and listed in red at the pane's foot.

Inbox

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.

Away mode

When a turn ends with an answer and you have been idle longer than awayMinutes, a summary is sent.

  • Presence. On macOS the idle time is read from 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).
  • Summary. 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.
  • Channel. $.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.

Settings

SettingDefault
gauges[]custom gauges, JSON
refreshSeconds30git and PR refresh period
quietInboxoffconsume inbox messages
awayModeonsummary when you are away
awayMinutes5idle time that counts as away
awaySummaryansweranswer or haiku
presenceServer, presenceTool, presenceArgsemptypresence from an MCP tool
awayServer, awayTool, awayArgsemptyaway message through an MCP tool

What it costs

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.

Limits

  • The pane is read-only apart from the buttons; the sparkline scales to the last 40 readings, not to a fixed range.
  • Gauge history lives in session state, so it starts over with each session.
  • ioreg presence is macOS only.
  • Inbox sorting relies on the delivery's origin; deliveries the engine cannot classify (unclassified) are left alone.
Source 3 files
hooks/register.tsx 328 lines
1import { 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}
328
hooks/lib.ts 248 lines
1// 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}
248
types/index.d.ts 27 lines
1export 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