Shows the main agent and subagents of this session as pixel characters in an office pane

pixel-office is a Claude Code mod that shows the agents of your session as pixel characters working in a small office, in a pane beside the chat.
<img src="docs/office.png" alt="The pixel office: Claude's mascot at the main desk, twelve creatures at their desks below, each screen showing what its agent is doing" width="384">
A static render of the office with every creature. The pane also draws name tags, animates the characters and keeps an activity log below.
! while it waits for your permission, and turns grey when it is done.--password, sk-…/ghp_… keys, user:pass@ in URLs) and fields named like password, token or api_key are masked first. The masking is best effort, and only arguments are shown, not results.The office is drawn as SVG on the desktop app, the VS Code extension and the mobile app; the terminal shows a text list instead.
At the prompt of a terminal session:
/plugin install pixel-office --marketplace force416/claude-pixel-office
Answer y to add the marketplace, then pick a scope (user is first).
From a shell:
claude plugin install pixel-office --marketplace force416/claude-pixel-office
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with force416/claude-pixel-office, then Add plugin.
The pane opens when a session starts. If you close it, run /pixel-office.
The plugin is named pixel-office rather than claude-pixel-office because plugin names starting with claude- are kept for Anthropic's own plugins.
hooks/register.tsx 304 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, AgentView, LogEntry, Office, Usage } from '../types'
5import { drawOffice, usageLine } from './office'
6import { callDetails } from './redact'
7
8// A tool call the log records: its tool_use_id and its arguments.
9type Call = { id: string; input: unknown }
10
11const PANE = 'pixel-office'
12// The log keeps the newest KEPT calls and shows them PAGE at a time.
13const KEPT = 200
14const PAGE = 20
15const MAIN: AgentView = { id: 'main', label: 'main', type: 'main', activity: 'idle' }
16const INITIAL: Office = { agents: [MAIN], log: [] }
17const office = atom({ plugin: 'pixel-office', key: 'office' } as const, INITIAL)
18
19const WORDS: Record<Activity, string> = {
20 type: '打字',
21 read: '閱讀',
22 shell: '終端機',
23 web: '上網',
24 delegate: '派工',
25 tool: '工具',
26 think: '思考',
27 wait: '等待權限',
28 idle: '閒置',
29 done: '完成',
30 failed: '失敗',
31}
32
33function activityOf(tool: string): Activity {
34 if (['Edit', 'Write', 'NotebookEdit'].includes(tool)) return 'type'
35 if (['Read', 'Grep', 'Glob'].includes(tool)) return 'read'
36 if (tool === 'Bash') return 'shell'
37 if (tool === 'WebFetch' || tool === 'WebSearch') return 'web'
38 if (tool === 'Agent' || tool === 'Task') return 'delegate'
39 return 'tool'
40}
41
42// Only the file name: patterns, commands and URLs can hold secrets.
43function detailOf(input: unknown): string | undefined {
44 if (typeof input !== 'object' || input === null) return undefined
45 const path = (input as Record<string, unknown>).file_path ?? (input as Record<string, unknown>).notebook_path
46 return typeof path === 'string' ? path.split('/').pop() : undefined
47}
48
49async function logEntry($: EngineInterface, agent: AgentView, tool: string, call: Call): Promise<LogEntry> {
50 const now = new Date(await $.clock.now())
51 const at = [now.getHours(), now.getMinutes(), now.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
52 return {
53 at,
54 who: agent.label.slice(0, 12),
55 tool,
56 detail: detailOf(call.input),
57 isMain: agent.id === 'main',
58 id: call.id,
59 details: callDetails(tool, call.input),
60 }
61}
62
63// Tool names by kind, in theme colors so they follow light and dark themes.
64const TOOL_COLORS: Partial<Record<Activity, string>> = {
65 type: 'success',
66 read: 'suggestion',
67 shell: 'warning',
68 web: 'ide',
69 delegate: 'claude',
70 tool: 'subtle',
71}
72
73// `mcp__server__tool` reads as the tool, with its server beside it.
74function toolName(tool: string): [name: string, server?: string] {
75 const match = /^mcp__(.+?)__(.+)$/.exec(tool)
76 return match ? [match[2]!, match[1]!] : [tool]
77}
78
79// One write per event, and none when nothing would change: every write
80// redraws the pane. A subagent joins the first time one of its events
81// arrives; loops that $.agent.list() does not name (compaction, memory
82// forks) stay out.
83async function setActivity(
84 $: EngineInterface,
85 agentId: string | undefined,
86 activity: Activity,
87 tool?: string,
88 logged?: Call,
89) {
90 const id = agentId ?? 'main'
91 const current = await read($, office)
92 let agent = current.agents.find(a => a.id === id)
93 if (!agent) {
94 const info = (await $.agent.list()).find(a => a.id === id)
95 if (!info) return
96 agent = { id, label: info.description || info.type, type: info.type, activity }
97 } else if (agent.activity === activity && agent.tool === tool && !logged) {
98 return
99 }
100 const changed: AgentView = { ...agent, activity, tool }
101 const entry = logged && tool ? await logEntry($, changed, tool, logged) : undefined
102 await update($, office, value => ({
103 ...value,
104 agents: value.agents.some(a => a.id === id)
105 ? value.agents.map(a => (a.id === id ? changed : a))
106 : [...value.agents, changed],
107 log: entry ? [...value.log, entry].slice(-KEPT) : value.log,
108 calls: (value.calls ?? value.log.length) + (entry ? 1 : 0),
109 }))
110}
111
112// The boards beside the main desk. Read without a breakdown, which is free;
113// written only when a figure changed, as every write redraws the pane.
114async function refreshUsage($: EngineInterface) {
115 const { context, rateLimits } = await $.session.usage()
116 const usage: Usage = {
117 context: { tokens: context.tokens, window: context.window, percent: context.percent },
118 limits: rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
119 }
120 const { usage: shown } = await read($, office)
121 if (JSON.stringify(shown) === JSON.stringify(usage)) return
122 await update($, office, value => ({ ...value, usage }))
123}
124
125export const register: Register = on => {
126 on('session.start', async ($, e, next) => {
127 await $.command.register({
128 name: 'pixel-office',
129 description: 'Show the agents of this session as pixel characters',
130 })
131 void $.ui.open({ id: PANE, title: 'Pixel Office' })
132 const started = await next(e)
133 await refreshUsage($)
134
135 return started
136 })
137
138 on('command.run', { command: 'pixel-office' }, async $ => {
139 await $.ui.open({ id: PANE, title: 'Pixel Office' })
140
141 return { text: 'Pixel Office pane opened.' }
142 })
143
144 on('turn.start', async ($, e, next) => {
145 await setActivity($, e.agentId, 'think')
146
147 return next(e)
148 })
149
150 on('turn.complete', async ($, e, next) => {
151 const ended: Activity = e.agentId === undefined ? 'idle' : e.isAborted ? 'failed' : 'done'
152 await setActivity($, e.agentId, ended)
153 const completed = await next(e)
154 if (e.agentId === undefined) await refreshUsage($)
155
156 return completed
157 })
158
159 // tool.check and turn.step mark a tool's start and end without holding a
160 // hook frame open around the tool, so a subagent's own calls still reach
161 // them. A check with no tool_use_id is a query, not a call. An ask a person
162 // answers is the wait: the classic PermissionRequest and Notification can
163 // be passed by a plugin seated above this one.
164 on('tool.check', async ($, e, next) => {
165 const verdict = await next(e)
166 if (e.tool_use_id !== undefined && verdict.decision !== 'deny') {
167 const isAsked = verdict.decision === 'ask' && e.approver === 'person'
168 await setActivity($, e.agentId, isAsked ? 'wait' : activityOf(e.tool), e.tool, { id: e.tool_use_id, input: e.input })
169 }
170
171 return verdict
172 })
173
174 // A model request after the first means the step's tools have all ended.
175 // The classic PostToolUse can be passed by a plugin seated above this one.
176 // Once the main loop's response is in, its usage figures have moved.
177 on('turn.step', async function* ($, e, next) {
178 if (e.index > 0) await setActivity($, e.agentId, 'think')
179 const response = yield* next(e)
180 if (e.agentId === undefined) await refreshUsage($)
181
182 return response
183 })
184
185 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
186 const { Box, Text, Button, Code } = $.ui.resolve(e)
187 const { agents: list, log: entries, open, page = 0, calls = entries.length, usage } = await read($, office)
188 const now = await $.clock.now()
189 const toggle = (id: string) => update($, office, value => ({ ...value, open: value.open === id ? undefined : id }))
190 const pages = Math.max(1, Math.ceil(entries.length / PAGE))
191 // The page shown, kept inside the log as the log changes.
192 const shown = Math.min(page, pages - 1)
193 const turn = (by: number) =>
194 update($, office, value => {
195 const last = Math.max(0, Math.ceil(value.log.length / PAGE) - 1)
196 return { ...value, page: Math.min(last, Math.max(0, Math.min(value.page ?? 0, last) + by)) }
197 })
198 const alt = list.map(a => `${a.label}: ${WORDS[a.activity]}${a.tool ? ` (${a.tool})` : ''}`).join('; ')
199 // The terminal refuses a face of its own (it has one).
200 const isTerminal = e.surface === 'terminal'
201 const mono = isTerminal ? {} : ({ mono: true } as const)
202 const digits = isTerminal ? {} : ({ mono: true, tabular: true } as const)
203 // Newest first, the newest in bold; columns line up in the mono face. A
204 // row opens to show its call's arguments.
205 const rows = [...entries]
206 .reverse()
207 .slice(shown * PAGE, shown * PAGE + PAGE)
208 .map((one, i) => {
209 const [name, server] = toolName(one.tool)
210 const { id, details } = one
211 const isOpen = id !== undefined && id === open
212 const row = (
213 <Box gap={1}>
214 <Text {...digits} dimColor>
215 {one.at}
216 </Text>
217 <Text color={one.isMain ? 'claude' : 'suggestion'}>●</Text>
218 <Text {...mono} bold={shown === 0 && i === 0}>
219 {one.who.slice(0, 10).padEnd(10)}
220 </Text>
221 <Text {...mono} bold color={TOOL_COLORS[activityOf(one.tool)]}>
222 {name}
223 </Text>
224 {server && (
225 <Text dimColor>
226 {server}
227 </Text>
228 )}
229 {one.detail && (
230 <Text dimColor wrap="truncate-middle">
231 {one.detail}
232 </Text>
233 )}
234 {id && details && (
235 <Button key={`args-${id}`} plain dimColor onPress={() => toggle(id)}>
236 {isOpen ? '▾' : '▸'}
237 </Button>
238 )}
239 </Box>
240 )
241 if (!isOpen || !details) return row
242 return (
243 <Box flexDirection="column">
244 {row}
245 <Box marginLeft={2}>
246 <Code language={details.language} source={details.text} wrap="wrap" />
247 </Box>
248 </Box>
249 )
250 })
251 const activity = (
252 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} marginTop={1}>
253 <Box justifyContent="space-between">
254 <Text bold color="subtle">
255 ACTIVITY
256 </Text>
257 <Box gap={1}>
258 <Text color="subtle">
259 {entries.length === 0
260 ? '0 calls'
261 : `${shown * PAGE + 1}–${Math.min(entries.length, shown * PAGE + PAGE)} / ${entries.length} · ${calls} calls`}
262 </Text>
263 {shown > 0 && (
264 <Button key="log-newer" plain dimColor onPress={() => turn(-1)}>
265 ▲
266 </Button>
267 )}
268 {shown < pages - 1 && (
269 <Button key="log-older" plain dimColor onPress={() => turn(1)}>
270 ▼
271 </Button>
272 )}
273 </Box>
274 </Box>
275 {entries.length === 0 ? <Text dimColor>No tool calls yet.</Text> : rows}
276 </Box>
277 )
278
279 if (e.surface === 'terminal') {
280 return (
281 <Box flexDirection="column">
282 {list.map(a => (
283 <Text>
284 {a.label.padEnd(12)} {WORDS[a.activity]}
285 {a.tool ? ` ${a.tool}` : ''}
286 </Text>
287 ))}
288 {usage && <Text dimColor>{usageLine(usage, now)}</Text>}
289 {activity}
290 </Box>
291 )
292 }
293
294 const { Svg } = $.ui.resolve(e)
295
296 return (
297 <Box flexDirection="column">
298 <Svg source={drawOffice(list, usage, now)} alt={usage ? `${alt}. ${usageLine(usage, now)}` : alt} />
299 {activity}
300 </Box>
301 )
302 })
303}
304hooks/office.ts 469 lines1import type { Activity, AgentView, Usage } from '../types'
2import { CLAWD, CREATURES } from './creatures'
3import type { Creature } from './creatures'
4
5// One unit is one art pixel; the SVG scales it by S. The surface fits a
6// wider SVG to the pane, so a large S fills the pane's width.
7const S = 5
8const W = 60
9const H = 62
10// Characters stand in front of the desk at the art's own scale.
11const ZOOM = 1
12const COLS = 3
13const M = 6
14const WALL = 30
15const MAX_SUBS = 30
16
17const INK = '#2b2836'
18
19const DOTS: Record<Activity, string> = {
20 type: '#3ecf6e',
21 read: '#3ecf6e',
22 shell: '#3ecf6e',
23 web: '#3ecf6e',
24 delegate: '#3ecf6e',
25 tool: '#3ecf6e',
26 think: '#3ecf6e',
27 wait: '#f5b82e',
28 idle: '#9aa0b5',
29 done: '#9aa0b5',
30 failed: '#e5534b',
31}
32
33const GLYPHS: Partial<Record<Activity, string[]>> = {
34 read: ['.##..', '#..#.', '#..#.', '.##..', '....#'],
35 shell: ['#....', '.#...', '#....', '..###', '.....'],
36 web: ['.###.', '#.#.#', '#####', '#.#.#', '.###.'],
37 delegate: ['.###.', '#####', '#...#', '#.#.#', '#####'],
38 tool: ['.#.#.', '##.##', '..#..', '##.##', '.#.#.'],
39 wait: ['..#..', '..#..', '..#..', '.....', '..#..'],
40 done: ['....#', '...#.', '#.#..', '.#...', '.....'],
41 failed: ['#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
42 idle: ['####.', '..#..', '.#...', '####.', '.....'],
43}
44const GLYPH_COLORS: Partial<Record<Activity, string>> = {
45 read: '#3d8fe0',
46 shell: INK,
47 web: '#3d6fd6',
48 delegate: '#8d5524',
49 tool: '#6b7080',
50 wait: '#e5534b',
51 done: '#2e9d57',
52 failed: '#e5534b',
53}
54
55const hash = (text: string): number => {
56 let n = 0
57 for (const ch of text) n = (n * 31 + ch.charCodeAt(0)) >>> 0
58 return n
59}
60
61const escape = (text: string): string => text.replace(/[&<>"']/g, ch => `&#${ch.charCodeAt(0)};`)
62
63const rect = (x: number, y: number, w: number, h: number, fill: string, inner = ''): string =>
64 inner === ''
65 ? `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`
66 : `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}">${inner}</rect>`
67
68const anim = (attr: string, values: string, dur: string, extra = ''): string =>
69 `<animate attributeName="${attr}" values="${values}" dur="${dur}" repeatCount="indefinite"${extra}/>`
70
71const move = (values: string, dur: string, extra = ''): string =>
72 `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${dur}" repeatCount="indefinite"${extra}/>`
73
74const use = (id: string, x: number, y: number, color?: string): string =>
75 `<use href="#${id}" x="${x}" y="${y}"${color ? ` color="${color}"` : ''}/>`
76
77// The cells whose key is in `keys`, as one path of row runs.
78function runs(rows: string[], keys: string): string {
79 let d = ''
80 rows.forEach((row, y) => {
81 for (let x = 0; x < row.length; ) {
82 if (!keys.includes(row[x]!)) {
83 x++
84 continue
85 }
86 let n = 1
87 while (x + n < row.length && keys.includes(row[x + n]!)) n++
88 d += `M${x} ${y}h${n}v1h-${n}z`
89 x += n
90 }
91 })
92 return d
93}
94
95function sprite(id: string, rows: string[], palette: Record<string, string>): string {
96 const paths = Object.entries(palette)
97 .map(([key, fill]) => {
98 const d = runs(rows, key)
99 return d ? `<path d="${d}" fill="${fill}"/>` : ''
100 })
101 .join('')
102 return `<symbol id="${id}" overflow="visible">${paths}</symbol>`
103}
104
105// The sprite's silhouette grown by one pixel all round, as 'g' cells; it is
106// one pixel larger on every side, so it is drawn at -1, -1.
107function halo(rows: string[]): string[] {
108 const h = rows.length
109 const w = rows[0]!.length
110 const isSolid = (x: number, y: number) => y >= 0 && y < h && x >= 0 && x < w && rows[y]![x] !== '.'
111 return Array.from({ length: h + 2 }, (_, y) =>
112 Array.from({ length: w + 2 }, (_, x) => {
113 for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (isSolid(x - 1 + dx, y - 1 + dy)) return 'g'
114 return '.'
115 }).join(''),
116 )
117}
118
119const creatureOf = (a: AgentView): Creature =>
120 a.id === 'main' ? CLAWD : CREATURES[hash(a.id) % CREATURES.length]!
121
122const DESK =
123 `<symbol id="pa-desk" overflow="visible">` +
124 rect(1, 28, 48, 2, '#b4b8c6') +
125 rect(0, 8, 50, 21, INK) +
126 rect(1, 9, 48, 13, '#4a4e63') +
127 rect(1, 9, 48, 1, '#62677f') +
128 rect(1, 22, 48, 6, '#363a4c') +
129 rect(4, 24, 10, 1, '#2b2e3d') +
130 rect(36, 24, 10, 1, '#2b2e3d') +
131 rect(6, 0, 18, 12, INK) +
132 rect(13, 12, 4, 3, INK) +
133 rect(11, 14, 8, 2, '#3b3e4f') +
134 rect(26, 0, 18, 12, INK) +
135 rect(33, 12, 4, 3, INK) +
136 rect(31, 14, 8, 2, '#3b3e4f') +
137 rect(16, 17, 18, 4, INK) +
138 rect(17, 18, 16, 2, '#8a8fa6') +
139 rect(43, 12, 5, 5, INK) +
140 rect(44, 13, 3, 3, '#e9e4d8') +
141 rect(0, 8, 8, 6, INK) +
142 rect(1, 9, 6, 4, '#4caf50') +
143 rect(2, 9, 2, 1, '#7bd17f') +
144 rect(1, 13, 6, 5, INK) +
145 rect(2, 14, 4, 3, '#c0703f') +
146 `</symbol>`
147
148// Only the characters in the office get a symbol.
149const defs = (cast: Creature[]): string =>
150 `<defs>` +
151 `<pattern id="pa-floor" width="16" height="16" patternUnits="userSpaceOnUse">` +
152 rect(0, 0, 16, 16, '#d3d6df') +
153 rect(15, 0, 1, 16, '#c7cad5') +
154 rect(0, 15, 16, 1, '#c7cad5') +
155 rect(5, 6, 1, 1, '#c9ccd6') +
156 rect(11, 10, 1, 1, '#c9ccd6') +
157 `</pattern>` +
158 `<filter id="pa-grey"><feColorMatrix type="saturate" values="0"/></filter>` +
159 cast.map(c => sprite(`pa-${c.id}`, c.rows, c.palette)).join('') +
160 sprite('pa-halo', halo(CLAWD.rows), { g: '#fff1d6' }) +
161 DESK +
162 `</defs>`
163
164const glyph = (rows: string[], x: number, y: number, color: string): string =>
165 rows
166 .flatMap((row, dy) => [...row].map((ch, dx) => (ch === '#' ? rect(x + dx, y + dy, 1, 1, color) : '')))
167 .join('')
168
169const bubble = (x: number, y: number, inner: string, extra = ''): string =>
170 `<g>${rect(x - 1, y, 11, 9, INK)}${rect(x, y - 1, 9, 11, INK)}` +
171 `${rect(x, y, 9, 9, '#ffffff')}${rect(x + 1, y + 9, 2, 2, INK)}${inner}${extra}</g>`
172
173// Both screens of a desk; x, y is the left screen's corner, 16 x 10 each.
174function screens(activity: Activity, x: number, y: number): string {
175 const off = (sx: number) => rect(sx, y, 16, 10, '#14161f')
176 const chat = rect(x + 20, y, 16, 10, '#1e2233') + rect(x + 22, y + 2, 9, 2, '#3d6fd6') + rect(x + 25, y + 6, 9, 2, '#4a4e63')
177 const dark = rect(x, y, 16, 10, '#1e2233')
178 switch (activity) {
179 case 'type':
180 case 'tool':
181 return (
182 dark +
183 rect(x + 2, y + 2, 6, 1, '#c678dd') +
184 rect(x + 9, y + 2, 4, 1, '#61afef') +
185 rect(x + 4, y + 4, 8, 1, '#98c379') +
186 rect(x + 4, y + 6, 5, 1, '#e5c07b') +
187 rect(x + 2, y + 8, 2, 1, '#98c379', anim('width', '1;10;1', '1.4s')) +
188 chat
189 )
190 case 'read':
191 return (
192 rect(x, y, 16, 10, '#f1f3f7') +
193 rect(x + 2, y + 2, 12, 1, '#9aa0b5') +
194 rect(x + 2, y + 4, 10, 1, '#9aa0b5') +
195 rect(x + 2, y + 6, 12, 1, '#ffd54f') +
196 rect(x + 2, y + 8, 8, 1, '#9aa0b5') +
197 chat
198 )
199 case 'shell':
200 return (
201 rect(x, y, 16, 10, '#0b0d12') +
202 rect(x + 2, y + 2, 1, 1, '#3ecf6e') +
203 rect(x + 4, y + 2, 8, 1, '#cfd3dc') +
204 rect(x + 2, y + 4, 10, 1, '#6b7080') +
205 rect(x + 2, y + 6, 1, 1, '#3ecf6e') +
206 rect(x + 4, y + 6, 2, 1, '#3ecf6e', anim('opacity', '1;0', '0.8s', ' calcMode="discrete"')) +
207 chat
208 )
209 case 'web':
210 return (
211 rect(x, y, 16, 10, '#ffffff') +
212 rect(x, y, 16, 2, '#dfe3ea') +
213 rect(x + 2, y + 3, 12, 3, '#8fc9ea') +
214 rect(x + 2, y + 7, 5, 2, '#3d6fd6') +
215 rect(x + 9, y + 7, 5, 2, '#c7cad5') +
216 chat
217 )
218 case 'delegate':
219 return (
220 dark +
221 rect(x + 6, y + 1, 4, 3, '#f5b82e') +
222 rect(x + 7, y + 4, 2, 1, '#9aa0b5') +
223 rect(x + 3, y + 5, 10, 1, '#9aa0b5') +
224 rect(x + 2, y + 6, 4, 3, '#3d8fe0') +
225 rect(x + 10, y + 6, 4, 3, '#3d8fe0') +
226 chat
227 )
228 case 'wait':
229 return dark + rect(x + 3, y + 2, 10, 6, '#f5b82e') + rect(x + 7, y + 3, 2, 2, INK) + rect(x + 7, y + 6, 2, 1, INK) + chat
230 case 'idle':
231 return rect(x, y, 16, 10, '#16213a') + rect(x + 7, y + 4, 2, 2, '#8fc9ea', anim('opacity', '0.2;1;0.2', '3s')) + off(x + 20)
232 case 'done':
233 case 'failed':
234 return off(x) + off(x + 20)
235 default:
236 return dark + rect(x + 2, y + 2, 10, 1, '#4a4e63') + rect(x + 2, y + 4, 7, 1, '#4a4e63') + rect(x + 2, y + 6, 11, 1, '#4a4e63') + chat
237 }
238}
239
240// How the character moves: a quick hop while it works, a jump while it
241// waits for you, a slow breath while idle.
242function motion(activity: Activity): string {
243 switch (activity) {
244 case 'wait':
245 return move('0 0;0 -3;0 0', '0.5s')
246 case 'idle':
247 return move('0 0;0 1;0 0', '2.4s')
248 case 'think':
249 case 'done':
250 case 'failed':
251 return ''
252 default:
253 return move('0 0;0 -1;0 0', '0.4s')
254 }
255}
256
257function icon(a: AgentView, ox: number, oy: number): string {
258 if (a.activity === 'think') {
259 return (
260 rect(ox + 40, oy + 16, 11, 6, INK) +
261 rect(ox + 41, oy + 17, 9, 4, '#ffffff') +
262 rect(ox + 39, oy + 21, 2, 2, INK) +
263 [0, 1, 2].map(i => rect(ox + 42 + i * 3, oy + 18, 1, 2, INK, anim('opacity', '0;1;1;0', '1.5s', ` begin="${i * 0.3}s"`))).join('')
264 )
265 }
266 const rows = GLYPHS[a.activity]
267 if (!rows) return ''
268 if (a.activity === 'idle') {
269 return `<g>${glyph(rows, ox + 41, oy + 12, '#5c6178')}${move('0 3;0 -2', '2.5s')}${anim('opacity', '0;1;0', '2.5s')}</g>`
270 }
271 const bounce = a.activity === 'wait' ? move('0 0;0 -2;0 0', '0.6s') : ''
272 return bubble(ox + 41, oy + 12, glyph(rows, ox + 43, oy + 14, GLYPH_COLORS[a.activity] ?? INK), bounce)
273}
274
275// A Gather-style name tag: a dark pill with a status dot.
276function tag(cx: number, y: number, label: string, dot: string): string {
277 const units = [...label].reduce((n, ch) => n + (ch.charCodeAt(0) > 0x2e7f ? 5.4 : 3.1), 0)
278 const w = Math.ceil(units + 11)
279 const x = cx - w / 2
280 return (
281 `<g shape-rendering="geometricPrecision">` +
282 `<rect x="${x}" y="${y}" width="${w}" height="8" rx="4" fill="#1f2130" fill-opacity="0.82"/>` +
283 `<circle cx="${x + 4.5}" cy="${y + 4}" r="1.7" fill="${dot}"/>` +
284 `<text x="${x + 7.5}" y="${y + 5.9}" font-family="Inter,Helvetica,Arial,sans-serif" font-size="5.2" font-weight="600" fill="#ffffff">${escape(label)}</text>` +
285 `</g>`
286 )
287}
288
289function station(a: AgentView, ox: number, oy: number): string {
290 const isGrey = a.activity === 'done' || a.activity === 'failed'
291 const creature = creatureOf(a)
292 const label = a.label.length > 14 ? `${a.label.slice(0, 13)}…` : a.label
293 // Every character stands on the floor in front of the desk, below the
294 // screens, centred on the desk.
295 const left = ox + 30 - (creature.rows[0]!.length * ZOOM) / 2
296 const top = oy + 38 - creature.rows.length * ZOOM
297
298 return (
299 `<g${isGrey ? ' opacity="0.6" filter="url(#pa-grey)"' : ''}>` +
300 use('pa-desk', ox + 5, oy + 4) +
301 screens(a.activity, ox + 12, oy + 5) +
302 rect(ox + 21, oy + 37, 18, 2, '#c2c5d0') +
303 `<g>${motion(a.activity)}<g transform="translate(${left} ${top}) scale(${ZOOM})">` +
304 // The main agent's light rim keeps the orange mascot clear of the dark desk.
305 (a.id === 'main' ? use('pa-halo', -1, -1) : '') +
306 `${use(`pa-${creature.id}`, 0, 0)}</g></g>` +
307 `</g>` +
308 icon(a, ox, oy) +
309 tag(ox + 30, oy + 50, label, DOTS[a.activity])
310 )
311}
312
313function wall(w: number, count: number): string {
314 const window = (x: number) =>
315 rect(x, 5, 32, 18, INK) +
316 rect(x + 1, 6, 30, 16, '#5c6178') +
317 rect(x + 2, 7, 13, 14, '#8fc9ea') +
318 rect(x + 17, 7, 13, 14, '#8fc9ea') +
319 rect(x + 4, 8, 2, 6, '#c4e6f7') +
320 rect(x + 19, 8, 2, 6, '#c4e6f7')
321 const bx = w / 2 - 22
322 const clock = bx + 50
323 let panels = ''
324 for (let x = 0; x < w; x += 24) panels += rect(x, 0, 1, WALL - 3, '#2a2d42')
325 return (
326 rect(0, 0, w, WALL, '#30334a') +
327 panels +
328 rect(0, WALL - 3, w, 3, '#1f2133') +
329 rect(0, WALL, w, 2, '#b9bcc8') +
330 window(8) +
331 window(w - 40) +
332 rect(bx, 4, 44, 20, INK) +
333 rect(bx + 1, 5, 42, 18, '#9aa0b5') +
334 rect(bx + 2, 6, 40, 16, '#f4f5f8') +
335 rect(bx + 5, 16, 10, 1, '#3d6fd6') +
336 rect(bx + 5, 19, 16, 1, '#e5534b') +
337 rect(bx + 26, 16, 10, 1, '#3ecf6e') +
338 `<text x="${w / 2}" y="12.5" font-family="Inter,Helvetica,Arial,sans-serif" font-size="5.2" font-weight="600" fill="${INK}" text-anchor="middle" shape-rendering="geometricPrecision">${count} agent${count === 1 ? '' : 's'}</text>` +
339 rect(clock, 8, 9, 9, INK) +
340 rect(clock + 1, 9, 7, 7, '#f4f5f8') +
341 rect(clock + 4, 10, 1, 3, INK) +
342 rect(clock + 4, 12, 2, 1, INK)
343 )
344}
345
346const FONT = 'font-family="Inter,Helvetica,Arial,sans-serif"'
347
348// 569172 -> 569.2k, 1000000 -> 1M.
349function tokens(n: number): string {
350 if (n >= 1e6) return `${Number((n / 1e6).toFixed(1))}M`
351 if (n >= 1e3) return `${Number((n / 1e3).toFixed(1))}k`
352 return String(n)
353}
354
355const LIMIT_NAMES: Record<string, string> = { five_hour: 'Session', seven_day: 'Weekly', spend_limit: 'Spend' }
356const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
357
358// When a window resets: "4h 50m" within a day, else "Wed 07:00".
359function resets(at: string | undefined, now: number): string {
360 const when = at === undefined ? NaN : Date.parse(at)
361 if (Number.isNaN(when)) return ''
362 const minutes = Math.max(0, Math.round((when - now) / 60000))
363 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
364 const date = new Date(when)
365 return `${DAYS[date.getDay()]} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
366}
367
368const contextPercent = (usage: Usage): number | undefined =>
369 usage.context.percent ?? (usage.context.tokens === undefined ? undefined : Math.round((usage.context.tokens / usage.context.window) * 100))
370
371// The usage figures as one line, for the terminal and the drawing's alt text.
372export function usageLine(usage: Usage, now: number): string {
373 const percent = contextPercent(usage)
374 const parts = [`Context ${percent === undefined ? '—' : `${percent}%`}`]
375 for (const limit of usage.limits) {
376 const reset = resets(limit.resetsAt, now)
377 parts.push(`${LIMIT_NAMES[limit.kind] ?? limit.kind} ${limit.percentUsed}%${reset ? ` (resets ${reset})` : ''}`)
378 }
379 return parts.join(' · ')
380}
381
382// A board on two legs, its face (x, y, w, h), with a header label.
383function board(x: number, y: number, w: number, h: number, title: string): string {
384 return (
385 rect(x + 6, y + h, 2, 4, INK) +
386 rect(x + w - 8, y + h, 2, 4, INK) +
387 rect(x, y, w, h, INK) +
388 rect(x + 1, y + 1, w - 2, h - 2, '#1f2130') +
389 rect(x + 1, y + 1, w - 2, 7, '#2a2d3e') +
390 `<text x="${x + 4}" y="${y + 6}" ${FONT} font-size="4.4" font-weight="700" fill="#9aa0b5" shape-rendering="geometricPrecision">${title}</text>`
391 )
392}
393
394// A progress bar: a dark track and a fill for `percent`.
395function bar(x: number, y: number, w: number, percent: number, color: string): string {
396 const filled = Math.round((w * Math.min(100, Math.max(0, percent))) / 100)
397 return rect(x, y, w, 3, '#3b3e4f') + (filled > 0 ? rect(x, y, filled, 3, color) : '')
398}
399
400const text = (x: number, y: number, size: number, fill: string, value: string, extra = ''): string =>
401 `<text x="${x}" y="${y}" ${FONT} font-size="${size}" fill="${fill}" shape-rendering="geometricPrecision"${extra}>${escape(value)}</text>`
402
403// The context window board: the percentage large, the tokens, a bar.
404function contextBoard(usage: Usage | undefined, x: number, y: number): string {
405 const w = 50
406 const percent = usage ? contextPercent(usage) : undefined
407 const { tokens: used, window } = usage?.context ?? { window: 0 }
408 return (
409 board(x, y, w, 40, 'CONTEXT') +
410 text(x + 4, y + 21, 10, '#ef8a3a', percent === undefined ? '—' : `${percent}%`, ' font-weight="700"') +
411 text(x + 4, y + 29, 4.4, '#9aa0b5', used === undefined || window === 0 ? '' : `${tokens(used)} / ${tokens(window)}`) +
412 bar(x + 4, y + 33, w - 8, percent ?? 0, '#5b8def')
413 )
414}
415
416// The rate-limit board: one row per window, each a name, a percentage, a bar
417// and when it resets.
418function limitsBoard(usage: Usage | undefined, now: number, x: number, y: number): string {
419 const w = 50
420 const limits = usage?.limits.slice(0, 2) ?? []
421 const rows = limits
422 .map((limit, i) => {
423 const top = y + 11 + i * 15
424 return (
425 text(x + 4, top + 3, 4.4, '#e6e8ef', LIMIT_NAMES[limit.kind] ?? limit.kind, ' font-weight="600"') +
426 text(x + w - 4, top + 3, 4.4, '#e6e8ef', `${limit.percentUsed}%`, ' text-anchor="end"') +
427 bar(x + 4, top + 5, w - 8, limit.percentUsed, '#5b8def') +
428 text(x + 4, top + 12.5, 3.8, '#8a8fa6', resets(limit.resetsAt, now))
429 )
430 })
431 .join('')
432 return board(x, y, w, 40, 'USAGE') + (rows || text(x + 4, y + 22, 4.4, '#8a8fa6', 'no reading yet'))
433}
434
435export function drawOffice(list: AgentView[], usage?: Usage, now = 0): string {
436 const main = list.find(a => a.id === 'main')
437 const subs = list.filter(a => a.id !== 'main').slice(-MAX_SUBS)
438 const cast = [...new Set([...(main ? [main] : []), ...subs].map(creatureOf))]
439 const rows = Math.max(1, Math.ceil(subs.length / COLS))
440 const w = M * 2 + COLS * W
441 const mainTop = WALL + 6
442 const subTop = mainTop + H + 6
443 const h = subTop + rows * H + 6
444 const rugX = M + W + 2
445
446 const desks = subs.map((a, i) => station(a, M + (i % COLS) * W, subTop + Math.floor(i / COLS) * H)).join('')
447 const empty =
448 subs.length === 0
449 ? `<text x="${w / 2}" y="${subTop + H / 2}" font-family="Inter,Helvetica,Arial,sans-serif" font-size="6" fill="#8a8fa6" text-anchor="middle">no subagents yet</text>`
450 : ''
451
452 return (
453 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w * S}" height="${h * S}" shape-rendering="crispEdges">` +
454 defs(cast) +
455 `<rect x="0" y="0" width="${w}" height="${h}" fill="url(#pa-floor)"/>` +
456 wall(w, list.length) +
457 rect(rugX, mainTop + 2, W - 4, H - 2, '#4f5b8f') +
458 rect(rugX + 1, mainTop + 3, W - 6, H - 4, '#6f7fbf') +
459 rect(rugX + 3, mainTop + 5, W - 10, H - 8, '#8391cc') +
460 rect(rugX + 4, mainTop + 6, W - 12, H - 10, '#6f7fbf') +
461 contextBoard(usage, M + 5, mainTop + 8) +
462 limitsBoard(usage, now, M + 2 * W + 5, mainTop + 8) +
463 (main ? station(main, M + W, mainTop) : '') +
464 desks +
465 empty +
466 `</svg>`
467 )
468}
469hooks/redact.ts 42 lines1// Masks the common shapes of a secret in a shell command before the log
2// keeps it. Best effort: a secret in a shape not listed here stays visible.
3const RULES: [pattern: RegExp, replacement: string][] = [
4 // user:password@ in a URL
5 [/(:\/\/[^:/\s@]+:)[^@\s]+@/g, '$1****@'],
6 // Authorization: Bearer <token>, and the like
7 [/(authorization:\s*(?:bearer|basic|token)\s+)[^\s'"]+/gi, '$1****'],
8 // --password <value>, --token=<value>, ...
9 [/(--?(?:password|passwd|token|api-?key|secret|access-?key)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi, '$1****'],
10 // PASSWORD=<value>, api_key: <value>, ...
11 [/(\b[\w-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s'"]+)/gi, '$1****'],
12 // Key formats that stand alone
13 [/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abprs]-[A-Za-z0-9-]{10,})\b/g, '****'],
14]
15
16const MAX = 2000
17
18// Argument names whose value is a secret whatever it looks like.
19const SECRET_NAME = /password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|authorization|cookie/i
20
21const cut = (text: string): string => (text.length > MAX ? `${text.slice(0, MAX)}…` : text)
22
23export function redact(command: string): string {
24 return cut(RULES.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), command))
25}
26
27export type CallDetails = { text: string; language: 'bash' | 'json' }
28
29// A call's arguments, shown when its log row opens: a Bash command as shell,
30// anything else as JSON with every string masked, the whole cut to MAX.
31export function callDetails(tool: string, input: unknown): CallDetails | undefined {
32 if (typeof input !== 'object' || input === null) return undefined
33 const command = (input as Record<string, unknown>).command
34 if (tool === 'Bash' && typeof command === 'string') return { text: redact(command), language: 'bash' }
35 const json = JSON.stringify(
36 input,
37 (name, value: unknown) => (typeof value === 'string' ? (SECRET_NAME.test(name) ? '****' : redact(value)) : value),
38 2,
39 )
40 return json === '{}' ? undefined : { text: cut(json), language: 'json' }
41}
42hooks/creatures.ts 279 lines1// Characters: Claude Code's orange mascot for the main agent, and original
2// pocket creatures a subagent gets by its id. Rows are one character per art
3// pixel, '.' transparent; `palette` colors every other key. 'k' and 'e' are
4// ink.
5
6export type Creature = { id: string; name: string; rows: string[]; palette: Record<string, string> }
7
8const INK = '#2b2836'
9const SKIN = { r: '#f4a3a3', m: '#b5524f' }
10
11// The startup banner's shape: a block body with eye slits, arms out at the
12// middle, four short legs.
13export const CLAWD: Creature = {
14 id: 'clawd',
15 name: 'Claude',
16 rows: [
17 '...kkkkkkkkkkkk...',
18 '...kcccccccccck...',
19 '...kcecccccceck...',
20 '...kcecccccceck...',
21 '.kkkcccccccccckkk.',
22 '.kcccccccccccccck.',
23 '.kkkcccccccccckkk.',
24 '...kcccccccccck...',
25 '...kCCCCCCCCCCk...',
26 '...kkkkkkkkkkkk...',
27 '...kckck..kckck...',
28 '...kkkkk..kkkkk...',
29 ],
30 palette: { k: INK, e: INK, c: '#d97757', C: '#b85f42' },
31}
32
33export const CREATURES: Creature[] = [
34 {
35 id: 'wickling',
36 name: '燭芯',
37 rows: [
38 '.......kk.......',
39 '......kaak......',
40 '.....kabbak.....',
41 '.....kabbak.....',
42 '......kaak......',
43 '.......kd.......',
44 '....kkkkkkkk....',
45 '...kcccccccCk...',
46 '..kcccccccccck..',
47 '..kcceccccecck..',
48 '..kcceccccecck..',
49 '..kcCccmmccCck..',
50 '..kcCccccccCck..',
51 '..kCccccccccCk..',
52 '..kCCccccccCCk..',
53 '...kkkkkkkkkk...',
54 ],
55 palette: { k: INK, e: INK, m: SKIN.m, a: '#ff9f1c', b: '#ffe066', d: '#5a4632', c: '#f6ecd4', C: '#e0cfa8' },
56 },
57 {
58 id: 'bubbleslug',
59 name: '泡泡蛞蝓',
60 rows: [
61 '.kkk......kkk...',
62 'kwbbk....kwbbk..',
63 'kbbbk....kbbbk..',
64 '.kkk......kkk...',
65 '...k......k.....',
66 '..kkkkkkkkkk....',
67 '.kaaaaaaaaaak...',
68 '.kaaeaaaaeaak...',
69 '.kaaeaaaaeaak...',
70 '.kaaaammaaaak...',
71 '.kaaaaaaaaaakk..',
72 '.kAaaaaaaaaaaak.',
73 '.kAAaaaaaaaaaaak',
74 '..kAAAAAAAAAAAk.',
75 '...kkkkkkkkkkk..',
76 ],
77 palette: { k: INK, e: INK, m: SKIN.m, w: '#ffffff', b: '#bfefff', a: '#4cc9c0', A: '#2f9e97' },
78 },
79 {
80 id: 'mossball',
81 name: '苔球',
82 rows: [
83 '..kk........kk..',
84 '.kggk......kggk.',
85 '.kgGgk....kgGgk.',
86 '..kgGgkkkkgGgk..',
87 '...kkaaaaaakk...',
88 '..kaaaaaaaafpak.',
89 '.kaaaaaaaaaffaak',
90 '.kaaeaaaaeaaaak.',
91 '.kaaeaaaaeaaaak.',
92 '.kaAaammaaaaAak.',
93 '.kAaaaaaaaaaAAk.',
94 '.kAAaaaaaaaaAAk.',
95 '..kAAAaaaaAAAk..',
96 '...kkkkkkkkkk...',
97 ],
98 palette: {
99 k: INK, e: INK, m: SKIN.m,
100 g: '#7bd17f', G: '#3f9a4a', a: '#6abf5e', A: '#4a9a42', f: '#ff8fb1', p: '#ffe066',
101 },
102 },
103 {
104 id: 'plugbot',
105 name: '插座仔',
106 rows: [
107 '.......kk.......',
108 '......kyyk......',
109 '.......kk.......',
110 '...kkkkkkkkkk...',
111 '..kaaaaaaaaaak..',
112 '..kawwaaaawwak..',
113 '..kaweaaaaewak..',
114 '..kaaaammaaaak..',
115 '..kAAAAAAAAAAk..',
116 '...kaayyyyaak...',
117 '...kaaayyaaak...',
118 '...kAAAAAAAAk...',
119 '...kkk....kkk...',
120 ],
121 palette: { k: INK, e: INK, m: SKIN.m, w: '#ffffff', a: '#8fa3c7', A: '#6b7fa6', y: '#ffd23f' },
122 },
123 {
124 id: 'snowpuff',
125 name: '雪團',
126 rows: [
127 '....k.k..k.k....',
128 '....kikkkkik....',
129 '....kiiiiiik....',
130 '...kwwwwwwwwk...',
131 '..kwwwwwwwwwwk..',
132 '.kwwwwwwwwwwwwk.',
133 '.kwwewwwwwwewwk.',
134 '.kwwewwwwwwewwk.',
135 '.kwrwwwmmwwwrwk.',
136 '.kwwwwwwwwwwwwk.',
137 '.kWwwwwwwwwwwWk.',
138 '..kWWwwwwwwWWk..',
139 '...kkkkkkkkkk...',
140 ],
141 palette: { k: INK, e: INK, m: SKIN.m, r: SKIN.r, w: '#f7fbff', W: '#cfdbe8', i: '#7cc6f0' },
142 },
143 {
144 id: 'pebblecake',
145 name: '石餅',
146 rows: [
147 '....kkkkkkkk....',
148 '..kkggggggaakk..',
149 '.kaaggggaaaaaak.',
150 'kaaaaaaaaaaaaaak',
151 'kaaaeeaaaaeeaaak',
152 'kaaaaaaaaaaaaaak',
153 'kaaaaaammaaaaaak',
154 'kAaaaaaaaaaaaaAk',
155 '.kAAaaaaaaaaAAk.',
156 '..kkkkkkkkkkkk..',
157 ],
158 palette: { k: INK, e: INK, m: SKIN.m, a: '#a9a9b8', A: '#7d7d8f', g: '#6abf5e' },
159 },
160 {
161 id: 'inkdrop',
162 name: '墨滴',
163 rows: [
164 '.......kk.......',
165 '......kaak......',
166 '.....kaaaak.....',
167 '....kaaaaaak....',
168 '...kaawaaaaak...',
169 '..kaawwaaaaaak..',
170 '..kaaaaaaaaaak..',
171 '.kaaeaaaaaaeaak.',
172 '.kaaeaaaaaaeaak.',
173 '.kaaaaammaaaaak.',
174 '.kAaaaaaaaaaaAk.',
175 '.kAAaaaaaaaaAAk.',
176 '..kAAAaaaaAAAk..',
177 '...kkkkkkkkkk...',
178 ],
179 palette: { k: INK, e: INK, m: SKIN.m, a: '#8e6fd8', A: '#6a4fb0', w: '#d9cbff' },
180 },
181 {
182 id: 'paperbird',
183 name: '紙雀',
184 rows: [
185 '.......k........',
186 '......kwk.......',
187 '.....kwwwk......',
188 '....kwwewwk.....',
189 '...kwwwwwwwkk...',
190 '..kwwwwwwwwwook.',
191 'kkwWwwwwwwwwkk..',
192 'kWWWwwwwwwwwk...',
193 '.kWWWwwwwwwwk...',
194 '..kWWwwwwwwk....',
195 '...kkwwwwkk.....',
196 '.....kokok......',
197 ],
198 palette: { k: INK, e: INK, w: '#fdf6e3', W: '#e3d9bf', o: '#f4a259' },
199 },
200 {
201 id: 'shroomgrub',
202 name: '菇蟲',
203 rows: [
204 '....kkkkkkkk....',
205 '..kkrrwrrrrwrkk.',
206 '.krrrrrrrwwrrrk.',
207 'krwwrrrrrrrrrrrk',
208 'kkkkkkkkkkkkkkkk',
209 '..kyyyyyyyyyyk..',
210 '..kyeyyyyyyeyk..',
211 '..kyeyyyyyyeyk..',
212 '..kyyyymmyyyyk..',
213 '..kYyyyyyyyyYk..',
214 '..kkYYyyyyYYkk..',
215 '...kYkyyyykYk...',
216 '...kk.kkkk.kk...',
217 ],
218 palette: { k: INK, e: INK, m: SKIN.m, r: '#e5534b', w: '#ffffff', y: '#f2d16b', Y: '#c9a94a' },
219 },
220 {
221 id: 'sheetghost',
222 name: '布影',
223 rows: [
224 '.....kkkkkk.....',
225 '....kwwwwwwk....',
226 '...kwwwwwwwwk...',
227 '..kwwwwwwwwwwk..',
228 '..kwwewwwwewwk..',
229 '..kwwewwwwewwk..',
230 '..kwwwwmmwwwwk..',
231 '.kwwwwwwwwwwwwk.',
232 'kwwwwwwwwwwwwwwk',
233 '.kwWwwwwwwwwWwk.',
234 '..kWwwwwwwwwWk..',
235 '..kWWwwwwwwWWk..',
236 '..kWkWWkkWWkWk..',
237 '...k.kk..kk.k...',
238 ],
239 palette: { k: INK, e: INK, m: SKIN.m, w: '#f4f5f8', W: '#c9ccd6' },
240 },
241 {
242 id: 'teapot',
243 name: '茶壺精',
244 rows: [
245 '.......kk.......',
246 '......kllk......',
247 '....kkkkkkkk....',
248 '...kllllllllk...',
249 '..kkkkkkkkkkkk..',
250 'kk.kttttttttk.kk',
251 'k.kttettttettk.k',
252 'k.kttettttettkkk',
253 'kkkttttmmttttk..',
254 '..kTttttttttTk..',
255 '..kTTttttttTTk..',
256 '...kkkkkkkkkk...',
257 ],
258 palette: { k: INK, e: INK, m: SKIN.m, t: '#4fb3a9', T: '#378a82', l: '#f2d16b' },
259 },
260 {
261 id: 'cloudpuff',
262 name: '雲朵綿',
263 rows: [
264 '....kkk..kkk....',
265 '...kwwwkkwwwk...',
266 '.kkwwwwwwwwwwkk.',
267 'kwwwwwwwwwwwwwwk',
268 'kwwwewwwwwwewwwk',
269 'kwwwewwwwwwewwwk',
270 'kwrwwwwmmwwwwrwk',
271 'kWwwwwwwwwwwwwWk',
272 '.kWWwwwwwwwwWWk.',
273 '..kkWWkkkkWWkk..',
274 '....kk....kk....',
275 ],
276 palette: { k: INK, e: INK, m: SKIN.m, r: SKIN.r, w: '#e8f4ff', W: '#b9d6f2' },
277 },
278]
279types/index.d.ts 58 lines1export type Activity =
2 | 'type'
3 | 'read'
4 | 'shell'
5 | 'web'
6 | 'delegate'
7 | 'tool'
8 | 'think'
9 | 'wait'
10 | 'idle'
11 | 'done'
12 | 'failed'
13
14export type AgentView = {
15 id: string
16 label: string
17 type: string
18 activity: Activity
19 tool?: string
20}
21
22// `details` is the call's arguments with secrets masked: a Bash command as
23// shell, anything else as JSON; absent for a call with none.
24export type LogEntry = {
25 at: string
26 who: string
27 tool: string
28 detail?: string
29 isMain?: boolean
30 id?: string
31 details?: { text: string; language: 'bash' | 'json' }
32}
33
34// The session's usage as the boards beside the main desk show it: the
35// context window, and each rate-limit window (`five_hour`, `seven_day`, ...).
36export type Usage = {
37 context: { tokens?: number; window: number; percent?: number }
38 limits: { kind: string; percentUsed: number; resetsAt?: string }[]
39}
40
41// One value, so one event is one write and one redraw. `open` is the id of
42// the log entry whose command is shown, `page` the log page shown (0 the
43// newest), `calls` every call logged this session, kept or not.
44export type Office = {
45 agents: AgentView[]
46 log: LogEntry[]
47 open?: string
48 page?: number
49 calls?: number
50 usage?: Usage
51}
52
53declare module 'claude-code' {
54 interface PluginState {
55 'pixel-office': { office: Office }
56 }
57}
58