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

<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">
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.
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.
You need an OmniDimension account, an API key, an agent ID, and your own phone number.
/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./omni check. It reads one call log and places no call, so it is a free way to confirm the setup./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
| Command | What it does | ||
|---|---|---|---|
| `/omni demo [cat\ | crab\ | logo]` | Plays every state of a character. No key, no call. |
/omni check | Reads one call log to confirm your key and agent work. No call. | ||
/omni test | Calls your phone with your agent, then reports the result. | ||
/omni last | Loads the last test call's transcript, so Claude can suggest prompt changes. | ||
/omni clear | Sends 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.
<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.
https://omnidim.io/api/v1, over https. It is stored as a sensitive plugin setting, not in a plain file.POST /calls/dispatch) and read the newest call log for the agent you chose (GET /calls/logs).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.
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.
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.
MIT. See LICENSE.
hooks/register.tsx 233 lines1import { 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}
233hooks/art.ts 287 lines1// 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}
287hooks/lib.ts 37 lines1import 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}
37types/index.d.ts 17 lines1export 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