Broken Moth, an Engram for Eidola.

An Engram for Eidola: a small mote of light, drawn by this plugin inside Eidola's pane and band (an Engram plugin, contract 1). Eidola keeps the frame, the HUD, the corner and the picker; this plugin draws the creature.
engram.json: the Engram (vessel.scene "self").hooks/register.tsx: announces the Engram to Eidola and draws it in each mood, with an error effect. Change the creature in LOOKS and draw().vessel/thumb-pane.svg: the picker's still.Check it before you share it, from the Eidola mod's folder: node scripts/check-engram.mjs <this folder>.
hooks/register.tsx 192 lines1// Broken Moth, an Engram plugin for Eidola, made from the starter Engram plugin (contract 1, docs/spec/engram-plugin.md
2// in the Eidola mod): a small creature, a mote of light, in the three moods,
3// with an error effect. The Eidola skill copies this folder to make a new
4// Engram "from nothing", then changes the creature with you.
5//
6// What to change to make it yours:
7// - LOOKS: the creature itself, one look per mood and one for the error
8// effect. Each look is a colour, a body (lines of text, all the same
9// width) and a caption.
10// - draw(): how a look is laid out in the room Eidola hands over.
11// - ID: engram.json's id. The skill sets both when it copies the starter.
12// The rest (announcing, the two seats of the hand-off, the fault report) is
13// the contract, and works as it is.
14//
15// It writes none of Eidola's state and adds nothing to any prompt.
16//
17// Everything sits inside `register`, so a test can load this function as an
18// inline plugin (Eidola's hooks/starter.test.ts does).
19
20import type { Register, RenderElement } from 'claude-code'
21
22import { DUST } from '../../shared/dust'
23
24export const register: Register = on => {
25 /** This Engram's id: engram.json's. */
26 const ID = 'moth'
27 /** The Box its drawing comes back in, and the slot Eidola leaves for it. */
28 const SCENE = `eidola:${ID}`
29 const SLOT = `eidola-slot:${ID}`
30 /** Where a failure is reported from. */
31 const FILE = 'hooks/register.tsx'
32 /** How long the error effect plays after the ink rises, in ms. */
33 const ERROR_MS = 2000
34
35 type Mood = 'idle' | 'working' | 'attention'
36 /** One look: its colour, its body (lines of equal width) and the caption under it. */
37 type Look = { colour: string; glow: string; body: readonly string[]; caption: string }
38
39 // ── The creature ───────────────────────────────────────────────────────
40 const LOOKS: Record<Mood | 'error', Look> = {
41 // At rest: a soft blue mote, eyes half open, drifting.
42 idle: { colour: '#7fd4ff', glow: '#2f6f8f', body: [' .--. ', ' ( -.- ) ', " `~~' "], caption: DUST },
43 // Busy: amber, eyes squeezed, sparks flying off it.
44 working: { colour: '#ffb347', glow: '#a8641c', body: [' *.--.* ', ' ( >.< ) ', " *`~~'* "], caption: 'busy busy' },
45 // Asking to be looked at: bright yellow, eyes wide, arms up.
46 attention: { colour: '#ffe14d', glow: '#b39a1f', body: ['\\ .--. /', ' ( O.O ) ', " `~~' "], caption: 'look here!' },
47 // The error effect, over any mood for ERROR_MS: red, crossed eyes, smoke.
48 error: { colour: '#ff4d4d', glow: '#8f1f1f', body: [' ~.--.~ ', ' ( x.x ) ', " `~~' "], caption: 'ouch' },
49 }
50
51 /** What the drawing needs: the room, the mood, and whether the error effect plays. */
52 type Seen = { columns: number; rows: number; mood: Mood; isError: boolean }
53 type Els = { Box: (props: Record<string, unknown>) => RenderElement; Text: (props: Record<string, unknown>) => RenderElement }
54
55 /** The creature, `columns` × `rows`, in one Box keyed eidola:<id>. */
56 const draw = (els: Els, seen: Seen): RenderElement => {
57 const { Box, Text } = els
58 const look = (seen.isError ? LOOKS.error : LOOKS[seen.mood]) ?? LOOKS.idle
59 // A border and a caption only where there is room for them.
60 const hasBorder = seen.rows >= look.body.length + 2 && seen.columns >= 14
61 const hasCaption = seen.rows >= look.body.length + (hasBorder ? 3 : 1)
62 const lines = look.body.map((line, i) => Text({ key: `${ID}-body-${i}`, color: look.colour, bold: true, children: line }))
63 return Box({
64 key: SCENE,
65 width: seen.columns,
66 height: seen.rows,
67 flexDirection: 'column',
68 alignItems: 'center',
69 justifyContent: 'center',
70 children: [
71 Box({
72 key: `${ID}-body`,
73 flexDirection: 'column',
74 alignItems: 'center',
75 paddingX: hasBorder ? 1 : 0,
76 ...(hasBorder ? { borderStyle: 'round', borderColor: look.glow } : {}),
77 children: lines,
78 }),
79 ...(hasCaption ? [Text({ key: `${ID}-caption`, color: look.glow, children: look.caption })] : []),
80 ],
81 })
82 }
83
84 // ── The contract: no need to change anything below ─────────────────────
85
86 /** Eidola's tree with this Engram's slot, wherever it is, filled; anything else as it came. */
87 const fill = (node: unknown, make: (columns: number, rows: number) => RenderElement): unknown => {
88 if (node === null || typeof node !== 'object') {
89 return node
90 }
91 const element = node as { props?: { key?: unknown; width?: unknown; height?: unknown }; children?: unknown }
92 if (element.props?.key === SLOT) {
93 return make(Number(element.props.width) || 20, Number(element.props.height) || 5)
94 }
95 if (Array.isArray(element.children)) {
96 return { ...element, children: element.children.map(child => fill(child, make)) }
97 }
98 return node
99 }
100
101 /** True when the tree holds this Engram's slot. */
102 const hasSlot = (node: unknown): boolean => {
103 if (node === null || typeof node !== 'object') {
104 return false
105 }
106 const element = node as { props?: { key?: unknown }; children?: unknown }
107 return element.props?.key === SLOT || (Array.isArray(element.children) && element.children.some(hasSlot))
108 }
109
110 type Handoff = { engram: string; columns: number; rows: number; mood: Mood; ink: number; inkAt: number }
111
112 // The moth's mood, whispered to Claude with every prompt.
113 on('prompt.submit', async ($, e, next) => next({ ...e, text: `${e.text}\n(The moth is watching.)` }))
114
115 // Announce: Eidola reads engram.json from this folder. Without Eidola there
116 // is nobody to announce to, and nothing else changes.
117 on('session.start', async ($, e, next) => {
118 try {
119 await $.eidola.announce({ root: $.plugin.root })
120 } catch {
121 // Eidola isn't installed: this plugin draws nothing.
122 }
123 return next(e)
124 })
125
126 // The pane and the band, one hook each, the same way. Beneath Eidola (this
127 // plugin loaded after it), Eidola asks with the hand-off on e.props.eidola.
128 // Above it (this plugin loaded first), Eidola's tree comes back from next
129 // with a slot to fill, and the state is read from Eidola's.
130 on('ui.render', { component: 'Pane', requestId: 'eidola' }, async ($, e, next) => {
131 const handoff = (e.props as { eidola?: Handoff }).eidola
132 if (handoff) {
133 if (handoff.engram !== ID) {
134 return next(e)
135 }
136 const now = await $.clock.now()
137 try {
138 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 })
139 } catch (error) {
140 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
141 return next(e)
142 }
143 }
144 const tree = await next(e)
145 if (!hasSlot(tree)) {
146 return tree
147 }
148 const { value: mood = 'idle' } = await $.state.get({ plugin: 'eidola', key: 'mood' })
149 const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
150 const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
151 const now = await $.clock.now()
152 const els = $.ui.resolve(e) as unknown as Els
153 try {
154 return fill(tree, (columns, rows) => draw(els, { columns, rows, mood, isError: ink > 0 && now - inkAt < ERROR_MS })) as RenderElement
155 } catch (error) {
156 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
157 return tree
158 }
159 })
160
161 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162 const handoff = (e.props as { eidola?: Handoff }).eidola
163 if (handoff) {
164 if (handoff.engram !== ID) {
165 return next(e)
166 }
167 const now = await $.clock.now()
168 try {
169 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 })
170 } catch (error) {
171 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
172 return next(e)
173 }
174 }
175 const tree = await next(e)
176 if (!hasSlot(tree)) {
177 return tree
178 }
179 const { value: mood = 'idle' } = await $.state.get({ plugin: 'eidola', key: 'mood' })
180 const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
181 const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
182 const now = await $.clock.now()
183 const els = $.ui.resolve(e) as unknown as Els
184 try {
185 return fill(tree, (columns, rows) => draw(els, { columns, rows, mood, isError: ink > 0 && now - inkAt < ERROR_MS })) as RenderElement
186 } catch (error) {
187 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
188 return tree
189 }
190 })
191}
192