SLOPSHOPPER

agents-pane

Astral Express: each subagent boards as a Honkai: Star Rail pixel hero; the Express rides above the prompt with the working heroes aboard, and a pane plays…

newpanebandguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents-pane
│ ┃ Astral Express ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ Astral Express W 0 · L 0 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ Party │ ⏺ Update(src/auth.ts) │ ┃ │ 0 │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ The Express is quiet. A Trailblazer boards │ ┃ as soon as a subagent starts. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-pane │ ⎿ agents-pane: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Astral Express
✦ Astral Express W 0 · L 0 ╭──────────────────────────────────────────────────────────╮ │ Party │ │ 0 │ ╰──────────────────────────────────────────────────────────╯ The Express is quiet. A Trailblazer boards as soon as a subagent starts.
README

claude-mod

A collection of public Claude Code mods. Each one is a standalone plugin under plugins/, so you install only the ones you want.

<img src="https://img.shields.io/badge/Claude%20Code-plugin%20marketplace-D97757" alt="Claude Code plugin marketplace"> <img src="https://img.shields.io/badge/agents--pane-v0.3.0-555555" alt="agents-pane v0.3.0"> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green.svg" alt="License: MIT"></a>

Contents

Mods

ModVersionWhat it does
agents-pane0.3.0Every subagent boards the Astral Express as a Honkai: Star Rail pixel hero. A strip above the prompt shows the train with the working heroes in its windows, and the /agents-pane pane tracks each subagent's status, tools, tokens and time while its hero plays through their kit.

[!IMPORTANT] Mods are built on function hooks (hooks/register.tsx), an early-access Claude Code API that may change between releases.

agents-pane: the Astral Express

Each subagent takes the next hero in the roster, or the hero its description names (for example "Firefly …"). While it works, its card loops through the hero's kit and names each move as it plays.

HeroPathBasic ATKSkillUltimate
Dan Heng • Imbibitor LunaeDestructionBeneficent Lotus: water slashesTranscendence: dragon scales and a spear thrustAzure's Aqua Ablutes All: full moon and the azure dragon
Dan Heng • Permansor TerraePreservationAegis Vitae: a gauntlet strikeTerra Omnibus: a golden shield, Bondmate and SouldragonA Dragon's Zenith Knows No Rue: the golden dragon dives into a shockwave
CastoriceRemembranceLament, Nethersea's RippleSilence, Wraithfly's Caress: wraithfliesDoomshriek, Dawn's Chime: Netherwing over the Lost Netherland
FireflyDestructionOrder: Flare PropulsionOrder: Aerial BombardmentComplete Combustion: SAM's green flame wings

Firefly fights as SAM, her armor, and returns to herself once her work is done.

  • Above the prompt, the Astral Express rides the star rail with one carriage window per working subagent, up to four. With none at work, it waits on the rail.
  • The pane (/agents-pane) opens on a board styled after Star Rail's battle screen: the action order with the working heroes' faces, trailblaze time, victories and defeats, a skill point for each hero at work, and token totals.
  • In a terminal, the heroes are drawn in half-block characters.

Installation

From the marketplace

In Claude Code, run:

/plugin marketplace add desmondlee95700/claude-mod
/plugin install agents-pane@claude-mod

Then restart the session.

[!TIP] If a mod installed from the marketplace doesn't show up, load it by hand as described below.

By hand

Clone the repository:

git clone https://github.com/desmondlee95700/claude-mod.git ~/claude-mod

Load a mod for a single session:

claude --plugin-dir ~/claude-mod/plugins/agents-pane

Or load it for every session, including the desktop app, through env in ~/.claude/settings.json. Separate several folders with :, and append to the variable if it's already set:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-mod/plugins/agents-pane"
  }
}

Run git pull in the clone to update mods loaded this way.

Repository layout

