SLOPSHOPPER

omni-cat

A pixel cat above the prompt that runs an OmniDimension test call and reports how it went.

newbandcommandtoastnetworktimer
v0.1.0MITupdated 2026-10-10aloki-alok/omni-cat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · omni-cat
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /omni ⎿ omni-cat: Usage: /omni check (no call) | test (dial your phone) | last (read the last call) | demo [cat|crab|logo] | cle ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="docs/assets/omni-cat.gif" alt="A pixel cat above the Claude Code prompt paces while dialing, shakes while the phone rings, and hops when the call goes well" width="720">

omni-cat

A pixel cat that lives above your Claude Code prompt. Type /omni test and it dials your phone with an OmniDimension voice agent, paces while it dials, shakes while it rings, and tells you how the call went.

It is a Claude Code mod: a plugin that draws in the terminal. It is also a fast way to hear a voice agent you are building, without leaving your editor.

Try it in a minute

You do not need an account or a key for the demo.

claude plugin marketplace add aloki-alok/omni-cat
claude plugin install omni-cat@omni-cat

Restart Claude Code, then run:

/omni demo cat

The cat dials, rings, and reacts to a good call, a flat one, a bad one, and a failure. Try /omni demo crab and /omni demo logo too.

Test a real call

You need an OmniDimension account, an API key, an agent ID, and your own phone number.

  1. Run /plugin configure omni-cat@omni-cat in Claude Code. Set the API key, the agent ID, your phone number (with country code, like +15551234567) and the character.
  2. Run /omni check. It reads one call log and places no call, so it is a free way to confirm the setup.
  3. Run /omni test. Your phone rings. Pick up and talk to the agent.

A test call is a real call. It uses credits on the account the key belongs to.

You can also set values from a zsh shell. This keeps the key out of your shell history:

read -rs "k?OmniDimension API key: "; printf '{"api_key":"%s"}' "$k" | claude plugin configure omni-cat@omni-cat --values-stdin; unset k

Commands

CommandWhat it does
`/omni demo [cat\crab\logo]`Plays every state of a character. No key, no call.
/omni checkReads one call log to confirm your key and agent work. No call.
/omni testCalls your phone with your agent, then reports the result.
/omni lastLoads the last test call's transcript, so Claude can suggest prompt changes.
/omni clearSends the cat home.

The cat appears while a demo or a test call runs, and goes home 90 seconds after a call ends. It does not sit there all day.

Pick your character

<img src="docs/assets/characters.png" alt="The cat, the crab and the OmniDimension mark, each shown idle, dialing, ringing, after a good call, a flat call, a bad call and a failed call" width="900">

Set the character in /plugin configure omni-cat@omni-cat. All three react to the same states: the cat's ears flick while it dials and its tail swishes while the phone rings, and each one celebrates a good call and reacts to a bad one.

What it sends

  • Your API key goes only to https://omnidim.io/api/v1, over https. It is stored as a sensitive plugin setting, not in a plain file.
  • It makes two kinds of request: start a call (POST /calls/dispatch) and read the newest call log for the agent you chose (GET /calls/logs).
  • It follows a call by watching that log. A call counts as finished only when someone spoke, or when the platform reports why not: no answer, busy, failed, or voicemail.
  • No analytics, no telemetry, nothing else leaves your machine.

Requirements

  • Claude Code 2.1.287 or later, running in a terminal with true color, such as iTerm2, Ghostty, kitty or WezTerm. Other terminals may show approximate colors.
  • The Claude desktop app and the VS Code panel do not show the cat, because the mod draws with the terminal's pixel element.

How it works

The cat is drawn from code, not from image files. Each character is a small grid of colored pixels, packed two pixels to a terminal cell and painted with Claude Code's terminal Raster element in 24-bit color. Every frame of the GIF above comes from that same drawing function.

Status

Today the cat reports a call from its call log, which covers how it ended. Live stages while the call is in progress, and live conversation text, are planned.

Development

claude plugin test .
claude plugin validate .

To re-render the images in docs/assets (needs bun, uv and a monospace font; Hack Nerd Font is used if installed):

