SLOPSHOPPER

pixel-office

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

newpaneguardcommand
v0.1.0no licenseupdated 2026-10-26force416/claude-pixel-office
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-office
│ ┃ Pixel Office ✕ › fix the failing auth test and add an audit log call │ ┃ main 閒置 │ ┃ Context 49% · Session 31% ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭──────────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ ┃ │ ACTIVITY 0 calls │ ⎿ Added 2 lines, removed 1 line │ ┃ │ No tool calls yet. │ ⏺ 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 │ │ › /pixel-office │ ⎿ pixel-office: Pixel Office pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pixel Office
main 閒置 Context 49% · Session 31% ╭──────────────────────────────────────────────────────────╮ │ ACTIVITY 0 calls │ │ No tool calls yet. │ ╰──────────────────────────────────────────────────────────╯
README

claude-pixel-office

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.

  • The main agent is Claude's orange mascot, at its own desk on a rug.
  • Two boards stand beside the main desk: CONTEXT shows how full the context window is, and USAGE shows the session (five-hour) and weekly rate limits with when each resets. They refresh after each of the main agent's model responses.
  • Each subagent gets one of twelve original pocket creatures and a desk of its own, picked by its id.
  • What a character does follows its tool calls: code on screen while it edits, a document while it reads, a terminal while it runs a command, a browser on the web, an org chart while it delegates. It hops while it works, jumps with a red ! while it waits for your permission, and turns grey when it is done.
  • Below the office, an activity log keeps the last 200 tool calls, 20 to a page. Each row opens to show the call's arguments: a Bash command as shell, anything else as JSON. Common secret shapes (bearer tokens, --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.

Install

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.

Notes

  • Claude's mascot belongs to Anthropic; the twelve creatures are original.
  • The mod reads only this session's own events. It sends nothing anywhere.
Source 5 files
hooks/register.tsx 304 lines
1import { 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}
304
hooks/office.ts 469 lines
1import 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}
469
hooks/redact.ts 42 lines
1// 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}
42
hooks/creatures.ts 279 lines
1// 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]
279
types/index.d.ts 58 lines
1export 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