SLOPSHOPPER

eidola-engram-fixture

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

paneband
A shopper browsing a rack in a slop shop
README

Eidola (mod prototype)

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.

  • States: idle, thinking, working, attention, success, responding, error, waiting (asleep when no Engram is awake: the pane and band show the Wisp's scene, dimmed)
  • Commands: /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)
  • Make your own: /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.
  • The Engram picker: click the triangle in the bottom-right corner of the pane, or of the band for a band-only Engram, to list the Engrams, filter them by where they draw and where they come from, and awaken one

Options

All are in /config once the mod is installed.

OptionDefaultWhat it does
paneonThe 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.
bandonThe awake Engram draws in the band above the prompt, when it shows there.
hideBandWhilePaneOpenonThe 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.
statusLineonThe 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.

  • Bundled Engrams: 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.

Diagnostics

/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.

Install

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).

Develop

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.

Source 1 files
hooks/register.tsx 159 lines
1// 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