bun tools/dump-frames.ts > /tmp/frames.json
uv run --with pillow python tools/compose.py /tmp/frames.json docs/assets

docs/assets/social-preview.png is the 1280 by 640 card for the repository's social preview.

License

MIT. See LICENSE.

Source 4 files
hooks/register.tsx 233 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Call, Phase, Result } from '../types'
5import { draw, MASCOTS, toCells } from './art'
6import type { Mascot } from './art'
7import { newerLog, parseLog, unansweredReason } from './lib'
8
9const call = atom({ plugin: 'omni-cat', key: 'call' } as const, { phase: 'idle', note: '', result: null } as Call)
10const tick = atom({ plugin: 'omni-cat', key: 'tick' } as const, 0)
11
12const POLL_MS = 5000
13const POLL_LIMIT = 36 // 3 minutes
14const SHOW_DONE_MS = 90_000
15const USAGE = 'Usage: /omni check (no call) | test (dial your phone) | last (read the last call) | demo [cat|crab|logo] | clear'
16
17type Row = Record<string, unknown> & { id: number }
18type Settings = { api_key: string; agent_id: string; phone: string; mascot: Mascot }
19
20const API = 'https://omnidim.io/api/v1'
21
22// Module variables restart on every reload; the mascot's state lives in the atoms.
23let settings: Settings
24let ticker: Timer | undefined
25let poller: Timer | undefined
26let hideTimer: Timer | undefined
27let lastRow: Row | undefined
28let isBusy = false
29let demoMascot: Mascot | undefined // set only while /omni demo previews another character
30let demoId = 0 // bumped to stop a demo that is still playing
31
32async function show($: EngineInterface, phase: Phase, note = '', result: Result | null = null) {
33  hideTimer?.cancel()
34  // A new phase starts its animation at frame 0; a note change inside a phase must not restart it.
35  if ((await read($, call)).phase !== phase) await update($, tick, () => 0)
36  await update($, call, () => ({ phase, note, result }))
37  if (phase === 'idle') {
38    ticker?.cancel()
39    ticker = undefined
40    return
41  }
42  ticker ??= $.clock.every(400,() => update($, tick, n => n + 1))
43  if (phase === 'done' || phase === 'failed') hideTimer = $.clock.after(SHOW_DONE_MS, () => show($, 'idle'))
44}
45
46// The OmniDimension REST API, called directly with the plugin's own key. The key goes only to API.
47async function api($: EngineInterface, path: string, init: { method?: string; body?: string } = {}) {
48  const res = await $.http.fetch(`${API}${path}`, {
49    ...init,
50    headers: { Authorization: `Bearer ${settings.api_key}`, 'Content-Type': 'application/json' },
51  })
52  let body: Record<string, unknown> = {}
53  try {
54    body = JSON.parse(res.text)
55  } catch {}
56  if (!res.ok) throw new Error(String(body.message ?? body.error ?? `OmniDimension answered ${res.status}`).slice(0, 120))
57  return body
58}
59
60async function latestLogs($: EngineInterface): Promise<Row[]> {
61  const page = await api($, `/calls/logs?pageno=1&pagesize=1&agentid=${settings.agent_id}`)
62  return (page.call_log_data as Row[] | undefined) ?? []
63}
64
65// What stops a call from starting, in the order a person fixes it. Empty when the settings are fine.
66function settingsProblem(): string {
67  const { api_key, agent_id, phone } = settings
68  if (!api_key) return 'Add your OmniDimension API key: run /plugin configure omni-cat, or `claude plugin configure omni-cat@omni-cat`.'
69  if (!agent_id || !phone) return 'Set the agent ID and your phone number in /config under omni-cat first.'
70  if (!/^\d+$/.test(agent_id)) return 'The agent ID must be a number.'
71  if (!/^\+\d{8,15}$/.test(phone)) return 'The phone number needs a country code, like +15551234567.'
72  return ''
73}
74
75async function finish($: EngineInterface, row: Row, result: Result) {
76  lastRow = row
77  isBusy = false
78  poller?.cancel()
79  await show($, 'done', 'Call finished', result)
80  $.ui.toast(`Test call done: ${result.sentiment}, ${result.seconds}s`)
81}
82
83async function fail($: EngineInterface, why: string) {
84  isBusy = false
85  poller?.cancel()
86  await show($, 'failed', why)
87  $.ui.toast(`Test call failed: ${why}`)
88}
89
90function watch($: EngineInterface, before: number) {
91  let tries = 0
92  let errors = 0
93  let silent = 0
94  poller = $.clock.every(POLL_MS, async () => {
95    if (++tries > POLL_LIMIT) return fail($, 'No call log after 3 minutes')
96    try {
97      const row = newerLog(await latestLogs($), before)
98      errors = 0
99      if (!row) return
100      const why = unansweredReason(row.call_status)
101      if (why) return fail($, why)
102      if (row.call_status !== 'completed') return show($, 'ringing', 'In call')
103      const result = parseLog(row)
104      if (result) return finish($, row, result)
105      // Seen twice, so a log that is still being written is not called silent too early.
106      if (++silent >= 2) return fail($, 'The call connected but nobody spoke')
107    } catch {
108      if (++errors >= 3) return fail($, 'Could not read call logs')
109    }
110  })
111}
112
113async function startTest($: EngineInterface): Promise<string> {
114  const problem = settingsProblem()
115  if (problem) return problem
116  if (isBusy) return 'A test call is already running.'
117
118  const { agent_id, phone } = settings
119  demoId++ // stop a demo that is still playing; the real call owns the display now
120  demoMascot = undefined
121  isBusy = true
122  try {
123    await show($, 'dialing', 'Checking your latest call log')
124    const before = (await latestLogs($))[0]?.id ?? 0
125    await show($, 'dialing', 'Dialing')
126    const sent = await api($, '/calls/dispatch', {
127      method: 'POST',
128      body: JSON.stringify({ agent_id: Number(agent_id), to_number: phone }),
129    })
130    if (sent.success === false) throw new Error('OmniDimension refused the call')
131    await show($, 'ringing', `Ringing ${phone.slice(0, 3)}…${phone.slice(-2)}`)
132    watch($, before)
133    return 'Dialing your phone. Pick up when it rings and talk to the agent. Your omni cat will report back.'
134  } catch (error) {
135    await fail($, 'Could not start the call')
136    return `Test call did not start: ${(error as Error).message}`
137  }
138}
139
140async function demo($: EngineInterface, character: Mascot | undefined) {
141  const mine = ++demoId
142  demoMascot = character
143  const sleep = (ms: number) => new Promise<void>(resolve => $.clock.after(ms, resolve))
144  const fake = (sentiment: string): Result => ({ seconds: 42, sentiment, cost: 0.12, summary: 'demo' })
145  const steps: [Phase, string, string, number][] = [
146    ['dialing', 'Dialing (demo)', '', 3000],
147    ['ringing', 'Ringing (demo)', '', 5000],
148    ['done', 'Call finished (demo)', 'Positive', 4000],
149    ['done', 'Call finished (demo)', 'Neutral', 4000],
150    ['done', 'Call finished (demo)', 'Negative', 4000],
151    ['failed', 'The call connected but nobody spoke (demo)', '', 4000],
152  ]
153  for (const [phase, note, sentiment, ms] of steps) {
154    if (mine !== demoId) return // a test call or /omni clear took over the display
155    await show($, phase, note, sentiment ? fake(sentiment) : null)
156    await sleep(ms)
157  }
158  if (mine !== demoId) return
159  demoMascot = undefined
160  await show($, 'idle')
161}
162
163export const register: Register = (on, options) => {
164  settings = options as Settings
165
166  on('session.start', async ($, e, next) => {
167    await $.command.register({ name: 'omni', description: 'Run an OmniDimension test call with your omni cat' })
168    return next(e)
169  })
170
171  on('command.run', { command: 'omni' }, async ($, e) => {
172    const sub = e.args.trim().split(/\s+/)[0]
173
174    if (sub === 'test') return { text: await startTest($) }
175    if (sub === 'check') {
176      const problem = settingsProblem()
177      if (problem) return { text: problem }
178      try {
179        const [latest] = await latestLogs($)
180        return { text: latest ? `Connected to OmniDimension. Latest call log for this agent: #${latest.id}.` : 'Connected to OmniDimension. This agent has no call logs yet.' }
181      } catch (error) {
182        return { text: `OmniDimension check failed: ${(error as Error).message}` }
183      }
184    }
185    if (sub === 'demo') {
186      const pick = e.args.trim().split(/\s+/)[1] as Mascot | undefined
187      if (pick && !MASCOTS.includes(pick)) return { text: `Pick one of: ${MASCOTS.join(', ')}.` }
188      if (isBusy) return { text: 'A test call is running. Run /omni clear to stop it first.' }
189      void demo($, pick)
190      return { text: `Demo running with the ${pick ?? settings.mascot}. It ends by itself.` }
191    }
192    if (sub === 'clear') {
193      isBusy = false
194      demoId++
195      demoMascot = undefined
196      poller?.cancel()
197      await show($, 'idle')
198      return { text: 'Sent your omni cat home.' }
199    }
200    if (sub === 'last') {
201      if (!lastRow) return { text: 'No test call yet. Run /omni test first.' }
202      const transcript = String(lastRow.call_conversation ?? '').replace(/<br\s*\/?>/g, '\n')
203      return {
204        text: 'Last test call loaded for Claude. Ask what to change in the prompt.',
205        context: [`Transcript of the last OmniDimension test call:\n${transcript.slice(0, 6000)}`],
206      }
207    }
208    return { text: USAGE }
209  })
210
211  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
212    const { phase, note, result } = await read($, call)
213    if (phase === 'idle' || e.props.hasSurvey) return next(e)
214
215    const frame = await read($, tick)
216    const { Box, Text, Raster } = $.ui.resolve(e)
217    const detail = result
218      ? `${result.sentiment}, ${result.seconds}s${result.cost === null ? '' : `, $${result.cost.toFixed(2)}`}`
219      : ''
220    const pixels = draw(demoMascot ?? settings.mascot, phase, frame, result?.sentiment)
221
222    return (
223      <Box gap={2}>
224        <Raster key="mascot" columns={pixels.w} rows={pixels.h / 2} cells={toCells(pixels)} />
225        <Box flexDirection="column">
226          <Text bold>{note}</Text>
227          {detail !== '' && <Text dimColor>{detail}</Text>}
228        </Box>
229      </Box>
230    )
231  })
232}
233
hooks/art.ts 287 lines
1// Pixel art for the band. A Grid is a canvas of 0xRRGGBB pixels; toCells packs it two pixels per
2// terminal cell (the upper half block, foreground on top, background below) for the Raster element.
3
4import type { Phase } from '../types'
5
6export type Px = number | null
7
8export class Grid {
9  px: Px[]
10  constructor(
11    public w: number,
12    public h: number,
13  ) {
14    this.px = new Array(w * h).fill(null)
15  }
16
17  set(x: number, y: number, c: Px) {
18    x = Math.round(x)
19    y = Math.round(y)
20    if (x >= 0 && x < this.w && y >= 0 && y < this.h) this.px[y * this.w + x] = c
21  }
22
23  get(x: number, y: number): Px {
24    return x >= 0 && x < this.w && y >= 0 && y < this.h ? this.px[y * this.w + x] : null
25  }
26
27  // `c` may be a function of the pixel, for shading.
28  ellipse(cx: number, cy: number, rx: number, ry: number, c: number | ((x: number, y: number) => Px)) {
29    for (let y = 0; y < this.h; y++)
30      for (let x = 0; x < this.w; x++)
31        if (((x + 0.5 - cx) / rx) ** 2 + ((y + 0.5 - cy) / ry) ** 2 <= 1)
32          this.set(x, y, typeof c === 'function' ? c(x, y) : c)
33  }
34
35  rect(x: number, y: number, w: number, h: number, c: Px) {
36    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, c)
37  }
38}
39
40export const MASCOTS = ['cat', 'crab', 'logo'] as const
41export type Mascot = (typeof MASCOTS)[number]
42
43const W = 24
44const H = 12
45const CX = 12
46
47const BLACK = 0x1b1b24
48const WHITE = 0xffffff
49const PINK = 0xff8fa3
50const GOLD = 0xffd23f
51const CYAN = 0x00ffff
52
53type Mood = 'calm' | 'focus' | 'happy' | 'flat' | 'sad' | 'dead'
54
55// What the mascot is doing this frame, shared by all of them so they react the same way.
56type Pose = { dx: number; dy: number; mood: Mood; blink: boolean; walk: boolean; clawsUp: 0 | 1 | 2; tick: number; phase: Phase }
57
58function pose(phase: Phase, tick: number, sentiment: string): Pose {
59  const base: Pose = { dx: 0, dy: 0, mood: 'calm', blink: false, walk: false, clawsUp: 0, tick, phase }
60  if (phase === 'idle') return { ...base, blink: tick % 6 === 5 }
61  if (phase === 'dialing') return { ...base, dx: [-1, 0, 1, 0][tick % 4], mood: 'focus', walk: true, clawsUp: 1 }
62  if (phase === 'ringing') return { ...base, dx: tick % 2 ? 1 : -1, mood: 'focus', walk: true }
63  if (phase === 'failed') return { ...base, mood: 'dead' }
64  const mood = /pos/i.test(sentiment) ? 'happy' : /neg/i.test(sentiment) ? 'sad' : 'flat'
65  return { ...base, mood, dy: mood === 'happy' && tick % 2 ? -1 : 0, clawsUp: mood === 'happy' ? 2 : 0 }
66}
67
68// Two arcs that grow out to the right while the phone rings.
69function waves(g: Grid, x: number, y: number, tick: number) {
70  for (const r of [2, 4]) {
71    if ((tick + (r === 2 ? 0 : 1)) % 3 === 2) continue
72    for (let a = -0.9; a <= 0.9; a += 0.3) g.set(x + Math.cos(a) * r, y + Math.sin(a) * r, CYAN)
73  }
74}
75
76function sparkles(g: Grid, tick: number) {
77  const spots = [[3, 1], [21, 2], [5, 9], [20, 8]]
78  spots.forEach(([x, y], i) => {
79    if ((tick + i) % 2 === 0) g.set(x, y, i % 2 ? GOLD : WHITE)
80  })
81}
82
83const SHELL = 0xe9733a
84const SHELL_L = 0xff9a5c
85const SHELL_D = 0xb5522b
86
87function crab(p: Pose): Grid {
88  const g = new Grid(W, H)
89  const cx = CX + p.dx
90  const y0 = p.dy + 1 // one row of headroom so the hop and the raised claws do not clip
91
92  for (const s of [-1, 1])
93    for (const k of [3, 5]) {
94      const bend = p.walk && (p.tick + k) % 2 ? 1 : 0
95      g.set(cx + s * k, 9 + y0, SHELL_D)
96      g.set(cx + s * (k + bend), 10 + y0, SHELL_D)
97    }
98
99  g.ellipse(cx, 7 + y0, 5.5, 3.2, (_x, y) => (y - y0 >= 9 ? SHELL_D : y - y0 <= 5 ? SHELL_L : SHELL))
100
101  for (const s of [-1, 1]) {
102    const isUp = p.clawsUp === 2 || (p.clawsUp === 1 && (s === -1) === (p.tick % 2 === 0))
103    const cy = (isUp ? 2.5 : 5.5) + y0
104    g.set(cx + s * 5, isUp ? 5 + y0 : 6 + y0, SHELL)
105    g.ellipse(cx + s * 6.4, cy, 2, 1.9, SHELL)
106    g.set(cx + s * 6.4 + (s === -1 ? 0 : -1), cy - 1.5, null)
107  }
108
109  for (const x of [cx - 3, cx + 1]) {
110    g.set(x + 1, 3 + y0, SHELL_D)
111    const left = x === cx - 3
112    const inner = left ? x + 1 : x
113    // White eyes first so the black marks (lids, ^, x) read on a dark terminal.
114    g.rect(x, 1 + y0, 2, 2, WHITE)
115    if (p.mood === 'dead') {
116      g.set(x, 1 + y0, BLACK), g.set(x + 1, 2 + y0, BLACK)
117    } else if (p.blink || p.mood === 'flat') {
118      g.rect(x, 2 + y0, 2, 1, BLACK)
119    } else if (p.mood === 'happy') {
120      g.set(x + (left ? 0 : 1), 2 + y0, BLACK), g.set(x + (left ? 1 : 0), 1 + y0, BLACK)
121    } else {
122      g.set(inner, (p.mood === 'focus' ? 1 : 2) + y0, BLACK)
123    }
124  }
125
126  const mouth: Record<Mood, [number, number][]> = {
127    calm: [[-1, 8], [0, 8]],
128    focus: [[-1, 8], [0, 8]],
129    flat: [[-1, 8], [0, 8]],
130    happy: [[-2, 7], [-1, 8], [0, 8], [1, 7]],
131    sad: [[-1, 8], [0, 8], [-2, 9], [1, 9]],
132    dead: [[-2, 8], [-1, 9], [0, 8], [1, 9]],
133  }
134  for (const [x, y] of mouth[p.mood]) g.set(cx + x, y + y0, BLACK)
135  if (p.mood === 'happy') (g.set(cx - 4, 7 + y0, PINK), g.set(cx + 3, 7 + y0, PINK))
136  if (p.mood === 'sad') g.set(cx - 3, 3 + (p.tick % 4) + y0, CYAN)
137
138  if (p.phase === 'ringing') waves(g, CX + 7, 3, p.tick)
139  if (p.mood === 'happy') sparkles(g, p.tick)
140  return g
141}
142
143const FUR = 0xf2d3a4
144const FUR_D = 0xc98b4f
145const MUZZLE = 0xfff6e8
146const EYE = 0x00e5ff
147const WHISKER = 0xdcdcdc
148
149// A sitting cat seen from the front: pointed ears, cyan slit eyes, whiskers, paws and a tail
150// that swishes while the phone rings. Offsets named `rel` are columns from the middle, mirrored.
151function cat(p: Pose): Grid {
152  const g = new Grid(W, H)
153  const cx = CX + p.dx
154  const y0 = p.dy + 1 // one row of headroom so the hop does not clip the ears
155  const both = (rel: number, y: number, c: Px) => {
156    g.set(cx + rel, y + y0, c)
157    g.set(cx - rel - 1, y + y0, c)
158  }
159  const busy = p.walk || p.mood === 'happy'
160
161  // Tail along the floor, rising at the right. It swishes while busy.
162  const rise = 3 + (busy ? [0, 1, 2, 1][p.tick % 4] : 0)
163  for (let x = cx + 5; x <= cx + 9; x++) g.set(x, 10 + y0, FUR)
164  for (let i = 0; i < rise; i++) g.set(cx + 10, 9 - i + y0, i === rise - 1 ? FUR_D : FUR)
165
166  // Body, white chest and paws.
167  g.ellipse(cx, 10.5 + y0, 5, 3, FUR)
168  g.ellipse(cx, 10.5 + y0, 2.2, 2.2, MUZZLE)
169  g.rect(cx - 3, 10 + y0, 2, 1, MUZZLE)
170  g.rect(cx + 1, 10 + y0, 2, 1, MUZZLE)
171
172  // Ears: the right one flicks while dialing, both droop when the call went badly.
173  const droop = p.mood === 'sad' ? 1 : 0
174  for (const side of [-1, 1] as const) {
175    const flick = side === 1 && p.phase === 'dialing' && p.tick % 2 ? 1 : 0
176    const ey = y0 + droop + flick
177    const put = (rel: number, y: number, c: number) => g.set(side === -1 ? cx + rel : cx - rel - 1, y + ey, c)
178    put(-5, 0, FUR_D)
179    for (let r = -6; r <= -4; r++) put(r, 1, r === -5 ? PINK : FUR)
180    for (let r = -6; r <= -3; r++) put(r, 2, r === -5 || r === -4 ? PINK : FUR)
181  }
182
183  // Head, forehead stripes, cheek stripes and a pale muzzle.
184  g.ellipse(cx, 5 + y0, 6.5, 3.5, FUR)
185  both(0, 2, FUR_D), both(0, 3, FUR_D), both(-3, 2, FUR_D), both(-6, 5, FUR_D), both(-6, 7, FUR_D)
186  g.ellipse(cx, 6.8 + y0, 2.6, 1.7, MUZZLE)
187
188  // Eyes: cyan with a black slit. They close for a blink, a flat call, or smile when the call went well.
189  for (const x0 of [cx - 5, cx + 2]) {
190    if (p.mood === 'dead') {
191      for (const [dx, y] of [[0, 4], [2, 4], [1, 5], [0, 6], [2, 6]]) g.set(x0 + dx, y + y0, BLACK)
192    } else if (p.mood === 'happy') {
193      g.set(x0, 5 + y0, EYE), g.set(x0 + 1, 4 + y0, EYE), g.set(x0 + 2, 5 + y0, EYE)
194    } else if (p.blink || p.mood === 'flat') {
195      g.rect(x0, 5 + y0, 3, 1, BLACK)
196    } else {
197      g.rect(x0, 4 + y0, 3, 2, EYE)
198      g.set(x0 + 1, 4 + y0, BLACK), g.set(x0 + 1, 5 + y0, BLACK)
199      g.set(x0, 4 + y0, 0xc8ffff)
200    }
201  }
202  if (p.mood === 'sad') g.set(cx - 5, 6 + (p.tick % 3) + y0, CYAN)
203
204  // Nose and mouth.
205  g.set(cx - 1, 6 + y0, PINK), g.set(cx, 6 + y0, PINK)
206  const mouth: Record<Mood, [number, number][]> = {
207    calm: [[-1, 7], [0, 7], [-2, 8], [1, 8]],
208    focus: [[-1, 7], [0, 7], [-2, 8], [1, 8]],
209    flat: [[-1, 7], [0, 7]],
210    happy: [[-2, 7], [-1, 8], [0, 8], [1, 7]],
211    sad: [[-1, 8], [0, 8], [-2, 9], [1, 9]],
212    dead: [[-2, 8], [-1, 7], [0, 8], [1, 7]],
213  }
214  for (const [x, y] of mouth[p.mood]) g.set(cx + x, y + y0, BLACK)
215  if (p.mood === 'happy') (g.set(cx - 5, 7 + y0, PINK), g.set(cx + 4, 7 + y0, PINK))
216
217  // Whiskers, two a side with a row between so they read as lines and not a block.
218  for (const [rel, y] of [[-7, 5], [-8, 5], [-9, 4], [-7, 7], [-8, 7], [-9, 8]]) both(rel, y, WHISKER)
219
220  if (p.phase === 'ringing') waves(g, CX + 7, 2, p.tick)
221  if (p.mood === 'happy') sparkles(g, p.tick)
222  return g
223}
224
225const TINT: Record<Mood, number> = {
226  calm: CYAN,
227  focus: CYAN,
228  happy: CYAN,
229  flat: 0x5fb8c0,
230  sad: 0x3a6b8f,
231  dead: 0x808080,
232}
233
234// The OmniDimension mark: a cyan O with a white arc offset behind it.
235function logo(p: Pose): Grid {
236  const g = new Grid(W, H)
237  const cx = CX + p.dx - 1
238  const cy = 6 + p.dy
239  const slide = p.phase === 'dialing' ? [0, 1, 2, 1][p.tick % 4] : p.phase === 'ringing' ? p.tick % 2 : 0
240  const tint = p.phase === 'ringing' && p.tick % 2 ? 0x9bffff : TINT[p.mood]
241
242  const inCyanOuter = (x: number, y: number, grow: number) => ((x + 0.5 - cx) / (5 + grow)) ** 2 + ((y + 0.5 - cy) / (5.4 + grow)) ** 2 <= 1
243  g.ellipse(cx + 3 + slide, cy, 5, 5.4, (x, y) => {
244    const inner = ((x + 0.5 - (cx + 3 + slide)) / 2.8) ** 2 + ((y + 0.5 - cy) / 3.4) ** 2 <= 1
245    return inner || inCyanOuter(x, y, 1) ? null : p.mood === 'dead' ? 0x606060 : WHITE
246  })
247  g.ellipse(cx, cy, 5, 5.4, (x, y) => (((x + 0.5 - cx) / 2.8) ** 2 + ((y + 0.5 - cy) / 3.4) ** 2 <= 1 ? null : tint))
248
249  if (p.mood === 'dead') for (const [x, y] of [[-1, -1], [0, 0], [1, 1], [1, -1], [-1, 1]]) g.set(cx + 1 + x, cy + y, 0xff4444)
250  if (p.mood === 'happy') (g.set(cx - 1, cy, WHITE), g.set(cx + 1, cy, WHITE), g.set(cx, cy + 1, WHITE))
251  if (p.mood === 'sad') g.set(cx - 5, cy + 1 + (p.tick % 4), CYAN)
252  if (p.phase === 'ringing') waves(g, CX + 8, 3, p.tick)
253  if (p.mood === 'happy') sparkles(g, p.tick)
254  return g
255}
256
257export function draw(mascot: Mascot, phase: Phase, tick: number, sentiment = ''): Grid {
258  const p = pose(phase, tick, sentiment)
259  return mascot === 'cat' ? cat(p) : mascot === 'logo' ? logo(p) : crab(p)
260}
261
262const DEFAULT = 0x01000000
263
264export function toCells(g: Grid): string {
265  const rows = Math.ceil(g.h / 2)
266  const words = new Uint32Array(g.w * rows * 3)
267  let i = 0
268  for (let row = 0; row < rows; row++)
269    for (let x = 0; x < g.w; x++) {
270      const top = g.get(x, row * 2)
271      const bottom = g.get(x, row * 2 + 1)
272      const [glyph, fg, bg] =
273        top === null && bottom === null
274          ? [0x20, DEFAULT, DEFAULT]
275          : bottom === null
276            ? [0x2580, top as number, DEFAULT]
277            : top === null
278              ? [0x2584, bottom, DEFAULT]
279              : top === bottom
280                ? [0x2588, top, DEFAULT]
281                : [0x2580, top, bottom]
282      words.set([glyph, fg, bg], i)
283      i += 3
284    }
285  return new Uint8Array(words.buffer).toBase64()
286}
287
hooks/lib.ts 37 lines
1import type { Result } from '../types'
2
3// A turn is "user:" or "LLM:" followed by real text. A call that rang but stayed silent
4// leaves only empty turns, so the log existing does not mean anyone spoke.
5const SPOKEN_TURN = /(?:user|LLM):\s*[^\s<]/
6
7export function parseLog(row: Record<string, unknown>): Result | null {
8  if (!SPOKEN_TURN.test(String(row.call_conversation ?? ''))) return null
9
10  const cost = row.aggregated_estimated_cost
11  return {
12    seconds: Number(row.call_duration_in_seconds) || 0,
13    sentiment: String(row.sentiment_score || 'Unknown'),
14    cost: typeof cost === 'number' ? cost : null,
15    summary: String(row.sentiment_analysis_details || ''),
16  }
17}
18
19// call_request_id is an object, not a scalar, so a dispatch cannot be matched to its log.
20// Snapshot the newest log id before dialing and take the newest one past it.
21export function newerLog<T extends { id: number }>(rows: T[], before: number): T | undefined {
22  return rows.filter(r => r.id > before).sort((a, b) => b.id - a.id)[0]
23}
24
25// The call_status values the platform reports when a call ends without a conversation.
26// `completed` is parsed for speech instead; any other status counts as still live.
27const UNANSWERED: Record<string, string> = {
28  'no-answer': 'Nobody picked up',
29  busy: 'The line was busy',
30  failed: 'The call failed',
31  voicemail_detected: 'It reached voicemail',
32}
33
34export function unansweredReason(status: unknown): string | null {
35  return UNANSWERED[String(status)] ?? null
36}
37
types/index.d.ts 17 lines
1export type Phase = 'idle' | 'dialing' | 'ringing' | 'done' | 'failed'
2
3export type Result = {
4  seconds: number
5  sentiment: string
6  cost: number | null
7  summary: string
8}
9
10export type Call = { phase: Phase; note: string; result: Result | null }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'omni-cat': { call: Call; tick: number }
15  }
16}
17