.claude-plugin/marketplace.json   catalog read by /plugin marketplace add
plugins/<name>/
  .claude-plugin/plugin.json      manifest
  hooks/hooks.json                points at the hooks module
  hooks/register.tsx              the hooks module
  types/index.d.ts                its $.state contract
  tests/*.test.ts                 tests

Validate a plugin and the catalog before publishing:

claude plugin validate plugins/<name>
claude plugin validate .

License

The code is released under the MIT License.

[!NOTE] The pixel heroes are unofficial fan art of Honkai: Star Rail characters. This project is not affiliated with or endorsed by HoYoverse. Honkai: Star Rail and its characters belong to COGNOSPHERE / HoYoverse.

Source 4 files
hooks/register.tsx 772 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRow, AgentStatus } from '../types'
5import { H, W, bigSprite, bustOf, heroNamed, heroOf, hex, pixelsOf } from './cast'
6import type { Hero } from './cast'
7
8const PANE = 'agents'
9const agents = atom({ plugin: 'agents-pane', key: 'agents' } as const, [])
10const isStripHidden = atom({ plugin: 'agents-pane', key: 'isStripHidden' } as const, false)
11// True while the pane is drawn; the strip steps aside then.
12const isPaneUp = atom({ plugin: 'agents-pane', key: 'isPaneUp' } as const, false)
13// Bumped each second while anyone is in battle, so the battle clocks tick.
14const tick = atom({ plugin: 'agents-pane', key: 'tick' } as const, 0)
15// The next roster spot: each subagent boards the Astral Express as the next hero in line.
16const signed = atom({ plugin: 'agents-pane', key: 'signed' } as const, 0)
17
18const STATUS_COLOR: Record<AgentStatus, string> = {
19  running: 'success',
20  done: 'suggestion',
21  stopped: 'warning',
22  failed: 'error',
23}
24const STATUS_MARK: Record<AgentStatus, string> = {
25  running: '●',
26  done: '✦',
27  stopped: '■',
28  failed: '✗',
29}
30const STATUS_LABEL: Record<AgentStatus, string> = {
31  running: 'IN BATTLE',
32  done: 'VICTORY',
33  stopped: 'RETREATED',
34  failed: 'DEFEATED',
35}
36
37type Key = [t: number, x: number, y: number]
38
39function motion(keys: Key[], total: number, begin = 0): string {
40  const times = keys.map(([t]) => (t / total).toFixed(4)).join(';')
41  const values = keys.map(([, x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(';')
42  return `<animateTransform attributeName="transform" type="translate" values="${values}" keyTimes="${times}" dur="${total.toFixed(2)}s" begin="${begin.toFixed(2)}s" repeatCount="indefinite"/>`
43}
44
45// Shown only between `from` and `to` (seconds) of each loop.
46function blink(from: number, to: number, total: number): string {
47  const a = Math.max(0, from / total).toFixed(4)
48  const b = Math.min(1, to / total).toFixed(4)
49  return `<animate attributeName="opacity" values="0;1;0" keyTimes="0;${a};${b}" calcMode="discrete" dur="${total.toFixed(2)}s" repeatCount="indefinite"/>`
50}
51
52function fade(values: string, total: number, begin = 0): string {
53  return `<animate attributeName="opacity" values="${values}" dur="${total.toFixed(2)}s" begin="${begin.toFixed(2)}s" repeatCount="indefinite"/>`
54}
55
56const px = (x: number, y: number, size: number, color: number, extra = '') =>
57  `<rect x="${x}" y="${y}" width="${size}" height="${size}" fill="${hex(color)}"${extra}/>`
58
59const STAR = (x: number, y: number, color: number) =>
60  px(x, y - 1, 1, color) + px(x - 1, y, 1, color) + px(x, y, 1, 0xffffff) + px(x + 1, y, 1, color) + px(x, y + 1, 1, color)
61
62// A detailed hero (or SAM) in a nested box, clipped to `view` ("x y w h" of the 32×40 sprite).
63function bust(name: string, x: number, y: number, w: number, h: number, view = '4 2 24 15'): string {
64  const [, top, , rows] = view.split(' ').map(Number)
65  return `<svg x="${x}" y="${y}" width="${w}" height="${h}" viewBox="${view.replace(/^(\S+) \S+/, '$1 0')}" overflow="hidden">${bustOf(name, top, rows)}</svg>`
66}
67
68// The strip: the Astral Express riding the star rail, one carriage window per working subagent (up to four).
69const SCENE_H = 44
70const CAR_W = 30
71const LOCO_W = 40
72
73function train(party: Hero[], isMoving: boolean): { source: string; width: number } {
74  const n = Math.max(1, party.length)
75  const x0 = 6
76  const loco = x0 + n * (CAR_W + 2)
77  const width = loco + LOCO_W + 10
78  const anim = (inner: string, keys: Key[], total: number) => (isMoving ? `<g>${motion(keys, total)}${inner}</g>` : inner)
79
80  const STARS = [[9, 4], [23, 9], [41, 3], [57, 7], [74, 2], [88, 10], [103, 5], [121, 8], [137, 3], [152, 9], [169, 4], [186, 7], [201, 2], [218, 6]]
81  const field = STARS.filter(([x]) => x! < width)
82    .map(([x, y], i) => `<rect x="${x}" y="${y}" width="${i % 3 ? 1 : 1.5}" height="${i % 3 ? 1 : 1.5}" fill="#ffffff" opacity="${i % 2 ? 0.45 : 0.8}"/>`)
83    .join('')
84  const sky = isMoving ? anim(field + `<g transform="translate(${width} 0)">${field}</g>`, [[0, 0, 0], [8, -width, 0]], 8) : field
85
86  const sleepers = Array.from({ length: Math.ceil(width / 5) + 2 }, (_, i) => `<rect x="${i * 5}" y="41" width="2" height="1" fill="#6b5630"/>`).join('')
87  const rail = `<rect x="0" y="40" width="${width}" height="1" fill="#c9a24a"/>` + anim(sleepers, [[0, 0, 0], [0.25, -5, 0]], 0.25)
88
89  const wheel = (cx: number, cy: number, r: number) =>
90    `<g transform="translate(${cx} ${cy})"><circle r="${r}" fill="#25263a" stroke="#c9a24a" stroke-width="0.6"/>` +
91    `<g>${isMoving ? `<animateTransform attributeName="transform" type="rotate" values="0;360" dur="0.6s" repeatCount="indefinite"/>` : ''}` +
92    `<rect x="-0.3" y="${-r + 0.6}" width="0.6" height="${2 * r - 1.2}" fill="#c9a24a"/><rect x="${-r + 0.6}" y="-0.3" width="${2 * r - 1.2}" height="0.6" fill="#c9a24a"/></g>` +
93    `<circle r="0.9" fill="#f2c14e"/></g>`
94
95  const cars = Array.from({ length: n }, (_, i) => {
96    const x = x0 + i * (CAR_W + 2)
97    const hero = party[i]
98    const window = hero
99      ? `<rect x="${x + 4}" y="17" width="22" height="13" fill="#232c5c"/>` + bust(hero.name, x + 5, 17.5, 20, 12.5)
100      : `<rect x="${x + 4}" y="17" width="22" height="13" fill="#141a3a"/>` + STAR(x + 15, 23, 0x5a6aa8)
101    return (
102      `<rect x="${x + 1}" y="11" width="${CAR_W - 2}" height="3" fill="#2a2f55"/>` +
103      `<rect x="${x}" y="14" width="${CAR_W}" height="1" fill="#c9a24a"/>` +
104      `<rect x="${x}" y="15" width="${CAR_W}" height="21" fill="#efe6d2"/>` +
105      `<rect x="${x}" y="30" width="${CAR_W}" height="1" fill="#c9a24a"/>` +
106      `<rect x="${x}" y="31" width="${CAR_W}" height="5" fill="#7a1f2b"/>` +
107      window +
108      `<rect x="${x + 4}" y="17" width="22" height="13" fill="none" stroke="#c9a24a" stroke-width="0.8"/>` +
109      `<rect x="${x + CAR_W}" y="33" width="2" height="1.5" fill="#3a3a48"/>` +
110      wheel(x + 7, 37, 3.2) +
111      wheel(x + CAR_W - 7, 37, 3.2)
112    )
113  }).join('')
114
115  const L = loco
116  const puffs = isMoving
117    ? [0, 1, 2, 3]
118        .map(
119          i =>
120            `<g opacity="0"><g>${motion([[0, L + 28, 9], [1.6, L + 14, -2]], 1.6, -i * 0.4)}<circle r="2.4" fill="#e9e6ff"/><circle cx="1.6" cy="-0.6" r="0.5" fill="#f2c14e"/></g>${fade('0;0.85;0', 1.6, -i * 0.4)}</g>`,
121        )
122        .join('')
123    : ''
124  const locomotive =
125    `<rect x="${L}" y="11" width="16" height="3" fill="#2a2f55"/>` +
126    `<rect x="${L}" y="14" width="15" height="22" fill="#efe6d2"/>` +
127    `<rect x="${L + 3}" y="16.5" width="9" height="7" fill="#f6d27c"/><rect x="${L + 3}" y="16.5" width="9" height="7" fill="none" stroke="#c9a24a" stroke-width="0.8"/>` +
128    `<rect x="${L}" y="30" width="15" height="1" fill="#c9a24a"/><rect x="${L}" y="31" width="15" height="5" fill="#7a1f2b"/>` +
129    `<rect x="${L + 15}" y="19" width="20" height="17" fill="#7a1f2b"/><rect x="${L + 15}" y="19" width="20" height="2" fill="#9b2f3d"/>` +
130    `<rect x="${L + 19}" y="19" width="1.5" height="17" fill="#c9a24a"/><rect x="${L + 28}" y="19" width="1.5" height="17" fill="#c9a24a"/>` +
131    `<rect x="${L + 21}" y="15.5" width="5" height="3.5" fill="#f2c14e"/>` +
132    `<rect x="${L + 27}" y="12" width="4" height="7" fill="#2a2f55"/><rect x="${L + 26}" y="11" width="6" height="1.5" fill="#f2c14e"/>` +
133    `<rect x="${L + 35}" y="18" width="3" height="18" fill="#c9a24a"/>` +
134    `<circle cx="${L + 37}" cy="23" r="5" fill="#fff3b0" opacity="0.3">${isMoving ? `<animate attributeName="opacity" values="0.2;0.45;0.2" dur="1.2s" repeatCount="indefinite"/>` : ''}</circle>` +
135    `<circle cx="${L + 37}" cy="23" r="1.8" fill="#fff6c8"/>` +
136    `<polygon points="${L + 30},36 ${L + 38},36 ${L + 41},40 ${L + 30},40" fill="#a8802a"/>` +
137    STAR(L + 24, 27, 0xf2c14e) +
138    wheel(L + 7, 37, 3.2) +
139    wheel(L + 20, 36.5, 3.8) +
140    wheel(L + 30, 36.5, 3.8)
141
142  const body = anim(cars + locomotive, [[0, 0, 0], [0.25, 0, -0.4], [0.5, 0, 0]], 0.5)
143  return {
144    source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${SCENE_H}" shape-rendering="crispEdges">${sky}${rail}${puffs}${body}</svg>`,
145    width,
146  }
147}
148
149// A hero's card while they work: their kit on a loop, Basic ATK, then Skill, then Ultimate, each named as it plays.
150const LOOP = 6
151const PHASES: [number, number][] = [
152  [0, 1.6],
153  [1.6, 3.4],
154  [3.4, LOOP],
155]
156
157function phase(i: number, inner: string): string {
158  const [from, to] = PHASES[i]!
159  return `<g opacity="0">${inner}${blink(from, to, LOOP)}</g>`
160}
161
162const spin = (dur: number) => `dur="${dur}s" repeatCount="indefinite"`
163
164function hexagon(r: number): string {
165  return Array.from({ length: 6 }, (_, k) => {
166    const a = (Math.PI / 3) * k + Math.PI / 6
167    return `${(r * Math.cos(a)).toFixed(1)},${(r * Math.sin(a)).toFixed(1)}`
168  }).join(' ')
169}
170
171function chain(path: Key[], total: number, count: number, colors: number[], gap: number, size: (j: number) => number = j => (j === 0 ? 3 : j < 4 ? 2 : 1.5)): string {
172  return Array.from({ length: count }, (_, j) => `<g>${motion(path, total, -j * gap)}${px(-size(j) / 2, -size(j) / 2, size(j), colors[j % colors.length]!)}</g>`)
173    .reverse()
174    .join('')
175}
176
177function butterfly(color: number, dark: number): string {
178  return (
179    `<g><animateTransform attributeName="transform" type="scale" values="1 1;0.2 1;1 1" ${spin(0.3)}/>` +
180    `${px(-2, -1.5, 2, color)}${px(0, -1.5, 2, color)}${px(-1.5, 0.5, 1.5, dark)}${px(0, 0.5, 1.5, dark)}</g>`
181  )
182}
183
184function effects(hero: Hero): { before: string; after: string; body: Key[]; ultBody?: string } {
185  if (hero.name === 'Imbibitor Lunae') {
186    const slash = (d: string, from: number, to: number) =>
187      `<g opacity="0"><path d="${d}" fill="none" stroke="#7ff3e2" stroke-width="1.6" stroke-linecap="round" shape-rendering="geometricPrecision"/>${blink(from, to, LOOP)}</g>`
188    const scale = (x: number, from: number) =>
189      `<g opacity="0"><polygon points="${x},-6 ${x + 2},-3.5 ${x},-1 ${x - 2},-3.5" fill="#5fe3d2" stroke="#e9fffb" stroke-width="0.4"/>${blink(from, 3.4, LOOP)}</g>`
190    const loop: Key[] = Array.from({ length: 25 }, (_, k) => {
191      const a = (k / 24) * Math.PI * 2
192      return [(k / 24) * 1.4, 16 + 24 * Math.cos(a), 20 + 9 * Math.sin(a) - 5 * Math.sin(2 * a)]
193    })
194    return {
195      before: phase(2, `<circle cx="34" cy="-2" r="12" fill="#f8eeb0" opacity="0.2"/><circle cx="34" cy="-2" r="8" fill="#f8eeb0" opacity="0.9"/>`),
196      after:
197        slash('M30 4 Q44 14 32 30', 0.15, 0.6) +
198        slash('M33 8 Q46 20 34 34', 0.6, 1.05) +
199        slash('M28 12 Q42 22 30 38', 1.05, 1.55) +
200        scale(8, 1.7) +
201        scale(16, 1.9) +
202        scale(24, 2.1) +
203        `<g opacity="0"><g>${motion([[0, -8, 0], [2.5, -8, 0], [2.9, 12, 0], [LOOP, 12, 0]], LOOP)}<rect x="16" y="20" width="22" height="1.5" fill="#f6d27c"/><polygon points="38,18.5 43,20.75 38,23" fill="#e9fffb"/></g>${blink(2.5, 3.3, LOOP)}</g>` +
204        phase(2, chain(loop, 1.4, 12, [0xe9fffb, 0x5fe3d2, 0x2aa69c], 0.06)),
205      body: [[0, 0, 0], [0.3, 2, 0], [1.5, 2, 0], [1.6, 0, 0], [2.5, 0, 0], [2.7, 3, -1], [3.3, 0, 0], [3.6, 0, -3], [5.6, 0, -3], [LOOP, 0, 0]],
206    }
207  }
208  if (hero.name === 'Permansor Terrae') {
209    const burst = (r: number) =>
210      `<polygon points="${Array.from({ length: 16 }, (_, k) => {
211        const a = (Math.PI / 8) * k
212        const rr = k % 2 ? r / 2.5 : r
213        return `${(40 + rr * Math.cos(a)).toFixed(1)},${(20 + rr * Math.sin(a)).toFixed(1)}`
214      }).join(' ')}" fill="#f2c14e"/>`
215    const dive: Key[] = [[0, 46, -14], [0.5, 30, 8], [0.8, 16, 36], [2.6, 16, 36]]
216    const shards = [-6, 4, 26, 38]
217      .map((x, i) => `<g opacity="0"><g>${motion([[0, x, 40], [3.4 + 0.8, x, 40], [3.4 + 1.4, x + (i % 2 ? 4 : -4), 24], [LOOP, x + (i % 2 ? 4 : -4), 24]], LOOP)}<rect width="2.5" height="2" fill="#8a6a3a"/></g>${blink(4.2, 5.2, LOOP)}</g>`)
218      .join('')
219    return {
220      before: phase(
221        1,
222        `<g transform="translate(16 20)"><polygon points="${hexagon(24)}" fill="#f2c14e" fill-opacity="0.12" stroke="#f2c14e" stroke-width="1" shape-rendering="geometricPrecision"/></g>`,
223      ),
224      after:
225        `<g opacity="0">${burst(5)}${blink(0.5, 0.8, LOOP)}</g><g opacity="0">${burst(8)}${blink(0.8, 1.2, LOOP)}</g>` +
226        phase(1, `<polygon points="16,-9 18.5,-6 16,-3 13.5,-6" fill="#f2c14e" stroke="#fff1c0" stroke-width="0.4"/>`) +
227        phase(1, `<g>${chain([[0, 36, 30], [0.4, 40, 22], [0.8, 36, 14], [1.2, 40, 6], [1.6, 36, -2]], 1.6, 7, [0xfff1c0, 0x3fc7b5, 0xf2c14e], 0.08)}</g>`) +
228        phase(2, `<g>${chain(dive.map(([t, x, y]) => [t, x, y] as Key), 2.6, 12, [0xfff1c0, 0xf2c14e, 0xd9a63a], 0.05, j => (j === 0 ? 4 : j < 5 ? 3 : 2))}</g>`) +
229        `<g opacity="0"><ellipse cx="16" cy="40" rx="4" ry="1.5" fill="none" stroke="#f2c14e" stroke-width="1" shape-rendering="geometricPrecision"><animate attributeName="rx" values="4;4;30;30" keyTimes="0;${(4.2 / LOOP).toFixed(3)};${(5.2 / LOOP).toFixed(3)};1" dur="${LOOP}s" repeatCount="indefinite"/></ellipse>${blink(4.2, 5.4, LOOP)}</g>` +
230        shards +
231        phase(2, `<g transform="translate(16 20)"><polygon points="${hexagon(22)}" fill="none" stroke="#fff1c0" stroke-width="0.6" stroke-dasharray="2 2" shape-rendering="geometricPrecision"/></g>`),
232      body: [[0, 0, 0], [0.3, 0, 0], [0.5, 4, 0], [1.2, 4, 0], [1.5, 0, 0], [4.0, 0, 0], [4.2, 0, 1], [4.4, 0, 0], [LOOP, 0, 0]],
233    }
234  }
235  if (hero.name === 'Castorice') {
236    const ripple = (begin: number) =>
237      `<g opacity="0"><ellipse cx="36" cy="22" rx="2" ry="4" fill="none" stroke="#b784ea" stroke-width="0.8" shape-rendering="geometricPrecision"><animate attributeName="rx" values="2;8" dur="0.6s" begin="${begin}s" repeatCount="indefinite"/><animate attributeName="ry" values="4;12" dur="0.6s" begin="${begin}s" repeatCount="indefinite"/></ellipse>${blink(0.1, 1.5, LOOP)}</g>`
238    const flies = [10, 18, 26, 14, 30]
239      .map((y, i) => {
240        const keys: Key[] = [[0, 20, 18], [1.6 + i * 0.12, 20, 18], [2.6 + i * 0.12, 44, y], [LOOP, 44, y]]
241        return `<g opacity="0"><g>${motion(keys, LOOP)}${butterfly(0xc996ff, 0x8650d4)}</g>${blink(1.6 + i * 0.12, 3.3, LOOP)}</g>`
242      })
243      .join('')
244    const flowers = [-6, 2, 30, 38, 12]
245      .map(x => `${px(x, 38, 1, 0xffffff)}${px(x - 1, 39, 1, 0xc996ff)}${px(x + 1, 39, 1, 0xc996ff)}`)
246      .join('')
247    const wing = (dir: number) =>
248      `<polygon points="16,6 ${16 + dir * 26},-10 ${16 + dir * 24},4 ${16 + dir * 28},12 ${16 + dir * 10},16" fill="#2c1840" fill-opacity="0.85" stroke="#e8dcf5" stroke-width="0.6" shape-rendering="geometricPrecision"/>` +
249      `<path d="M16 6 L${16 + dir * 26} -10 M16 8 L${16 + dir * 24} 4 M16 10 L${16 + dir * 28} 12" stroke="#e8dcf5" stroke-width="0.5" shape-rendering="geometricPrecision"/>`
250    const netherwing =
251      `<g opacity="0"><g>${motion([[0, 0, 14], [3.4, 0, 14], [4.2, 0, 0], [LOOP, 0, 0]], LOOP)}` +
252      wing(-1) +
253      wing(1) +
254      `<polygon points="11,-13 21,-13 23,-7 19,-3 13,-3 9,-7" fill="#ece4f6"/>${px(12.5, -10, 2, 0x7b4fb0)}${px(17.5, -10, 2, 0x7b4fb0)}` +
255      `<rect x="13" y="-4" width="6" height="1" fill="#2c1840"/></g>${blink(3.4, LOOP, LOOP)}</g>`
256    return {
257      before: phase(2, `<ellipse cx="16" cy="40" rx="30" ry="4" fill="#6b3a9c" opacity="0.55"/>${flowers}`) + netherwing,
258      after: ripple(0) + ripple(0.3) + flies,
259      body: [[0, 0, 0], [0.2, 1, 0], [1.4, 1, 0], [1.6, 0, 0], [3.4, 0, 0], [4.0, 0, -2], [5.8, 0, -2], [LOOP, 0, 0]],
260    }
261  }
262  if (hero.name === 'Firefly') {
263    const jet = [0, 1, 2, 3, 4, 5]
264      .map(i => {
265        const c = [0x7ff0c8, 0xf2a03a, 0xd4f26a][i % 3]!
266        return `<g opacity="0"><g>${motion([[0, 28, 18 + (i % 3)], [0.5, 46, 16 + (i % 4)]], 0.5, -i * 0.08)}<rect width="3" height="2" fill="${hex(c)}"/></g>${blink(0.2, 1.5, LOOP)}</g>`
267      })
268      .join('')
269    const impact = [-4, 2, 8, 24, 30, 36]
270      .map((x, i) => `<g opacity="0"><g>${motion([[0, x, 40], [0.6, x + (i % 2 ? 1 : -1), 26]], 0.6, -i * 0.1)}<rect width="2" height="3" fill="${hex([0x7ff0c8, 0xd4f26a, 0x3fd0a0][i % 3]!)}"/></g>${blink(2.7, 3.4, LOOP)}</g>`)
271      .join('')
272    const flameWing = (dir: number) =>
273      `<polygon points="16,14 ${16 + dir * 30},-8 ${16 + dir * 22},6 ${16 + dir * 32},10 ${16 + dir * 18},18 ${16 + dir * 26},28 16,24" fill="#6ff7d0" fill-opacity="0.55" stroke="#c8fff0" stroke-width="0.5" shape-rendering="geometricPrecision"><animate attributeName="fill-opacity" values="0.35;0.7;0.35" ${spin(0.5)}/></polygon>`
274    const rising = [-6, 0, 6, 26, 32, 38]
275      .map((x, i) => `<g opacity="0"><g>${motion([[0, x, 40], [1, x, 8]], 1, -i * 0.17)}<rect width="2" height="3" fill="${hex([0x7ff0c8, 0xd4f26a][i % 2]!)}"/></g>${fade('0;1;0', 1, -i * 0.17)}</g>`)
276      .join('')
277    return {
278      before: phase(2, flameWing(-1) + flameWing(1)),
279      after: jet + impact + phase(2, rising),
280      body: [[0, 0, 0], [1.6, 0, 0], [2.2, 2, -14], [2.5, 2, -14], [2.7, 0, 0], [3.4, 0, 0], [3.8, 0, -2], [5.8, 0, -2], [LOOP, 0, 0]],
281      ultBody: 'SAM',
282    }
283  }
284  return { before: '', after: '', body: [[0, 0, 0], [LOOP, 0, 0]] }
285}
286
287function cardSvg(hero: Hero, isLive: boolean): string {
288  const wrap = (inner: string) =>
289    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-12 -14 56 58" shape-rendering="crispEdges"><rect x="-12" y="40" width="56" height="1" fill="${hex(hero.accent)}" opacity="0.4"/>${inner}</svg>`
290  if (!isLive || hero.kit === undefined) {
291    return wrap(bigSprite(hero.name))
292  }
293  const fx = effects(hero)
294  // Firefly fights as SAM, her armor; the other heroes fight as themselves.
295  const body = `<g>${motion(fx.body, LOOP)}${bigSprite(fx.ultBody ?? hero.name)}</g>`
296  const title = (i: number, tag: string) => {
297    const name = hero.kit![i]!
298    const fit = name.length > 18 ? ` textLength="54" lengthAdjust="spacingAndGlyphs"` : ''
299    return phase(
300      i,
301      `<text x="16" y="-9" text-anchor="middle" font-size="3.4" font-family="ui-monospace, Menlo, monospace" fill="#8d94b8" letter-spacing="0.6">${tag}</text>` +
302        `<text x="16" y="-4" text-anchor="middle" font-size="4.6" font-weight="700" font-family="ui-sans-serif, system-ui" fill="${hex(hero.accent)}"${fit}>${name.replace(/&/g, '&amp;')}</text>`,
303    )
304  }
305  return wrap(fx.before + body + fx.after + title(0, 'BASIC ATK') + title(1, 'SKILL') + title(2, 'ULTIMATE'))
306}
307
308// The pane's header, styled after Star Rail's battle screen: action order, trailblaze time, victories and defeats,
309// skill points for the heroes at work, and the tokens spent.
310type Board = { wins: number; losses: number; clock: string; roster: number; tokens: string; party: Hero[] }
311
312const DIGITS = [
313  ['111', '101', '101', '101', '111'],
314  ['010', '110', '010', '010', '111'],
315  ['111', '001', '111', '100', '111'],
316  ['111', '001', '111', '001', '111'],
317  ['101', '101', '111', '001', '001'],
318  ['111', '100', '111', '001', '111'],
319  ['111', '100', '111', '101', '111'],
320  ['111', '001', '010', '010', '010'],
321  ['111', '101', '111', '101', '111'],
322  ['111', '101', '111', '001', '111'],
323]
324
325function led(text: string, cx: number, top: number, pitch: number, on: string, off: string): string {
326  const widthOf = (ch: string) => (ch === ':' ? 2 : 4) * pitch
327  let x = cx - ([...text].reduce((sum, ch) => sum + widthOf(ch), 0) - pitch) / 2
328  const r = pitch * 0.38
329  const dot = (dx: number, dy: number, isOn: boolean) =>
330    `<circle cx="${(x + dx * pitch + pitch / 2).toFixed(1)}" cy="${(top + dy * pitch + pitch / 2).toFixed(1)}" r="${r.toFixed(2)}" fill="${isOn ? on : off}"/>`
331  const out: string[] = []
332  for (const ch of text) {
333    if (ch === ':') {
334      out.push(dot(0, 1, true), dot(0, 3, true))
335    } else {
336      const glyph = DIGITS[Number(ch)] ?? DIGITS[0]!
337      glyph.forEach((line, dy) => [...line].forEach((bit, dx) => out.push(dot(dx, dy, bit === '1'))))
338    }
339    x += widthOf(ch)
340  }
341  return out.join('')
342}
343
344const EMBLEM = (cx: number, cy: number, r: number, color: string) =>
345  `<polygon points="${Array.from({ length: 16 }, (_, k) => {
346    const a = (Math.PI / 8) * k - Math.PI / 2
347    const rr = k % 2 ? r * 0.38 : k % 4 ? r * 0.7 : r
348    return `${(cx + rr * Math.cos(a)).toFixed(1)},${(cy + rr * Math.sin(a)).toFixed(1)}`
349  }).join(' ')}" fill="${color}"/>`
350
351function scoreboard(b: Board): string {
352  const GOLD = '#f2c14e'
353  const CYAN = '#5fe3d2'
354  const FONT = 'font-family="ui-monospace, Menlo, monospace"'
355  const label = (x: number, y: number, text: string, fill = '#8d94b8', size = 5.5, anchor = 'middle') =>
356    `<text x="${x}" y="${y}" fill="${fill}" font-size="${size}" font-weight="600" ${FONT} text-anchor="${anchor}" letter-spacing="1">${text}</text>`
357  const panel = (x: number, w: number) =>
358    `<rect x="${x}" y="24" width="${w}" height="52" rx="2" fill="#0f1430" fill-opacity="0.85" stroke="#c9a24a" stroke-opacity="0.45"/>` +
359    `<polygon points="${x + w / 2},22 ${x + w / 2 + 2},24 ${x + w / 2},26 ${x + w / 2 - 2},24" fill="${GOLD}"/>`
360  const stars = [[24, 30], [70, 84], [130, 6], [196, 88], [250, 30], [300, 52], [12, 60], [306, 82], [160, 96], [100, 4]]
361    .map(([x, y], i) => `<circle cx="${x}" cy="${y}" r="${i % 3 ? 0.6 : 0.9}" fill="#ffffff" opacity="0.6"/>`)
362    .join('')
363  const corner = (x: number, y: number) => `<polygon points="${x},${y - 3} ${x + 3},${y} ${x},${y + 3} ${x - 3},${y}" fill="${GOLD}"/>`
364
365  const order = b.party.length
366    ? b.party
367        .slice(0, 4)
368        .map((hero, i) => {
369          const x = 13 + i * 26
370          return (
371            `<rect x="${x}" y="36" width="22" height="18" fill="#1b2350" stroke="${hex(hero.accent)}" stroke-width="0.8"/>` +
372            bust(hero.name, x + 0.5, 36.5, 21, 17, '4 2 24 19') +
373            `<rect x="${x}" y="56" width="22" height="2" fill="${hex(hero.accent)}"/>`
374          )
375        })
376        .join('')
377    : label(62, 52, 'STANDBY', '#5a6290', 7)
378  const points = Array.from({ length: 5 }, (_, i) => {
379    const x = 66 + i * 9
380    const isOn = i < Math.min(5, b.party.length)
381    return `<polygon points="${x},84 ${x + 3},87.5 ${x},91 ${x - 3},87.5" fill="${isOn ? CYAN : '#16304a'}" stroke="${GOLD}" stroke-width="0.5"/>`
382  }).join('')
383
384  return (
385    `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="200" viewBox="0 0 320 100">` +
386    `<defs><linearGradient id="night" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1a1f4a"/><stop offset="1" stop-color="#070915"/></linearGradient></defs>` +
387    `<rect x="1" y="1" width="318" height="98" rx="4" fill="url(#night)"/>` +
388    stars +
389    `<path d="M-10 92 Q160 62 330 92" fill="none" stroke="#c9a24a" stroke-opacity="0.25" stroke-width="0.8"/>` +
390    `<rect x="3" y="3" width="314" height="94" rx="3" fill="none" stroke="#c9a24a" stroke-opacity="0.8" stroke-width="0.8"/>` +
391    `<rect x="5" y="5" width="310" height="90" rx="2" fill="none" stroke="#c9a24a" stroke-opacity="0.3" stroke-width="0.5"/>` +
392    corner(3, 3) +
393    corner(317, 3) +
394    corner(3, 97) +
395    corner(317, 97) +
396    EMBLEM(18, 14, 6, GOLD) +
397    `<text x="28" y="17" fill="${GOLD}" font-size="8" font-weight="800" font-family="ui-serif, Georgia, serif" letter-spacing="2">ASTRAL EXPRESS</text>` +
398    label(310, 16.5, 'TRAILBLAZE LOG', '#8d94b8', 5, 'end') +
399    panel(8, 110) +
400    panel(124, 90) +
401    panel(220, 92) +
402    label(63, 32, 'ACTION ORDER') +
403    order +
404    label(169, 32, 'TRAILBLAZE TIME') +
405    led(b.clock, 169, 42, 4.4, CYAN, '#0c2624') +
406    label(169, 72, `PARTY ${b.roster}`, GOLD) +
407    label(243, 32, 'VICTORY', GOLD) +
408    led(String(b.wins).padStart(2, '0'), 243, 42, 4, GOLD, '#2a2110') +
409    label(289, 32, 'DEFEAT', '#e0507a') +
410    led(String(b.losses).padStart(2, '0'), 289, 42, 4, '#e0507a', '#2d0f17') +
411    label(266, 72, 'EXPRESS · BUGS', '#5a6290', 4.5) +
412    label(12, 90, 'SKILL POINTS', '#8d94b8', 5, 'start') +
413    points +
414    `<circle cx="252" cy="87.5" r="4" fill="none" stroke="${CYAN}" stroke-width="1"/><circle cx="252" cy="87.5" r="1.6" fill="${CYAN}"/>` +
415    label(310, 90, `TOKENS ${b.tokens.toUpperCase()}`, GOLD, 5.5, 'end') +
416    `</svg>`
417  )
418}
419
420// Two pixels per terminal cell: ▀ in the top color over the bottom one.
421function cells(hero: Hero): string {
422  const pixels = pixelsOf(hero)
423  const DEFAULT = 0x01000000
424  const words: number[] = []
425  for (let y = 0; y < H; y += 2) {
426    for (let x = 0; x < W; x++) {
427      const top = pixels[y * W + x] ?? null
428      const bottom = pixels[(y + 1) * W + x] ?? null
429      if (top === null && bottom === null) words.push(0x20, DEFAULT, DEFAULT)
430      else if (top === null) words.push(0x2584, bottom!, DEFAULT)
431      else words.push(0x2580, top, bottom ?? DEFAULT)
432    }
433  }
434  const bytes = new Uint8Array(Uint32Array.from(words).buffer) as Uint8Array & { toBase64(): string }
435  return bytes.toBase64()
436}
437
438function tokens(n: number): string {
439  return n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
440}
441
442function clock(ms: number): string {
443  const s = Math.max(0, Math.round(ms / 1000))
444  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
445}
446
447function shortModel(model: string): string {
448  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
449}
450
451function spotOf(row: AgentRow, list: AgentRow[]): number {
452  return row.player ?? list.indexOf(row)
453}
454
455function patch($: EngineInterface, id: string, change: (row: AgentRow) => AgentRow) {
456  return update($, agents, list => list.map(row => (row.id === id ? change(row) : row)))
457}
458
459async function openPane($: EngineInterface) {
460  await update($, isStripHidden, () => false)
461  const opened = await $.ui.open({ id: PANE, title: 'Astral Express' })
462  await update($, isPaneUp, () => opened.isPlaced)
463  return opened
464}
465
466let ticker: Timer | null = null
467
468export const register: Register = on => {
469  on('session.start', async ($, e, next) => {
470    await $.command.register({ name: 'agents-pane', description: 'Show the subagents of this session in a pane' })
471    // A pane kept open across a reload is still the engine's; pick it back up.
472    const isUp = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
473    await update($, isPaneUp, () => isUp)
474
475    return next(e)
476  })
477
478  on('command.run', { command: 'agents-pane' }, async $ => {
479    await openPane($)
480
481    return { text: 'Agents pane opened.' }
482  })
483
484  on('ui.close', async ($, e, next) => {
485    const closed = await next(e)
486    if (e.id === PANE) {
487      await update($, isPaneUp, () => false)
488    }
489
490    return closed
491  })
492
493  on('agent.spawn', async ($, e, next) => {
494    const spawned = await next(e)
495    if (spawned.deny !== undefined || spawned.agentId === undefined) {
496      return spawned
497    }
498    const row: AgentRow = {
499      id: spawned.agentId,
500      description: e.description || e.subagentType,
501      type: e.subagentType,
502      model: spawned.model,
503      isBackground: e.background === true,
504      status: 'running',
505      startedAt: await $.clock.now(),
506      endedAt: null,
507      tools: 0,
508      lastTool: null,
509      tokens: 0,
510      player: 0,
511    }
512    // An agent whose description names a hero gets that hero; the rest board in roster order.
513    const named = heroNamed(row.description)
514    if (named !== undefined) {
515      row.player = named
516    } else {
517      row.player = await read($, signed)
518      await update($, signed, n => n + 1)
519    }
520    await update($, agents, list => [...list, row].slice(-50))
521    await update($, isStripHidden, () => false)
522    ticker ??= $.clock.every(1000, () => {
523      update($, tick, n => n + 1).catch(() => undefined)
524    })
525
526    return spawned
527  }).catch(($, e, next) => next(e))
528
529  on('tool.call', async ($, e, next) => {
530    if (e.agentId !== undefined) {
531      await patch($, e.agentId, row => ({ ...row, tools: row.tools + 1, lastTool: e.tool }))
532    }
533
534    return next(e)
535  }).catch(($, e, next) => next(e))
536
537  on('turn.step', async function* ($, e, next) {
538    const step = yield* next(e)
539    const usage = step.usage
540    if (e.agentId !== undefined && usage !== null) {
541      const spent = usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
542      await patch($, e.agentId, row => ({ ...row, tokens: row.tokens + spent }))
543    }
544
545    return step
546  })
547
548  on('turn.complete', async ($, e, next) => {
549    if (e.agentId !== undefined) {
550      const status: AgentStatus = e.isAborted ? 'stopped' : e.reason === 'answer' ? 'done' : 'failed'
551      const endedAt = await $.clock.now()
552      await patch($, e.agentId, row => (row.status === 'running' ? { ...row, status, endedAt } : row))
553      if ((await read($, agents)).every(row => row.status !== 'running')) {
554        ticker?.cancel()
555        ticker = null
556      }
557    }
558
559    return next(e)
560  })
561
562  // The strip: the Astral Express riding the star rail with the working subagents aboard, one click from the full party.
563  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
564    const list = await read($, agents)
565    if (e.props.hasSurvey || list.length === 0 || (await read($, isStripHidden)) || (await read($, isPaneUp))) {
566      return next(e)
567    }
568    const { Box, Button, Text } = $.ui.resolve(e)
569    const running = list.filter(row => row.status === 'running').length
570    const finished = list.length - running
571    const spent = list.reduce((sum, row) => sum + row.tokens, 0)
572    const live = list.filter(row => row.status === 'running')
573    // Only the heroes at work ride the Express, the latest four; with none at work it waits on the rail.
574    const party = live.slice(-4)
575
576    const scene = () => {
577      if (e.surface === 'terminal') {
578        if (party.length === 0) {
579          return <Text dimColor>🚂 parked</Text>
580        }
581        return party.map(row => (
582          <Text key={row.id} color={STATUS_COLOR[row.status]}>
583            {heroOf(spotOf(row, list)).name}
584            {STATUS_MARK[row.status]}
585          </Text>
586        ))
587      }
588      const { Svg } = $.ui.resolve({ ...e, surface: 'desktop' })
589      const heroes = party.map(row => heroOf(spotOf(row, list)))
590      const ride = train(heroes, heroes.length > 0)
591      const names = heroes.map(hero => hero.name).join(', ')
592      return (
593        <Svg
594          key="express"
595          source={ride.source}
596          alt={heroes.length > 0 ? `The Astral Express riding the star rail with ${names} aboard` : 'The Astral Express waiting on the rail'}
597          width={(ride.width * 64) / SCENE_H}
598          height={64}
599          isInteractive={heroes.length > 0}
600        />
601      )
602    }
603
604    return (
605      <Box flexDirection="row" gap={1} alignItems="center">
606        <Text weight="semibold" color="claude">
607          ✦ Astral Express
608        </Text>
609        <Box flexDirection="row" gap={1} alignItems="center">
610          {scene()}
611        </Box>
612        <Text color={running > 0 ? 'success' : 'subtle'}>
613          {running} aboard · {finished} done
614        </Text>
615        <Text dimColor tabular>
616          {tokens(spent)} tokens
617        </Text>
618        <Box flexGrow={1} />
619        <Button key="open" label="Open party" hotkey="a" onPress={() => openPane($)} />
620        <Button key="hide" label="×" onPress={() => update($, isStripHidden, () => true)} />
621      </Box>
622    )
623  })
624
625  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
626    const { Box, Button, Text } = $.ui.resolve(e)
627    const list = await read($, agents)
628    await read($, tick)
629    const now = await $.clock.now()
630    const running = list.filter(row => row.status === 'running')
631    const finished = list.filter(row => row.status !== 'running').reverse()
632    const spent = list.reduce((sum, row) => sum + row.tokens, 0)
633    const wins = list.filter(row => row.status === 'done').length
634    const losses = list.filter(row => row.status === 'failed' || row.status === 'stopped').length
635    const first = list[0]?.startedAt ?? now
636    const last = running.length > 0 ? now : Math.max(first, ...list.map(row => row.endedAt ?? first))
637    const isTerminal = e.surface === 'terminal'
638
639    const stat = (label: string, value: string) => (
640      <Box flexDirection="column" flexGrow={1} borderStyle="round" borderDimColor paddingX={1}>
641        <Text dimColor size="caption">
642          {label}
643        </Text>
644        <Text weight="bold" size="heading" tabular>
645          {value}
646        </Text>
647      </Box>
648    )
649
650    const board = () => {
651      const { Svg } = $.ui.resolve({ ...e, surface: 'desktop' })
652      const time = clock(last - first).padStart(5, '0')
653      return (
654        <Svg
655          key="scoreboard"
656          source={scoreboard({
657            wins,
658            losses,
659            clock: time,
660            roster: list.length,
661            tokens: tokens(spent),
662            party: running.slice(-4).map(row => heroOf(spotOf(row, list))),
663          })}
664          alt={`Astral Express ${wins} victories, ${losses} defeats, trailblaze time ${time}, ${running.length} at work, ${tokens(spent)} tokens`}
665        />
666      )
667    }
668
669    const sprite = (row: AgentRow, hero: Hero) => {
670      if (isTerminal) {
671        const { Raster } = $.ui.resolve({ ...e, surface: 'terminal' })
672        return <Raster key={`hero-${row.id}`} columns={W} rows={H / 2} cells={cells(hero)} />
673      }
674      const { Svg } = $.ui.resolve({ ...e, surface: 'desktop' })
675      const isLive = row.status === 'running'
676      const alt = `${hero.name}, ${hero.path}${isLive && hero.kit ? `, using ${hero.kit.join(', then ')}` : ''}`
677      return <Svg source={cardSvg(hero, isLive)} alt={alt} width={96} height={99} isInteractive={isLive} />
678    }
679
680    const card = (row: AgentRow) => {
681      const hero = heroOf(spotOf(row, list))
682      const isLive = row.status === 'running'
683      return (
684        <Box
685          key={row.id}
686          flexDirection="row"
687          gap={2}
688          alignItems="center"
689          borderStyle="round"
690          borderColor={isLive ? STATUS_COLOR[row.status] : undefined}
691          borderDimColor={!isLive}
692          paddingX={1}
693        >
694          {sprite(row, hero)}
695          <Box flexDirection="column" flexGrow={1} flexShrink={1}>
696            <Box flexDirection="row" justifyContent="space-between" gap={1}>
697              <Text weight="semibold" wrap="truncate-end">
698                {row.description}
699              </Text>
700              <Text color={STATUS_COLOR[row.status]} weight="semibold" size="caption">
701                {STATUS_MARK[row.status]} {STATUS_LABEL[row.status]}
702              </Text>
703            </Box>
704            <Text wrap="truncate-end">
705              <Text weight="bold" color="claude">
706                {hero.name}
707              </Text>
708              <Text dimColor>
709                {' '}
710                {hero.path} · {row.type} · {shortModel(row.model)}
711                {row.isBackground ? ' · bg' : ''}
712              </Text>
713            </Text>
714            <Box flexDirection="row" justifyContent="space-between" gap={1}>
715              <Text dimColor wrap="truncate-end">
716                {row.tools} tools{row.lastTool !== null && isLive ? ` · ${row.lastTool}` : ''}
717              </Text>
718              <Text dimColor tabular>
719                {tokens(row.tokens)} · {clock((row.endedAt ?? now) - row.startedAt)}
720              </Text>
721            </Box>
722          </Box>
723        </Box>
724      )
725    }
726
727    return (
728      <Box flexDirection="column" gap={1}>
729        {isTerminal ? (
730          <Box flexDirection="column" gap={1}>
731            <Box flexDirection="row" justifyContent="space-between" alignItems="center">
732              <Text weight="bold" color="claude" size="heading">
733                ✦ Astral Express
734              </Text>
735              <Text dimColor tabular>
736                W {wins} · L {losses}
737              </Text>
738            </Box>
739            <Box flexDirection="row" gap={1}>
740              {stat('Party', `${list.length}`)}
741              {stat('Tokens', tokens(spent))}
742              {stat('Battle time', clock(last - first))}
743            </Box>
744          </Box>
745        ) : (
746          board()
747        )}
748        {list.length === 0 && <Text dimColor>The Express is quiet. A Trailblazer boards as soon as a subagent starts.</Text>}
749        {running.length > 0 && (
750          <Text color="success" weight="semibold" size="caption">
751            In battle · {running.length}
752          </Text>
753        )}
754        {running.map(card)}
755        {finished.length > 0 && (
756          <Box flexDirection="row" justifyContent="space-between" alignItems="center">
757            <Text dimColor weight="semibold" size="caption">
758              Done · {finished.length}
759            </Text>
760            <Button
761              key="clear"
762              label="Clear"
763              onPress={() => update($, agents, rows => rows.filter(row => row.status === 'running'))}
764            />
765          </Box>
766        )}
767        {finished.map(card)}
768      </Box>
769    )
770  })
771}
772
hooks/cast.ts 274 lines
1import { BIG } from './big'
2import type { Big } from './big'
3
4// The Astral Express roster: Honkai: Star Rail fan-art chibis, 20×24, one per subagent in turn.
5// '.' is see-through; every other letter is a color in the hero's palette.
6
7export const W = 20
8export const H = 24
9
10export type Hero = {
11  name: string
12  path: string
13  element: string
14  move: string
15  accent: number
16  palette: Record<string, number>
17  rows: string[]
18  // The kit as the game names it: Basic ATK, Skill, Ultimate.
19  kit?: [basic: string, skill: string, ultimate: string]
20}
21
22const BASE: Record<string, number> = {
23  k: 0x16141c, // outline
24  s: 0xfde6d4, // skin
25  S: 0xe9bfa8, // ear
26  b: 0xf6a5a5, // blush
27  m: 0xe08a80, // mouth
28  w: 0xffffff, // eye shine
29}
30
31export const CAST: Hero[] = [
32  {
33    name: 'Imbibitor Lunae',
34    path: 'Destruction',
35    element: 'Imaginary',
36    move: 'Azure Dragon',
37    accent: 0x3ccfc0,
38    kit: ['Beneficent Lotus', 'Transcendence', "Azure's Aqua Ablutes All"],
39    palette: {
40      ...BASE,
41      h: 0x1d2130, H: 0x2b3145, t: 0x5fe3d2, T: 0x1f8f8a, e: 0x3fd0c4, E: 0x1a7f7a,
42      c: 0x2aa69c, C: 0x166b66, g: 0xe8c062, p: 0x15171f, o: 0xf3f4f8,
43    },
44    rows: [
45      '....t..........t....',
46      '....tt........tt....',
47      '.....tTkkkkkkTt.....',
48      '....khhhhhhhhhhk....',
49      '...khhhHhhhhhhhhk...',
50      '...khhhhhhhhhhHhk...',
51      '...khhshhhhhhshhk...',
52      '...khskksssskkshk...',
53      '..SkhswessssweshkS..',
54      '...khbEEssssEEbhk...',
55      '...khssssmmsssshk...',
56      '...hkhsssssssshkh...',
57      '...hhkkoottookkhh...',
58      '..hkooooottoooookh..',
59      '..hocooooggoooocoh..',
60      '..hoccccggggccccoh..',
61      '..hsCccccccccccCsh..',
62      '..CcpppppoopppppcC..',
63      '..CcpppppoopppppcC..',
64      '..Cccpppp..ppppccC..',
65      '...Cc.ppp..ppp.cC...',
66      '...C..pTp..pTp..C...',
67      '......kkk..kkk......',
68      '.....kkkk..kkkk.....',
69    ],
70  },
71  {
72    name: 'Permansor Terrae',
73    path: 'Preservation',
74    element: 'Physical',
75    move: 'Souldragon Aegis',
76    accent: 0xf2c14e,
77    kit: ['Aegis Vitae', 'Terra Omnibus', "A Dragon's Zenith Knows No Rue"],
78    palette: {
79      ...BASE,
80      h: 0x1a1c26, H: 0x2b2f40, g: 0xf2c14e, G: 0xb07d1e, e: 0xf0b94a, E: 0xa06a1a,
81      K: 0x262833, y: 0xd9a63a, p: 0x17181f, c: 0x2aa69c, t: 0x3fc7b5, o: 0xf3f4f8,
82    },
83    rows: [
84      '....g..g....g..g....',
85      '.....gg......gg.....',
86      '......gkkkkkkg......',
87      '....khhhhhhhhhhk....',
88      '...khhHhhhhhhhHhk...',
89      '...khhhhhhhhhhhhk...',
90      '...khhshhhshhhshk...',
91      '...khskksssskkshk...',
92      '..SkhswessssweshkS..',
93      '...khbEEssssEEbhk...',
94      '...khssssmmsssshk...',
95      '....kssssssssssk....',
96      '....kKKKKggKKKKk....',
97      '..GgkKKKKggKKKKkoc..',
98      '..GgKKKKKggKKKKKoc..',
99      '..GgKKKKKKKKKKKKoc..',
100      '..ssyyyyyyyyyyyysc..',
101      '...kpppppppppppkoc..',
102      '...kpppppppppppkoct.',
103      '....pppp..pppp..octt',
104      '....pppp..pppp...tt.',
105      '....pgpp..ppgp..tt..',
106      '....kkkk..kkkk......',
107      '...kkkkk..kkkkk.....',
108    ],
109  },
110  {
111    name: 'Castorice',
112    path: 'Remembrance',
113    element: 'Quantum',
114    move: 'Netherwing',
115    accent: 0xb07ae0,
116    kit: ["Lament, Nethersea's Ripple", "Silence, Wraithfly's Caress", "Doomshriek, Dawn's Chime"],
117    palette: {
118      ...BASE,
119      L: 0xe2d6f0, l: 0xffffff, P: 0x9a5cc8, f: 0xf29ac0, e: 0xb07ae0, E: 0x6a3a98,
120      v: 0x6e3aa8, V: 0x4a2380, o: 0xf6f4fb, O: 0xdcd6ea,
121    },
122    rows: [
123      '......fwffffwf......',
124      '.PvP.kLLLLLLLLk.....',
125      '..v.kLLLLLLLLLLk....',
126      '...kLLLlLLLLLLlLk...',
127      '...kLLLLLLLLLLLLk...',
128      '...kLLLLLLLLLLLLk...',
129      '...kLsLLLLLLLLsLk...',
130      '..LkLskksssskksLkL..',
131      '..LkLswesssswesLkL..',
132      '..LkLbEEssssEEbLkL..',
133      '..PkLssssmmssssLkP..',
134      '..PPkLssssssssLkPP..',
135      '..LPLkooofffookLPL..',
136      '..PPsooooffoooosPP..',
137      '..PPsoooovvoooosPP..',
138      '.PPvsoooooooooosvPP.',
139      '.PvvoooooooooooovvP.',
140      '.vvOoOoOoOoOoOoOovv.',
141      '..v...ooo..ooo...v..',
142      '..V...ooo..ooo...V..',
143      '......oOo..oOo......',
144      '......ooo..ooo......',
145      '......vvv..vvv......',
146      '.....vvvv..vvvv.....',
147    ],
148  },
149  {
150    name: 'Firefly',
151    path: 'Destruction',
152    element: 'Fire',
153    move: 'Complete Combustion',
154    accent: 0x7ff0c8,
155    kit: ['Order: Flare Propulsion', 'Order: Aerial Bombardment', 'Complete Combustion'],
156    palette: {
157      ...BASE,
158      a: 0xdcd8d2, A: 0xf4f2ee, K: 0x2a2a38, e: 0x9fdde8, E: 0x5a9fb8,
159      o: 0xf28a2e, q: 0x6fd6c8, Q: 0xb8ead8, Y: 0xf0e48a, n: 0x2a3f6f, N: 0x1b2440, x: 0xf6f7fb,
160    },
161    rows: [
162      '......kkkkkkkk......',
163      '.....kKKKKKKKKk.....',
164      '....kaaaaaaaaaak....',
165      '...kaaaaaaaaaaaak...',
166      '...kaaAaaaaaaAaak...',
167      '...kaaaaaaaaaaaak...',
168      '...kaasaaasaaasak...',
169      '..akaskksssskksaka..',
170      '..akaswesssswesaka..',
171      '.aakabEEssssEEbakaa.',
172      '.aakassssmmssssakaa.',
173      '.aaaaksssssssskaaaa.',
174      '.aaKKkxxxooxxxkKKaa.',
175      '.aaKKxxooooooxxKKaa.',
176      '..aKxKxxxqqxxxKxKa..',
177      '..asxqqqqqqqqqqxsa..',
178      '...qqqqqqqqqqqqqq...',
179      '...QQQQQQQQQQQQQQ...',
180      '...YYYYYYYYYYYYYY...',
181      '......qqq..qqq......',
182      '......nnn..nnn......',
183      '......NNN..NNN......',
184      '......xxx..xxx......',
185      '.....xxoo..ooxx.....',
186    ],
187  },
188]
189
190// The other side: a glitch beetle, 12×10.
191export const BUG: Hero = {
192  name: 'Bug',
193  path: '',
194  element: '',
195  move: '',
196  accent: 0xe0507a,
197  palette: { k: 0x16141c, R: 0xd0406a, r: 0x8a2048, w: 0xffffff },
198  rows: [
199    '..k......k..',
200    '...k....k...',
201    '..kkkkkkkk..',
202    '.kRRRRRRRRk.',
203    'kRRwkRRwkRRk',
204    'kRRRRRRRRRRk',
205    'kRrRRrRRrRRk',
206    '.kRRRRRRRRk.',
207    '.k.k.kk.k.k.',
208    'k..k....k..k',
209  ],
210}
211
212// The roster spot of the hero a text names, by any word of the name ("Lunae", "Permansor", "firefly").
213export function heroNamed(text: string): number | undefined {
214  const words = text.toLowerCase().split(/[^a-z]+/)
215  const spot = CAST.findIndex(hero => hero.name.toLowerCase().split(' ').some(part => words.includes(part)))
216  return spot < 0 ? undefined : spot
217}
218
219export function heroOf(spot: number): Hero {
220  return CAST[spot % CAST.length]!
221}
222
223export function hex(color: number): string {
224  return `#${color.toString(16).padStart(6, '0')}`
225}
226
227// Row-major, null see-through.
228export function pixelsOf(hero: Hero): (number | null)[] {
229  return hero.rows.flatMap(line => [...line].map(ch => (ch === '.' ? null : (hero.palette[ch] ?? null))))
230}
231
232// One rect per run of a color along a row, to keep the markup small.
233export function rects(pixels: (number | null)[], width: number): string {
234  const out: string[] = []
235  for (let y = 0; y * width < pixels.length; y++) {
236    let x = 0
237    while (x < width) {
238      const c = pixels[y * width + x] ?? null
239      let run = 1
240      while (x + run < width && (pixels[y * width + x + run] ?? null) === c) run++
241      if (c !== null) out.push(`<rect x="${x}" y="${y}" width="${run}" height="1" fill="${hex(c)}"/>`)
242      x += run
243    }
244  }
245  return out.join('')
246}
247
248export function spriteOf(hero: Hero): string {
249  return rects(pixelsOf(hero), hero.rows[0]!.length)
250}
251
252// The detailed sprite of a hero (or of SAM), as rects.
253export function bigOf(name: string): Big {
254  return BIG[name]!
255}
256
257export function bigSprite(name: string): string {
258  const big = bigOf(name)
259  return rects(
260    big.rows.flatMap(line => [...line].map(ch => (ch === '.' ? null : (big.palette[ch] ?? null)))),
261    big.rows[0]!.length,
262  )
263}
264
265// The head and shoulders, for a train window or the board: rows `top`..`top + height` of the detailed sprite.
266export function bustOf(name: string, top = 0, height = 22): string {
267  const big = bigOf(name)
268  const rows = big.rows.slice(top, top + height)
269  return rects(
270    rows.flatMap(line => [...line].map(ch => (ch === '.' ? null : (big.palette[ch] ?? null)))),
271    rows[0]!.length,
272  )
273}
274
hooks/big.ts 232 lines
1// Detailed 32×40 heroes for the desktop, and SAM, Firefly's armor: '.' is see-through, every other letter a palette color.
2
3export type Big = { palette: Record<string, number>; rows: string[] }
4
5export const BIG: Record<string, Big> = {
6  "Imbibitor Lunae": {
7    palette: { s: 0xfde9de, S: 0xf0cbbd, w: 0xffffff, b: 0xf6b3c4, m: 0xd9878f, k: 0x161824, h: 0x1e2232, H: 0x2e3550, j: 0x2e3550, t: 0x7ff3e2, T: 0x2bb3a3, U: 0x17706a, e: 0x45d6ca, E: 0x157a74, o: 0xf4f6fa, O: 0xcfd6e2, c: 0x2aa69c, C: 0x166b66, g: 0xf0c868, G: 0xa8802a, p: 0x16181f, P: 0x2c3040, r: 0xc8342b },
8    rows: [
9      '...t........................t...',
10      '...tt..t................t..tt...',
11      '....tTtt................ttTt....',
12      '.....tTT..kkkkkkkkkkkk..TTt.....',
13      '......TkkkhhhhhhhhhhhhkkkT......',
14      '.....kkhhhhhhhhhhhhhhhhhhkk.....',
15      '....khhhhhhhhjhhhhjhhhhhhhhk....',
16      '...khhHhhhhhhhhhhhhhhhhhhHhhk...',
17      '...khHhhhhhjhhhhhhhhjhhhhhHhk...',
18      '..khhHhhshhhhshhhhshhhhshhHhhk..',
19      '..khHhhsshhssshsshssshhsshhHhk..',
20      '..khHhhskkksssssssssskkkshhHhk..',
21      '.khHhSssweEssssssssssEewssShHhk.',
22      '.khhhSsseeEssssssssssEeessShhhk.',
23      '.khhhhsbEEEssssssssssEEEbshhhhk.',
24      '.khhhhsssssssssssssssssssshhhhk.',
25      '.khhhhhssssssssmmsssssssshhhhhk.',
26      '.khhhhhhSssssssssssssssShhhhhhk.',
27      '.khhhhhhhhhSSSssssSSShhhhhhhhhk.',
28      '.khhhhhhhkkoocsccscookkhhhhhhhk.',
29      '.khhhhhhkoooooccccoooookhhhhhhk.',
30      'khhhhhhkoOooooccccooooOokhhhhhhk',
31      'khhhhhkoOooooocggcoooooOokhhhhhk',
32      'khhhhhkoOoooocggggcooooOokhhhhhk',
33      'khhhhhkssOcccgGggGgcccOsskhhhhhk',
34      'khhhhCkssccccccccccccccsrkChhhhk',
35      'khhhCCkkCpppppoooopppppCrkCChhhk',
36      '.khCcCkCcPpppppoopppppPcCrCcChk.',
37      '.kCcCkCcCPpppppoopppppPCcCkCcCk.',
38      '..kcCkCcCPpppppoopppppPCcCkCck..',
39      '..kCckCcCPppppp..pppppPCcCkcCk..',
40      '...kCkCcCkPppp....pppPkCcCkCk...',
41      '...kCkCcCkPppp....pppPkCcCkCk...',
42      '....kCcCk.pppp....pppp.kCcCk....',
43      '....kCCk..pTpp....ppTp..kCCk....',
44      '.....kk...pppp....pppp...kk.....',
45      '..........kkkk....kkkk..........',
46      '.........kkkkk....kkkkk.........',
47      '.........kPkkk....kkkPk.........',
48      '........kkkkkk....kkkkkk........',
49    ],
50  },
51  "Permansor Terrae": {
52    palette: { s: 0xfde9de, S: 0xf0cbbd, w: 0xffffff, b: 0xf6b3c4, m: 0xd9878f, k: 0x15161e, h: 0x1a1c26, H: 0x2e3346, g: 0xf2c14e, G: 0xa8761c, e: 0x45d6ca, E: 0x157a74, K: 0x262833, y: 0xd9a63a, p: 0x17181f, P: 0x2a2c38, o: 0xf3f4f8, O: 0xcfd6e2, c: 0x2aa69c, C: 0x166b66, t: 0x3fc7b5, T: 0x1f8f8a },
53    rows: [
54      '....g..g................g..g....',
55      '....gg.gg..............gg.gg....',
56      '.....gGg................gGg.....',
57      '......gG..kkkkkkkkkkkk..Gg......',
58      '.......GkkhhhhhhhhhhhhkkG.......',
59      '.....kkhhhhhhhhhhhhhhhhhhkk.....',
60      '....khhhhhhhhHhhhhHhhhhhhhhk....',
61      '...khhHhhhhhhhhhhhhhhhhhhHhhk...',
62      '...khHhhhhhhhhhhhhhhhhhhhhHhk...',
63      '..khhHhhshhhhshhhhshhhhshhHhhk..',
64      '..khHhhsshhssshsshssshhsshhHhk..',
65      '..khHhhskkksssssssssskkkshhHhk..',
66      '..kSSsssweEssssssssssEewsssSSk..',
67      '...kSssseeEssssssssssEeesssSk...',
68      '...khssbEEEssssssssssEEEbsshk...',
69      '....kssssssssssssssssssssssk....',
70      '....kssssssssssmmssssssssssk....',
71      '.....kSssssssssssssssssssSk.....',
72      '......kSSSSSssssssssSSSSSk......',
73      '........kKKKKKggggKKKKKk........',
74      '......kkKKKKKKKggKKKKKKKkoc.....',
75      '....kKKKKKKKKKKggKKKKKKKooock...',
76      '...kKKKKKKKKKKKggKKKKKKKoooOck..',
77      '...kGgKKKKKKKKKKKKKKKKKKooOocck.',
78      '..kGgGKKKKKKKKKKKKKKKKKKooOocCk.',
79      '..kgGgkyyyyyyyyyyyyyyyyyooOocCk.',
80      '..ksSkppppppppppppppppppooOocCk.',
81      '...kkpppppppppPppPppppppkoOocCk.',
82      '....kppppppppPppppPpppppkoOocck.',
83      '....kppppppppp....ppppppkoOocCk.',
84      '.....kpppppppp....ppppppkOoccCk.',
85      '......kppppppp....ppppppkOoccCk.',
86      '......kppppppp....pppppppkOcctk.',
87      '.......kpppppp....ppppppk.kcttt.',
88      '.......kppgppp....pppgppk..kttT.',
89      '.......kpppppp....ppppppk...tTt.',
90      '.......kkkkkkk....kkkkkkk..tTt..',
91      '......kkkkkkkk....kkkkkkkktt....',
92      '......kkGkkkkk....kkkkkGkk......',
93      '.....kkkkkkkkk....kkkkkkkkk.....',
94    ],
95  },
96  "Castorice": {
97    palette: { s: 0xfde9de, S: 0xf0cbbd, w: 0xffffff, b: 0xf6b3c4, m: 0xd9878f, k: 0x2a1838, L: 0xe8def6, l: 0xfbf8ff, M: 0xcdbbe9, P: 0xa577d6, Q: 0x7b4fb0, R: 0x52307e, e: 0xb784ea, E: 0x6b3a9c, d: 0xfbf8ff, D: 0xdcd2f0, F: 0xb6a6da, c: 0x4a2f68, C: 0x8a64b0, f: 0xfff2fb, g: 0x9d6cdc, y: 0xf6d27c, v: 0xc996ff, V: 0x8650d4, o: 0xf6f3fc, O: 0xcfc6e6 },
98    rows: [
99      '............c......c............',
100      '..........c.cC.cc.Cc.c..........',
101      '..........cCcCcCCcCcCc..........',
102      '.......kkfgfcfgffgfcfgfkk.......',
103      '......kLfyfgffyffyffgfyfLk......',
104      '...VvkLLLfflLLLLLLLLlffLLLk.....',
105      '..VvVLLLLLLLLlLLLLlLLLLLLLLk....',
106      '...vLLMLLLLLLLLLLLLLLLLLLMLLk...',
107      '...kLMLLLLLlLLLLLLLLlLLLLLMLk...',
108      '..kLLMLLsLLLLsLLLLsLLLLsLLMLLk..',
109      '..kLMLLssLLsssLssLsssLLssLLMLk..',
110      '..kLMLLskkksssssssssskkksLLMLk..',
111      '.kLMLSssweEssssssssssEewssSLMLk.',
112      '.kLPLSsseeEssssssssssEeessSLPLk.',
113      '.kLPLLsbEEEssssssssssEEEbsLLPLk.',
114      '.kPPLLssssssssssssssssssssLLPPk.',
115      '.kPPLLLssssssssmmssssssssLLLPPk.',
116      '.kPQPLLLSssssssssssssssSLLLPQPk.',
117      '.kPQPLLLLLLSSSssssSSSLLLLLLPQPk.',
118      '.kPQPPLLLksssssssssssskLLLPPQPk.',
119      '.kPQPPLLkssssddddddsssskLLPvQvk.',
120      'kPQQPPLkssSDdffggffdDSsskLPVvVPk',
121      'kPQQPPLkssSDddfddfddDSsskLPPvQPk',
122      'kPQRQPLksSkDddddddddDkSskLPQRQPk',
123      'kPQRQPPksSkDddVggVddDkSskPPQRQPk',
124      'kPQRQPPkssvvvvVggVvvvvsskPPQRQPk',
125      'kQRRQPvkkvvDdddDDdddDvvkkvPQRRQk',
126      'kQRRQvvVVddDdddDDdddDddVVvvQRRQk',
127      '.kQRvvVVdddDdddDDdddDdddVVvvRQk.',
128      '.kQRvVVFdddDdddDDdddDdddFVVvRQk.',
129      '..kRvVvFFddDFddDDddFDddFFvVvRk..',
130      '..kVvV.kFFFDFFFDDFFFDFFFk.VvVk..',
131      '...Vv..kVvkVvkVvvVkvVkvVk..vV...',
132      '...v........sss..sss........v...',
133      '............sSs..sSs............',
134      '............oOo..oOo............',
135      '............oOo..oOo............',
136      '............oOo..oOo............',
137      '...........koOokkoOok...........',
138      '...........kkkk..kkkk...........',
139    ],
140  },
141  "Firefly": {
142    palette: { s: 0xfde9de, S: 0xf0cbbd, w: 0xffffff, b: 0xf6b3c4, m: 0xd9878f, k: 0x1a1a24, a: 0xdedad6, A: 0xffffff, K: 0x1e1e2a, x: 0xf6f7fb, o: 0xf2a03a, O: 0x1e1e2a, q: 0x6fd6c8, Q: 0xb8ead8, Y: 0xf0e48a, n: 0x2a3f6f, N: 0x1b2440, e: 0xa9b2f0, E: 0x6a6fc0 },
143    rows: [
144      '................................',
145      '..........kkkkkkkkkkkk..........',
146      '........kkKKKKKKKKKKKKkk........',
147      '.......kKKKKKKKKKKKKKKKKk.......',
148      '...xq.kaaaaaaaaaaaaaaaaaak......',
149      '..xqxkaaaaaaaaaaaaaaaaaaaak.....',
150      '...qkaaaaAaaaaaaaaaaaaAaaaak....',
151      '...kaaAaaaaaaaaaaaaaaaaaaAaak...',
152      '...kaAaaaaaaaaaaaaaaaaaaaaAak...',
153      '..kaaAaasaaaasaaaasaaaasaaAaak..',
154      '..kaAaassaasssassasssaassaaAak..',
155      '..kaAaaskkksssssssssskkksaaAak..',
156      '.kaAaSssweEssssssssssEewssSaAak.',
157      '.kaaaSsseeEssssssssssEeessSaaak.',
158      '.kaaaasbEEEssssssssssEEEbsaaaak.',
159      '.kaaaassssssssssssssssssssaaaak.',
160      '.kaaaaassssssssmmssssssssaaaaak.',
161      '.kaaaaaaSssssssssssssssSaaaaaak.',
162      '.kqaaaaaaaaSSSssssSSSaaaaaaaaqk.',
163      '.kqaaaakKKKxxxxxxxxxxKKKkaaaaqk.',
164      '.kqaaakKKKKxxxooooxxxKKKKkaaaqk.',
165      'kqqaaakKKKKxxoOooOoxxKKKKkaaaqqk',
166      'kqqaaakxKKKKxxxooxxxKKKKxkaaaqqk',
167      'kqqaaakxxKKKxxqqqqxxKKKxxkaaaqqk',
168      'kqqaakxxxKKqqqqqqqqqqKKxxxkaaqqk',
169      'kqqaakssxkqqqqqqqqqqqqkxsskaaqqk',
170      '.kqqkssqqqqqqqqqqqqqqqqqqsskqqk.',
171      '..kqkqqqqqqqqqqqqqqqqqqqqqqkqk..',
172      '...kQQQQQQQQQQQQQQQQQQQQQQQQk...',
173      '...kQQQQQQQQQQQQQQQQQQQQQQQQk...',
174      '...kYYYYYYYYYYYYYYYYYYYYYYYYk...',
175      '....kkkkkkkkkkkkkkkkkkkkkkkk....',
176      '..........qqqq....qqqq..........',
177      '..........nnnn....nnnn..........',
178      '..........nnnn....nnnn..........',
179      '..........NNNN....NNNN..........',
180      '..........NNNN....NNNN..........',
181      '..........xxxx....xxxx..........',
182      '........kxxoox....xooxxk........',
183      '........kkkkkk....kkkkkk........',
184    ],
185  },
186  "SAM": {
187    palette: { k: 0x0e1016, M: 0x5a6476, m: 0x2c323e, n: 0x1c2029, G: 0x6ff7d0, g: 0xc8fff0 },
188    rows: [
189      '...............gg...............',
190      '..............kggk..............',
191      '...........kkkMggMkkk...........',
192      '.........kkMMMMmmMMMMkk.........',
193      '........kMMmmmmmmmmmmMMk........',
194      '.......kMmmmmmmmmmmmmmmMk.......',
195      '.......kmmGGGGGGGGGGGGmmk.......',
196      '.......kmGggggggggggggGmk.......',
197      '.......kmmGGGGGGGGGGGGmmk.......',
198      '.......kmmmmmmmmmmmmmmmmk.......',
199      '........kmmmmMmmmmMmmmmk........',
200      '.........kkmmmmmmmmmmkk.........',
201      '...kkkkk..kmmmmmmmmmmk..kkkkk...',
202      '..kMMMMMk.kmmmmmmmmmmk.kMMMMMk..',
203      '.kMMmmmmMkkmmGmmmmGmmkkMmmmmMMk.',
204      '.kMmmmmmmkmmmGGmmGGmmmkmmmmmmMk.',
205      '.kmmGmmmmkmmGGGGGGGGmmkmmmmGmmk.',
206      '..kmmmmmkmmmmGGggGGmmmmkmmmmmk..',
207      '...kmmmkmmmmmmmmmmmmmmmmkmmmk...',
208      '...kmmmkmmmmmmmmmmmmmmmmkmmmk...',
209      '...kmmmkkmmmmmmmmmmmmmmkkmmmk...',
210      '...kMmGkkmmmmmmmmmmmmmmkkGmMk...',
211      '...kmmmk.kmmmmmmmmmmmmk.kmmmk...',
212      '...kmmmk.kmmmmmmmmmmmmk.kmmmk...',
213      '...kGGGk.kmmGmmmmmmGmmk.kGGGk...',
214      '....kkk..kmmmmmmmmmmmmk..kkk....',
215      '........kmmmmm....mmmmmk........',
216      '........kmmmmm....mmmmmk........',
217      '........kmGmmm....mmmGmk........',
218      '........kmmmmm....mmmmmk........',
219      '.......kMmmmmm....mmmmmMk.......',
220      '.......kmmmmmm....mmmmmmk.......',
221      '.......kmmGmmm....mmmGmmk.......',
222      '.......kmmmmmm....mmmmmmk.......',
223      '......kMmmmmmm....mmmmmmMk......',
224      '......kmmmmmmm....mmmmmmmk......',
225      '......kmmmmmmm....mmmmmmmk......',
226      '.....kmmmmmmmm....mmmmmmmmk.....',
227      '.....kGGGGGGGG....GGGGGGGGk.....',
228      '.....kkkkkkkkk....kkkkkkkkk.....',
229    ],
230  },
231}
232
types/index.d.ts 24 lines
1export type AgentStatus = 'running' | 'done' | 'stopped' | 'failed'
2
3export type AgentRow = {
4  id: string
5  description: string
6  type: string
7  model: string
8  isBackground: boolean
9  status: AgentStatus
10  startedAt: number
11  endedAt: number | null
12  tools: number
13  lastTool: string | null
14  tokens: number
15  /** Roster spot on the Astral Express: picks the hero. Absent on rows from before the roster. */
16  player?: number
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'agents-pane': { agents: AgentRow[]; isStripHidden: boolean; signed: number; isPaneUp: boolean; tick: number }
22  }
23}
24