A test Engram plugin for Eidola: a small creature drawn in Eidola's pane and band. Follows docs/spec/engram-plugin.md.

An Engram's scene in a pane or the band above the prompt that reacts to what Claude is doing. Eidola adds nothing to the prompt.
/eidola (open the pane and print diagnostics), /eidola place [pane|band|hide|status on|off] (open the Surfaces screen on the desktop, print the switches elsewhere, or set one), /eidola frame (toggle the awake Engram's HUD), /eidola mood idle|working|attention (set the mood by hand until the next prompt), /eidola ink (show a failure), /eidola media (toggle the media test), /awaken <engram>, /sleep, /engrams [engram] (open the picker, or one Engram's page, in the pane on the desktop; print the list elsewhere)/eidola new, /eidola remix <engram>, /eidola edit <engram>, /eidola publish <engram> start the Eidola skill (skills/eidola-engram/, also /eidola:eidola-engram) on that task: Claude makes the Engram plugin with you in ~/.claude/eidola/engrams/<id>/, lists it in the local marketplace eidola-local, checks it, installs it with your yes, and publishes it to a GitHub repo of yours only with your yes. edit and publish take your own Engrams; a bundled one is remixed first.All are in /config once the mod is installed.
| Option | Default | What it does |
|---|---|---|
pane | on | The awake Engram draws in the pane, when it shows there, and the pane opens by itself. Off: the pane opens only when you run /eidola. |
band | on | The awake Engram draws in the band above the prompt, when it shows there. |
hideBandWhilePaneOpen | on | The band steps aside while the pane is open, for an Engram that shows in the pane. A band-only Engram, like the Wisp, keeps its band. |
statusLine | on | The Engram's name and state, such as "Wisp · idle", in the status line. |
One setting for every Engram: an Engram draws on a surface only if its switch is on and its shows lists it. One that shows only in the band never opens the pane by itself; /eidola still opens it, and it draws its band view there.
wisp (default), a will-o'-the-wisp in a night fen that draws in the band, kraken and lich. Spirit, Skull, Moon, Sigil and Starfield were removed in 0.7.0: one that was awake is cleared at session start, and /eidola says so once.kraken, something vast in deep water, and lich, a crowned skull of alchemical glyphs with soul-fire in its sockets. On desktop each is drawn live over a still backdrop and moves through three moods (idle, working, attention) without a cut; something going wrong makes the Kraken ink and scorches the Lich (/eidola ink to see it). Elsewhere each draws as a field scene, one per state./eidola prints the attached surfaces, whether the pane is placed, whether the band is showing, and network probes run from wherever the session runs: eidola.app (a future hosted relay), example.com (control), api.github.com and a Shrine on 127.0.0.1:9743. In a cloud session that host is the cloud machine, not your computer.
/eidola media swaps the pane to three tiny clips (a GIF in an SVG drawn in a sandboxed frame, the same GIF drawn as a plain image, and a WebM in an SVG foreignObject) to see which ones animate on each surface. Run it again to go back.
Known limits: session.compact hooks don't run where an organization manages Claude Code unless the org lists the plugin. The bundled Engrams are packages in engrams/<id>/ (an engram.json and its vessel/ thumbnails, docs/spec/engram-format.md), loaded at session start and on /engrams; one that fails validation is skipped, and /eidola says why.
At the prompt of a terminal session:
/plugin install eidola --marketplace AyeJK/eidola-mod
From a shell:
claude plugin install eidola --marketplace AyeJK/eidola-mod
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with AyeJK/eidola-mod, then Add plugin.
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
Run these from this folder before pushing:
claude plugin validate .
npx -p typescript tsc -p .
claude plugin test .
node scripts/bake-thumbs.mjs --check
node scripts/export-engram.mjs --check
Each bundled Engram draws through its own drawing module, engrams/<id>/hooks/draw.tsx, given the hand-off an Engram plugin is given (docs/spec/engram-plugin.md). node scripts/export-engram.mjs <id> <folder> turns one into a standalone Engram plugin under a new id, and node scripts/check-engram.mjs <folder> checks any Engram plugin against the contract, printing each problem with its file and fix. The tests read a committed export (test/fixtures/exported-lich/) and the checker's view of the exported three (hooks/exports.data.ts); after changing how a bundled Engram draws, write both again: node scripts/export-engram.mjs lich test/fixtures/exported-lich --fixture --force && node scripts/export-engram.mjs --data (--fixture is the only way an export lands inside the mod, and only under test/). --check fails when either has drifted.
The Eidola skill (skills/eidola-engram/SKILL.md) runs from wherever the mod is installed and never writes inside it. It works through scripts/engram-home.mjs (where, list, new, remix, check, draws, sync, publish-prep), which keeps your Engrams in ~/.claude/eidola/ (EIDOLA_HOME moves it) and refuses an id that is taken. "Create from nothing" copies the starter, skills/eidola-engram/starter/ (tested by hooks/starter.test.ts; keep tests out of the starter's folder). The skill's evals are in evals/eidola-engram/: claude plugin eval . --case "eidola-*" --runs 1 --ablation none --no-publish --scaffold --allow-tools Write Edit "Bash(node:*)" "Bash(git:*)" "Bash(ls:*)" "Bash(cat:*)" "Bash(mkdir:*)" "Bash(cd:*)" "Bash(pwd:*)" "Bash(find:*)" (from a terminal where claude auth status says logged in; --scaffold runs the cases' own setup scripts, which stage a broken Engram and a local bare git repo standing in for GitHub).
The Kraken's and the Lich's picker thumbnails are stills of their live drawings, baked into engrams/<id>/vessel/thumb-*.svg, the files each engram.json names. After changing how one of them draws, bake them again with node scripts/bake-thumbs.mjs: it runs each drawing in the test runner, keeps its best-composed frame, and writes the stills. --check only checks that every thumbnail an Engram names exists and fits a desktop Svg, that no per-state frame is left in a package's vessel/, and that the tests' copy of each manifest (hooks/packages.data.ts) matches it.
tsc needs the engine's types at .claude-plugin/types/claude-code/index.d.ts (git-ignored). The engine writes them there when it loads the mod from this folder (claude --plugin-dir .). The render tests mount the pane and the band on terminal and desktop. A mount fails when a surface refuses the tree (an element it lacks, a prop it doesn't take), which is how an empty desktop pane shows up in a test.
hooks/register.tsx 159 lines1// A test Engram plugin for Eidola, written to docs/spec/engram-plugin.md:
2// a small creature, one face and colour per mood, that plays an error
3// effect while Eidola's ink is fresh. It announces itself to Eidola, and
4// draws inside Eidola's pane and band through the hand-off, from wherever
5// its hooks sit in the chain. It writes none of Eidola's state and adds
6// nothing to any prompt.
7//
8// Everything sits inside `register`, so Eidola's tests (hooks/handoff.test.ts)
9// can load this very function as an inline plugin.
10
11import type { Register, RenderElement } from 'claude-code'
12
13export const register: Register = on => {
14 /** This Engram's id: engram.json's. */
15 const ID = 'fixture'
16 /** The Box its drawing comes back in, and the slot Eidola leaves for it. */
17 const SCENE = `eidola:${ID}`
18 const SLOT = `eidola-slot:${ID}`
19 /** How long the error effect plays after the ink rises, in ms. */
20 const ERROR_MS = 2000
21
22 type Mood = 'idle' | 'working' | 'attention'
23 type Look = { face: string; colour: string; line: string }
24 const LOOKS: Record<Mood | 'error', Look> = {
25 idle: { face: '( o_o )', colour: '#4ad6c8', line: 'drifting' },
26 working: { face: '( >_< )', colour: '#c9a84c', line: 'busy busy' },
27 attention: { face: '( O_O )!', colour: '#ffcf3a', line: 'look here' },
28 error: { face: '( x_x )', colour: '#ff3b3b', line: 'ouch' },
29 }
30 /** What the drawing needs: the room, the mood, and whether the error effect plays. */
31 type Seen = { columns: number; rows: number; mood: Mood; isError: boolean }
32 type Els = { Box: (props: Record<string, unknown>) => RenderElement; Text: (props: Record<string, unknown>) => RenderElement }
33
34 /** The creature, `columns` × `rows`, in one Box keyed eidola:<id>. */
35 const draw = (els: Els, seen: Seen): RenderElement => {
36 const { Box, Text } = els
37 const look = LOOKS[seen.isError ? 'error' : seen.mood] ?? LOOKS.idle
38 const body = Math.max(1, Math.min(3, seen.rows - 2))
39 return Box({
40 key: SCENE,
41 width: seen.columns,
42 height: seen.rows,
43 flexDirection: 'column',
44 alignItems: 'center',
45 justifyContent: 'center',
46 children: [
47 Box({
48 key: 'fixture-body',
49 flexDirection: 'column',
50 alignItems: 'center',
51 borderStyle: 'round',
52 borderColor: look.colour,
53 children: [Text({ color: look.colour, bold: true, children: look.face }), ...(body > 1 ? [Text({ color: look.colour, children: look.line })] : [])],
54 }),
55 ],
56 })
57 }
58
59 /** Eidola's tree with this Engram's slot, wherever it is, filled; anything else as it came. */
60 const fill = (node: unknown, make: (columns: number, rows: number) => RenderElement): unknown => {
61 if (node === null || typeof node !== 'object') {
62 return node
63 }
64 const element = node as { props?: { key?: unknown; width?: unknown; height?: unknown }; children?: unknown }
65 if (element.props?.key === SLOT) {
66 return make(Number(element.props.width) || 20, Number(element.props.height) || 5)
67 }
68 if (Array.isArray(element.children)) {
69 return { ...element, children: element.children.map(child => fill(child, make)) }
70 }
71 return node
72 }
73
74 /** True when the tree holds this Engram's slot. */
75 const hasSlot = (node: unknown): boolean => {
76 if (node === null || typeof node !== 'object') {
77 return false
78 }
79 const element = node as { props?: { key?: unknown }; children?: unknown }
80 return element.props?.key === SLOT || (Array.isArray(element.children) && element.children.some(hasSlot))
81 }
82
83 // Announce: Eidola reads engram.json from this folder. Without Eidola there
84 // is nobody to announce to, and nothing else changes.
85 on('session.start', async ($, e, next) => {
86 try {
87 await $.eidola.announce({ root: $.plugin.root })
88 } catch {
89 // Eidola isn't installed: this plugin draws nothing.
90 }
91 return next(e)
92 })
93
94 // The pane and the band, one hook each, the same way.
95 on('ui.render', { component: 'Pane', requestId: 'eidola' }, async ($, e, next) => {
96 const handoff = (e.props as { eidola?: { engram: string; columns: number; rows: number; mood: Mood; ink: number; inkAt: number } }).eidola
97 // Beneath Eidola: it asks, with the room and the state.
98 if (handoff) {
99 if (handoff.engram !== ID) {
100 return next(e)
101 }
102 const now = await $.clock.now()
103 try {
104 return draw($.ui.resolve(e) as unknown as Els, { columns: handoff.columns, rows: handoff.rows, mood: handoff.mood, isError: handoff.ink > 0 && now - handoff.inkAt < ERROR_MS })
105 } catch (error) {
106 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: 'hooks/register.tsx', stack: error instanceof Error ? error.stack : undefined })
107 return next(e)
108 }
109 }
110 // Above Eidola: its tree comes back from next, with a slot to fill.
111 const tree = await next(e)
112 if (!hasSlot(tree)) {
113 return tree
114 }
115 const { value: mood = 'idle' } = await $.state.get({ plugin: 'eidola', key: 'mood' })
116 const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
117 const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
118 const now = await $.clock.now()
119 const els = $.ui.resolve(e) as unknown as Els
120 try {
121 return fill(tree, (columns, rows) => draw(els, { columns, rows, mood, isError: ink > 0 && now - inkAt < ERROR_MS })) as RenderElement
122 } catch (error) {
123 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: 'hooks/register.tsx', stack: error instanceof Error ? error.stack : undefined })
124 return tree
125 }
126 })
127
128 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
129 const handoff = (e.props as { eidola?: { engram: string; columns: number; rows: number; mood: Mood; ink: number; inkAt: number } }).eidola
130 if (handoff) {
131 if (handoff.engram !== ID) {
132 return next(e)
133 }
134 const now = await $.clock.now()
135 try {
136 return draw($.ui.resolve(e) as unknown as Els, { columns: handoff.columns, rows: handoff.rows, mood: handoff.mood, isError: handoff.ink > 0 && now - handoff.inkAt < ERROR_MS })
137 } catch (error) {
138 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: 'hooks/register.tsx', stack: error instanceof Error ? error.stack : undefined })
139 return next(e)
140 }
141 }
142 const tree = await next(e)
143 if (!hasSlot(tree)) {
144 return tree
145 }
146 const { value: mood = 'idle' } = await $.state.get({ plugin: 'eidola', key: 'mood' })
147 const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
148 const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
149 const now = await $.clock.now()
150 const els = $.ui.resolve(e) as unknown as Els
151 try {
152 return fill(tree, (columns, rows) => draw(els, { columns, rows, mood, isError: ink > 0 && now - inkAt < ERROR_MS })) as RenderElement
153 } catch (error) {
154 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: 'hooks/register.tsx', stack: error instanceof Error ? error.stack : undefined })
155 return tree
156 }
157 })
158}
159