SLOPSHOPPER

eidola

A pixel Vessel that reacts to what Claude is doing, plus an Engram Soul injected into every session.

newpanebandguardcommandstatus
v0.4.0MITupdated 2026-10-16AyeJK/eidola-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · eidola
│ ┃ Eidola ✕ › fix the failing auth test and add an audit log call │ ┃ ( -_- ) z │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /eidola │ ⎿ eidola: Surfaces: terminal │ ⎿ eidola: Pane: placed. │ ⎿ eidola: Pane state: shown=undefined placed=true │ ⎿ eidola: Placement: both (setting: default). Band: showing. Statu │ ⎿ eidola: Network, from where this session runs: │ ⎿ eidola: eidola.app (hosted relay): reached, HTTP 0 │ │ ( -_- ) z ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
( -_- ) z
Pane · Eidola
( -_- ) z
README

Eidola (mod prototype)

A pixel Vessel in a pane that reacts to what Claude is doing, plus the awake Engram's Soul in the system prompt.

  • States: idle, thinking, working, attention, success, responding, error, waiting (asleep when no Engram is awake)
  • Commands: /eidola (open the pane and print diagnostics), /eidola place [setting] (show or set where the Vessel appears), /eidola media (toggle the media test), /awaken <engram>, /sleep, /engrams

Options

Both are in /config once the mod is installed.

OptionDefaultWhat it does
placementdefaultWhere the Vessel appears. default follows the awake Engram's suggestion (both if it has none). both: pane and band. pane: pane only. band: band only, and the pane opens only when you run /eidola. auto: band only while no pane is placed.
statusLineonThe Engram's name and state, such as "Wisp · idle", in the status line.

An Engram suggests a placement in its Vessel (placement); your option always wins, and nothing an Engram sets stops you opening the pane.

  • Bundled Engrams: wisp (default), spirit, skull, moon, sigil, starfield. All are visual only: their Souls add no voice. Each Vessel is baked in the Vessel Forge as one SVG per state under vessels/<engram>/; a file that can't be read falls back to the built-in pixel sprite.

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: prompt.compose and session.compact hooks don't run where an organization manages Claude Code unless the org lists the plugin. Engrams are inlined in hooks/engrams.ts for now.

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 .

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 8 files
hooks/register.tsx 502 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { VesselState, VesselView } from '../types'
5import { DEFAULT_ENGRAM, ENGRAMS, RESTING, soulSection } from './engrams'
6import type { Engram } from './engrams'
7import { mediaCases } from './media-test'
8import { NET_PROBES, PROBE_TIMEOUT_MS, describeOutcome } from './net-probe'
9import type { NetProbe, Outcome } from './net-probe'
10import {
11  PLACEMENT_HELP,
12  PLACEMENT_SETTINGS,
13  bandShows,
14  effectivePlacement,
15  isPlacementSetting,
16  opensPane,
17  readPlacement,
18  readStatusLine,
19} from './placement'
20import type { Placement, PlacementSetting } from './placement'
21import { EMPTY, step } from './resolver'
22import type { Model, Signal, TurnEndReason } from './resolver'
23import { FACES, SPRITE_PALETTE, vesselSvg } from './sprite'
24
25const PANE = 'eidola'
26const STORE_KEY = 'engram'
27
28const viewAtom = atom({ plugin: 'eidola', key: 'view' } as const, {
29  state: 'idle',
30  detail: '',
31} as VesselView)
32const engramAtom = atom({ plugin: 'eidola', key: 'engram' } as const, DEFAULT_ENGRAM as string | null)
33const mediaAtom = atom({ plugin: 'eidola', key: 'media' } as const, false)
34
35const LABELS: Record<VesselState, string> = {
36  idle: 'Idle',
37  thinking: 'Thinking',
38  working: 'Working',
39  attention: 'Needs you',
40  success: 'Done',
41  responding: 'Replying',
42  error: 'Hit an error',
43  waiting: 'Waiting',
44  sleeping: 'Asleep',
45}
46
47type Timer = ReturnType<EngineInterface['clock']['after']>
48
49// Resolver bookkeeping. Not drawn, so module variables are fine: a reload
50// resets them and the next event puts them right.
51let model: Model = EMPTY
52let generation = 0
53let pending: Timer | undefined
54const svgCache = new Map<string, string>()
55// Baked Vessel files, read once each. null: the file couldn't be read.
56const fileCache = new Map<string, string | null>()
57
58// The person's options. A change in /config reloads the module, so these
59// are set once per load in register().
60let placementSetting: PlacementSetting = 'default'
61let isStatusLineOn = true
62
63function engramOf(id: string | null): Engram | undefined {
64  return id === null ? undefined : ENGRAMS[id]
65}
66
67/** The built-in sprite for a state, at a size. */
68function spriteSvg(state: VesselState, size: number): string {
69  const key = `sprite:${state}:${size}`
70  let svg = svgCache.get(key)
71  if (svg === undefined) {
72    svg = vesselSvg(state, SPRITE_PALETTE, size)
73    svgCache.set(key, svg)
74  }
75  return svg
76}
77
78/**
79 * The Engram's baked SVG for a state, from vessels/<pack>/<state>.svg in
80 * the mod. A file that can't be read falls back to the built-in sprite, so
81 * a Vessel always shows.
82 */
83async function vesselFor($: EngineInterface, engram: Engram, state: VesselState, size: number): Promise<string> {
84  const key = `${engram.vessel.pack}/${state}`
85  if (!fileCache.has(key)) {
86    try {
87      fileCache.set(key, await $.fs.read(`${$.plugin.root}/vessels/${key}.svg`))
88    } catch {
89      fileCache.set(key, null)
90    }
91  }
92  return fileCache.get(key) ?? spriteSvg(state, size)
93}
94
95/** The width that fits a drawing inside a size-by-size box, read from its viewBox. */
96function fitWidth(svg: string, size: number): number {
97  const box = /viewBox="[-\d.]+ [-\d.]+ ([\d.]+) ([\d.]+)"/.exec(svg)
98  const w = Number(box?.[1])
99  const h = Number(box?.[2])
100  return w > 0 && h > w ? Math.round((size * w) / h) : size
101}
102
103/** One probe, never rejecting: the line says what happened. */
104async function probeLine($: EngineInterface, probe: NetProbe): Promise<string> {
105  let timer: Timer | undefined
106  const timeout = new Promise<Outcome>(resolve => {
107    timer = $.clock.after(PROBE_TIMEOUT_MS, () => resolve({ kind: 'timeout' }))
108  })
109  const fetched = $.http
110    .fetch(probe.url, { method: 'GET' })
111    .then((res): Outcome => ({ kind: 'status', status: res.status }))
112    .catch((error: unknown): Outcome => ({
113      kind: 'error',
114      message: error instanceof Error ? error.message : String(error),
115    }))
116  const outcome = await Promise.race([fetched, timeout])
117  timer?.cancel()
118  return describeOutcome(probe, outcome)
119}
120
121function setStatus($: EngineInterface, text: string | undefined): void {
122  $.ui.status(isStatusLineOn ? text : undefined)
123}
124
125/** Where the Vessel appears now: the person's option, else the Engram's suggestion. */
126async function currentPlacement($: EngineInterface): Promise<Placement> {
127  const engram = engramOf(await read($, engramAtom)) ?? RESTING
128  return effectivePlacement(placementSetting, engram.vessel.placement)
129}
130
131async function show($: EngineInterface, view: VesselView): Promise<void> {
132  const current = await read($, viewAtom)
133  if (current.state === view.state && current.detail === view.detail) {
134    return
135  }
136  await update($, viewAtom, () => view)
137  const engram = engramOf(await read($, engramAtom))
138  setStatus($, engram ? `${engram.engram.name} · ${LABELS[view.state].toLowerCase()}` : undefined)
139}
140
141// Every hook goes through here, and nothing here may break a tool call or
142// a turn: failures are swallowed and the Vessel just misses a beat.
143async function signal($: EngineInterface, s: Signal): Promise<void> {
144  try {
145    const out = step(model, s)
146    model = out.model
147    generation += 1
148    const mine = generation
149    pending?.cancel()
150    pending = undefined
151    await show($, out.view)
152    if (out.then) {
153      const later = out.then.view
154      pending = $.clock.after(out.then.ms, () => {
155        if (generation === mine) {
156          void show($, later).catch(() => undefined)
157        }
158      })
159    }
160  } catch {
161    // The Vessel is decoration; never let it cost the session anything.
162  }
163}
164
165async function setEngram($: EngineInterface, id: string | null): Promise<void> {
166  await $.store.set(STORE_KEY, id)
167  await update($, engramAtom, () => id)
168  const engram = engramOf(id)
169  setStatus($, engram ? `${engram.engram.name} · awake` : undefined)
170  // An open pane is retitled with the new name; a closed one stays closed.
171  try {
172    const panes = await $.ui.panes()
173    if (panes.some(one => one.id === PANE)) {
174      await $.ui.open({ id: PANE, title: paneTitle(engram) })
175    }
176  } catch {
177    // The old title is only cosmetic.
178  }
179  // A different Engram may suggest a different placement.
180  $.ui.invalidate('ui.render')
181}
182
183/** The pane's title: the awake Engram's name, else Eidola. */
184function paneTitle(engram: Engram | undefined): string {
185  return engram ? engram.engram.name : 'Eidola'
186}
187
188// Under the `auto` placement the band steps aside while the pane is placed.
189// Every open and close redraws it to keep that true.
190async function isPanePlaced($: EngineInterface): Promise<boolean> {
191  try {
192    const panes = await $.ui.panes()
193    return panes.some(one => one.id === PANE && one.isPlaced)
194  } catch {
195    // Can't tell: show the band rather than risk showing no Vessel at all.
196    return false
197  }
198}
199
200async function openPane($: EngineInterface) {
201  const opened = await $.ui.open({ id: PANE, title: paneTitle(engramOf(await read($, engramAtom))) })
202  $.ui.invalidate('ui.render')
203  return opened
204}
205
206function listText(active: string | null): string {
207  return Object.values(ENGRAMS)
208    .map(one => {
209      const mark = one.engram.id === active ? ' (awake)' : ''
210      return `${one.engram.id}${mark}: ${one.engram.meta.description}`
211    })
212    .join('\n')
213}
214
215async function placeText($: EngineInterface): Promise<string> {
216  const now = await currentPlacement($)
217  const options = PLACEMENT_SETTINGS.map(one => `  ${one}: ${PLACEMENT_HELP[one]}`).join('\n')
218  return `Placement: ${now} (setting: ${placementSetting}).\nChange it with /eidola place <setting>:\n${options}`
219}
220
221export const register: Register = (on, options) => {
222  placementSetting = readPlacement(options)
223  isStatusLineOn = readStatusLine(options)
224
225  // ── Session ────────────────────────────────────────────────────────────
226
227  on('session.start', async ($, e, next) => {
228    await $.command.register({ name: 'eidola', description: 'Open the Eidola Vessel pane' })
229    await $.command.register({
230      name: 'awaken',
231      description: 'Awaken an Engram: its Vessel reacts and its Soul joins the system prompt',
232      argumentHint: '<engram>',
233    })
234    await $.command.register({ name: 'sleep', description: 'Put the awake Engram to sleep and remove its Soul' })
235    await $.command.register({ name: 'engrams', description: 'List the bundled Engrams' })
236
237    const stored = await $.store.get(STORE_KEY)
238    const id = stored === undefined ? DEFAULT_ENGRAM : typeof stored === 'string' && ENGRAMS[stored] ? stored : null
239    await update($, engramAtom, () => id)
240
241    if (!isStatusLineOn) {
242      $.ui.status(undefined)
243    }
244    if (opensPane(await currentPlacement($))) {
245      void openPane($).catch(() => undefined)
246    }
247    await signal($, { kind: 'session' })
248
249    return next(e)
250  })
251
252  on('session.attach', async ($, e, next) => {
253    const result = await next(e)
254    if (opensPane(await currentPlacement($))) {
255      void openPane($).catch(() => undefined)
256    }
257    return result
258  })
259
260  // ── Engine events → Vessel state ──────────────────────────────────────
261
262  // The Soul also rides each typed prompt as hidden context. prompt.compose
263  // alone didn't reach the model in the Claude app's cloud sessions, which
264  // bring their own system prompt; this path works on every surface.
265  on('prompt.submit', async ($, e, next) => {
266    await signal($, { kind: 'prompt' })
267    const engram = engramOf(await read($, engramAtom))
268    if (!engram) {
269      return next(e)
270    }
271    const soul = `<eidola-soul engram="${engram.engram.id}">\n${soulSection(engram)}\n</eidola-soul>`
272    return next({ ...e, context: [...(e.context ?? []), soul] })
273  }).catch(($, e, next) => next(e))
274
275  // turn.start carries no agentId: it fires for the main loop only.
276  on('turn.start', async ($, e, next) => {
277    await signal($, { kind: 'turnStart' })
278    return next(e)
279  })
280
281  on('tool.call', async ($, e, next) => {
282    await signal($, { kind: 'toolStart', tool: e.tool })
283    let isOk = true
284    try {
285      const ran = await next(e)
286      isOk = !(ran.deny === undefined && ran.isError === true)
287      return ran
288    } catch (error) {
289      isOk = false
290      throw error
291    } finally {
292      await signal($, { kind: 'toolEnd', tool: e.tool, isOk })
293    }
294  }).catch(($, e, next) => next(e))
295
296  on('tool.check', async ($, e, next) => {
297    const verdict = await next(e)
298    if (verdict.decision === 'ask') {
299      await signal($, { kind: 'ask', tool: e.tool })
300    }
301    return verdict
302  }).catch(($, e, next) => next(e))
303
304  on('session.compact', async ($, e, next) => {
305    await signal($, { kind: 'compact' })
306    return next(e)
307  }).catch(($, e, next) => next(e))
308
309  on('turn.complete', async ($, e, next) => {
310    if (e.agentId === undefined) {
311      await signal($, { kind: 'turnEnd', reason: e.reason as TurnEndReason })
312    }
313    return next(e)
314  })
315
316  // ── Soul ───────────────────────────────────────────────────────────────
317
318  on('prompt.compose', async ($, e, next) => {
319    const composed = await next(e)
320    const engram = engramOf(await read($, engramAtom))
321    if (!engram) {
322      return composed
323    }
324    return {
325      ...composed,
326      sections: [
327        ...composed.sections.filter(one => one.id !== 'eidola:soul'),
328        { id: 'eidola:soul', text: soulSection(engram), scope: 'session' as const },
329      ],
330    }
331  })
332
333  // ── Commands ───────────────────────────────────────────────────────────
334
335  // /eidola        diagnostics: surfaces, pane, band, network reach
336  // /eidola media  toggles the media test in the pane
337  // /eidola place  shows or sets where the Vessel appears
338  on('command.run', { command: 'eidola' }, async ($, e) => {
339    const [sub = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
340
341    if (sub === 'place') {
342      if (!value) {
343        return { text: await placeText($) }
344      }
345      if (!isPlacementSetting(value)) {
346        return { text: `"${value}" isn't a placement.\n${await placeText($)}` }
347      }
348      // Written as the person's own /config choice; the module reloads with it.
349      const written = await $.config.set({ key: 'eidola.placement', value })
350      if ('deny' in written && written.deny !== undefined) {
351        return { text: `Couldn't change the placement here (${written.deny}). Set it in /config instead.` }
352      }
353      return { text: `Placement set to ${value}: ${PLACEMENT_HELP[value]}.` }
354    }
355
356    if (sub === 'media') {
357      const isOn = !(await read($, mediaAtom))
358      await update($, mediaAtom, () => isOn)
359      const opened = await openPane($)
360      if (!isOn) {
361        return { text: 'Media test off.' }
362      }
363      const where = opened.isPlaced ? 'It is in the Eidola pane.' : `But the pane is not placed (${opened.reason}), so nothing draws.`
364      return {
365        text:
366          `Media test on. ${where}\n` +
367          'Three clips, each a square sliding across with frames numbered 1-8:\n' +
368          '  A · GIF in SVG, sandboxed frame\n' +
369          '  B · GIF in SVG, plain image\n' +
370          '  C · WebM in SVG, sandboxed frame\n' +
371          'Report which ones move. Run /eidola media again to turn it off.',
372      }
373    }
374
375    const surfaces = await $.session.surfaces()
376    const opened = await openPane($)
377    const placement = await currentPlacement($)
378    const panes = await $.ui.panes()
379    const mine = panes.find(one => one.id === PANE)
380    const probes = await Promise.all(NET_PROBES.map(probe => probeLine($, probe)))
381    const lines = [
382      `Surfaces: ${surfaces.length ? surfaces.join(', ') : 'none attached'}`,
383      opened.isPlaced ? 'Pane: placed.' : `Pane: not placed (${opened.reason})`,
384      mine ? `Pane state: shown=${mine.isShown} placed=${mine.isPlaced}` : 'Pane state: not in the pane list',
385      `Placement: ${placement} (setting: ${placementSetting}). Band: ${
386        bandShows(placement, opened.isPlaced) ? 'showing' : 'hidden'
387      }. Status line: ${isStatusLineOn ? 'on' : 'off'}.`,
388      'Network, from where this session runs:',
389      ...probes.map(line => `  ${line}`),
390    ]
391    return { text: lines.join('\n') }
392  })
393
394  // The person closing the pane brings the band back.
395  on('ui.close', async ($, e, next) => {
396    const result = await next(e)
397    if (e.id === PANE) {
398      $.ui.invalidate('ui.render')
399    }
400    return result
401  }).catch(($, e, next) => next(e))
402
403  on('command.run', { command: 'awaken' }, async ($, e) => {
404    const id = e.args.trim().toLowerCase() || DEFAULT_ENGRAM
405    const engram = ENGRAMS[id]
406    if (!engram) {
407      return { text: `No Engram called "${id}". Bundled Engrams:\n${listText(await read($, engramAtom))}` }
408    }
409    await setEngram($, id)
410    void openPane($).catch(() => undefined)
411    return { text: `${engram.engram.name} is awake. Their Soul applies from your next message.` }
412  })
413
414  on('command.run', { command: 'sleep' }, async $ => {
415    const engram = engramOf(await read($, engramAtom))
416    await setEngram($, null)
417    return { text: engram ? `${engram.engram.name} is asleep. Their Soul is gone from your next message.` : 'Nothing was awake.' }
418  })
419
420  on('command.run', { command: 'engrams' }, async $ => ({ text: listText(await read($, engramAtom)) }))
421
422  // ── The pane ───────────────────────────────────────────────────────────
423
424  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
425    const id = await read($, engramAtom)
426    const view = await read($, viewAtom)
427    const awake = engramOf(id)
428    const engram = awake ?? RESTING
429    const state: VesselState = awake ? view.state : 'sleeping'
430
431    // The Vessel alone: the pane's title names the Engram and the status
432    // line carries the state, so the body repeats neither.
433    if (e.surface === 'terminal') {
434      const { Text } = $.ui.resolve(e)
435      return <Text>{FACES[state]}</Text>
436    }
437
438    const { Box, Text, Button, Svg } = $.ui.resolve(e)
439    const size = Math.max(112, Math.min(208, (e.props.bodyColumns ?? 28) * 6))
440    const cacheKey = `${engram.engram.id}:${state}:${size}`
441
442    if (await read($, mediaAtom)) {
443      return (
444        <Box flexDirection="column" alignItems="center" gap={1}>
445          <Text bold>Media test</Text>
446          <Text dimColor>Which ones move? Frames are numbered 1-8.</Text>
447          {mediaCases().map(one => (
448            <Box key={`media:${one.key}`} flexDirection="column" alignItems="center">
449              <Text>{one.label}</Text>
450              <Svg key={one.key} source={one.source} alt={one.label} width={96} isInteractive={one.isInteractive} />
451            </Box>
452          ))}
453          <Text dimColor>Control: the sprite below uses CSS animation, which is known to work.</Text>
454          <Svg key={`control:${state}`} source={spriteSvg(state, 64)} alt={`Sprite: ${LABELS[state]}`} width={64} isInteractive />
455          <Button key="media-off" label="End media test" onPress={() => update($, mediaAtom, () => false)} />
456        </Box>
457      )
458    }
459
460    const svg = await vesselFor($, engram, state, size)
461    return (
462      <Box flexDirection="column" alignItems="center">
463        <Svg key={cacheKey} source={svg} alt={`${engram.engram.name}: ${LABELS[state]}`} width={fitWidth(svg, size)} isInteractive />
464      </Box>
465    )
466  })
467
468  // ── The band above the prompt: shown only while no pane is placed ────
469
470  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
471    if (e.props.hasSurvey) {
472      return next(e)
473    }
474    const placement = await currentPlacement($)
475    if (!bandShows(placement, placement === 'auto' && (await isPanePlaced($)))) {
476      return next(e)
477    }
478    const awake = engramOf(await read($, engramAtom))
479    const view = await read($, viewAtom)
480    const engram = awake ?? RESTING
481    const state: VesselState = awake ? view.state : 'sleeping'
482    const detail = awake ? view.detail : 'No Engram awake'
483    const line = `${engram.engram.name} · ${LABELS[state]}${detail ? ` · ${detail}` : ''}`
484
485    // The Vessel alone, as in the pane; the line stays as its alt text.
486    if (e.surface === 'terminal') {
487      const { Text } = $.ui.resolve(e)
488      return <Text>{FACES[state]}</Text>
489    }
490
491    const { Box, Svg } = $.ui.resolve(e)
492    const size = 44
493    const cacheKey = `${engram.engram.id}:${state}:${size}`
494    const svg = await vesselFor($, engram, state, size)
495    return (
496      <Box flexDirection="row" alignItems="center">
497        <Svg key={`band:${cacheKey}`} source={svg} alt={line} width={fitWidth(svg, size)} isInteractive />
498      </Box>
499    )
500  })
501}
502
hooks/engrams.ts 103 lines
1// Bundled Engrams. Each mirrors the Engram format (engram.yaml + SOUL.md +
2// vessel.yaml). Their Vessels are baked in the Vessel Forge: one SVG per
3// state, shipped under vessels/<pack>/. The prototype inlines the rest;
4// loading Engram folders from disk is Phase 11 (plan Q5).
5
6import type { Placement } from './placement'
7
8export type Engram = {
9  /** engram.yaml */
10  engram: {
11    engram_version: string
12    id: string
13    name: string
14    voice_id: null
15    meta: { author: string; created: string; description: string; tags: string[] }
16  }
17  /** SOUL.md */
18  soul: string
19  /**
20   * vessel.yaml, type "svg": `vessels/<pack>/<state>.svg` in the mod, and the
21   * Forge style and look that made them. `placement` is the creator's
22   * suggestion of where the Vessel appears; the person's `placement` option
23   * overrides it.
24   */
25  vessel: { type: 'svg'; pack: string; forge: { source: string; style: string; look: string }; placement?: Placement }
26}
27
28/** A Soul that adds no voice: these Engrams are visual only. */
29function quietSoul(name: string): string {
30  return [
31    `You have a small companion, ${name}, shown in a pane beside this conversation. It reacts to what you are doing.`,
32    `${name} adds no voice of its own: keep whatever voice, tone and rules the user has already set.`,
33    `Never mention ${name}, the pane, or this note unless the user asks about them.`,
34  ].join('\n')
35}
36
37function visual(
38  id: string,
39  name: string,
40  description: string,
41  tags: string[],
42  forge: Engram['vessel']['forge'],
43): Engram {
44  return {
45    engram: {
46      engram_version: '1.0.0',
47      id,
48      name,
49      voice_id: null,
50      meta: { author: 'eidola', created: '2026-10-15', description, tags },
51    },
52    soul: quietSoul(name),
53    vessel: { type: 'svg', pack: id, forge },
54  }
55}
56
57const WISP = visual(
58  'wisp',
59  'Wisp',
60  'The default Vessel. A quiet spirit that keeps you company and keeps out of the way.',
61  ['default', 'calm'],
62  { source: 'wisp', style: 'pixel', look: 'Classic' },
63)
64
65export const ENGRAMS: Record<string, Engram> = {
66  wisp: WISP,
67  spirit: visual('spirit', 'Spirit', 'The first Eidola Vessel: a figure of drifting gold dust.', ['particle'], {
68    source: 'spirit',
69    style: 'particle',
70    look: 'Shrine dust',
71  }),
72  skull: visual('skull', 'Skull', 'A grinning skull in chunky dithered bone.', ['pixel'], {
73    source: 'skull',
74    style: 'pixel',
75    look: 'Dithered, bone palette, 40 px',
76  }),
77  moon: visual('moon', 'Moon', 'A sleepy crescent moon drawn in frosted scanlines.', ['pixel'], {
78    source: 'moon',
79    style: 'pixel',
80    look: 'Scanline',
81  }),
82  sigil: visual('sigil', 'Sigil', 'A spell circle of sparks whose wheels turn faster as work picks up.', ['particle'], {
83    source: 'sigil',
84    style: 'particle',
85    look: 'Embers',
86  }),
87  starfield: visual('starfield', 'Starfield', 'Stars streaming past at warp, quicker when busy.', ['particle'], {
88    source: 'stars',
89    style: 'particle',
90    look: 'Starfall',
91  }),
92}
93
94/** The Vessel the pane shows while nothing is awake. */
95export const RESTING: Engram = WISP
96
97export const DEFAULT_ENGRAM = 'wisp'
98
99/** The system-prompt section an awake Engram adds. */
100export function soulSection(engram: Engram): string {
101  return `# Active Engram: ${engram.engram.name}\n\n${engram.soul}`
102}
103
hooks/media-test.ts 48 lines
1// Media test: can a native surface play anything richer than a CSS sprite?
2// Each case wraps a tiny clip (a coloured square that slides, frames
3// numbered 1-8) in an SVG, since Svg is the only picture desktop, VS Code
4// and mobile draw. The base64 strings are generated; regenerate rather than
5// hand-edit.
6
7export const TEST_GIF_BASE64 =
8  'R0lGODlhIAAgAIMAANHR0tDQ0a6usJOTlmxscP9aWioqMiQkLBgYIAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQADwAAACwAAAAAIAAgAAAIYgARCBxIsKDBgwgTKlx4YACAhRANCiAQIKJFgQYQVLx4cSPHiB4/LgwpMiHJkgdPolzJsiWCAjBjymQps2YBmjZj4sx5cyVPmDtzBrU5tGbRmT5/ulzKtKnTp1CjSp1KdWVAACH5BAEPABoALAEAAgARABQAhP+0PNTU1MzMzb29v7u7vLi4ubW1tq6usKysrqKipZ2doJGRlGNjaGFhZlxcYllZX05OVEJCSTc3PTMzOigoLyEhKSAgKBoaIRkZIRgYIAAAAAAAAAAAAAAAAAAAAAAAAAhsADUwOFAAgoaDCBMepIDggoQBCiNqkPBAgwUBEjM2WJAx4gQCFDomrGAggkiEGBQ4yHDyIIMACBAkaEmzZsYMOHMC2Mmzp4acOnsK/Qk0g9ChRY0e5UkU6FKmSZ/ubBr0KVWcUgFcVWo1qtSAACH5BAEPAB8ALAEAAgAXABQAhPDmUNTU1c7Oz8TExcDAwba2t7CwsqysrqurraioqpqanZWVmJWVl4mJjHNzd3FxdllZXlVVWlJSV0xMUktLUUdHTTc3PS8vNisrMiUlLSAgJx0dJBsbIxoaIhgYIAAAAAh4ADUoOFBAwoeDCBMqROjggQcMAxZKVJhhw4cJBCZqRLggAIWNGz1EYABSYgMOHjoIKLmQAQQPFQywVHgBwYcEFmbq3MlTo4efQAEIHUpUItCgRJMaPeohqdKFTJs6Hbr06FSqUJleFVoV6dWuP7cCACv1a1arWwMCACH5BAEPABQALAEAAgAdABQAhNra2tbW1tLS0lrSeNDQ0by8vbKys7Cwsqysrp+foZWVl4uLjl1dYlpaX1FRV0ZGTCwsMyIiKh0dJRgYIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhyACcIdBCAgsGDCBMqPCgwwgEBCyNKFLigAQGJGBU+SDDhYsaPEgxA6PjxI4MCCBAAUFASo0AKJFtOFBhTps2bOHNGpElzgM+fQEvyFAi06AChQ40G/Th0gtKfSHk+9Rm159SqRK8yTao1Y9OpR7dKnRoQACH5BAEPABoALAEAAgAdABQAhM3NzcPDxK+vsaysrqqqrKWlqKGho1Cg/5mZnI6OkoaGiYWFiX19gXNzd2pqb2hobV9fZVdXXFVVWk1NU0VFSzQ0OzMzOjExOBoaIhgYIAAAAAAAAAAAAAAAAAAAAAAAAAhsACcEGDCAgYaDCBMqXIgQwoMMEBlKnNhAAsQMEzMqVJBAgAELGkNqWOAgQwQCIjVewJABAwCMKSUikKCBAsqYEisIGFAAJM6fQIMKzXigqNGjQY8qPZB0qdGmTpkCjVoUqlOrS7Eq1Yp0KtWAACH5BAEPACAALAEAAgAdABQAhNHR0s3NzcDAwbGxs6+vsa2tr6ysrqurrampq6ioqqKipJ+foZ6eoI2NkIiIjH5+gnNzd3BwdKpu/2lpblhYXVZWXEpKUDQ0OzMzOjIyOTExOCkpMB0dJBoaIhoaIRgYIAh6AD04GFAgA4iDCBMqXIhwwgMQFRgwnEiRwAUQHCBQ3KgwQAQDCzRwHAkCAAUQFBCQ5Chg5coGFkBYUOly4gYFBxLU3Mmzp8+FEoIKHfqhqFGSQ5NKMHp0pFKiTD8gfRo0qlSnVJdGnUrVKtenXrF23SoWLFmOWbUyDQgAIfkEAQ8AFAAsAQACABcAFACEzc3Ot7e5sbGzsLCyrKyuoKCinZ2gmZmcjo6SfHyAZWVq/1paVFRaSEhOQkJJNjY9NDQ7KSkwGRkhGBggAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHIADxAgMAAAhYMIEypEOGECBQUJFkpU2FBCgAgTM1JoyACBQ40SGxJ48BEkRQcFGppcOMFAA5UrE0IQ0LBkzJs4cy5cwLOnz5o1JfocugAoTIVEfxoVmpSnUZsImzpdulPqU6ZNr1bNShWp1a4JpRY1GhAAIfkEAQ8AIQAsAQACABEAFACF/7Q819fXz8/Pzc3OxcXGxMTFvLy+urq8tra3tbW2sbGzsLCyrq6wrKyuq6utkZGUWVlfWFhdUlJYS0tRR0dNRkZMRERKQkJIQUFHQEBGPT1DPDxDMzM6MTE4GhoiGhohGBggAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHIAJyxwwMBCiIMIEx4s0CEEBwMKI4ZAkAHEBQISFWIIcCCAhIwJG0QAAeEBSIQCPoDwMODkQQUUQFRI4DLEhgY4NdTcyTMjgJ9Ag4IYSjRE0KMAiBZFKlQpCKNMfzp9GlWqU6hRp2JlqrVq0qteu1adGhAAOw=='
9
10export const TEST_WEBM_BASE64 =
11  'GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAApJEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHYTbuMU6uEElTDZ1OsggElTbuMU6uEHFO7a1Osggog7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsirXsYMPQkBNgI1MYXZmNjAuMTYuMTAwV0GNTGF2ZjYwLjE2LjEwMESJiECSvAAAAAAAFlSua8iuAQAAAAAAAD/XgQFzxYj1POxfutAkGJyBACK1nIN1bmSIgQCGhVZfVlA4g4EBI+ODhAjvrK3gkLCBILqBIJqBAlWwhFW5gQESVMNn/HNzoGPAgGfImkWjh0VOQ09ERVJEh41MYXZmNjAuMTYuMTAwc3PWY8CLY8WI9TzsX7rQJBhnyKFFo4dFTkNPREVSRIeUTGF2YzYwLjMxLjEwMiBsaWJ2cHhnyKFFo4hEVVJBVElPTkSHkzAwOjAwOjAxLjE5OTAwMDAwMAAfQ7Z1SHTngQCjQSiBAACA0AkAnQEqIAAgAABHCIWFiIWEiAICAnW6JMFnA/wH8gNwg/G7eAf6BvwH8B9AHrAf0B9A7/Ef3L4AfIud4FiM3kA/YD0EX5Y8IDczv3PY7q/eumM1rpPcAP7//i+f4ef+OeU0NUs85/sGCl2/6f8v/1QzJ/+yD/0sPwZ0fh/ubtOAQ3/xGTMcNxpFf/obKi/m9Bf6c3dwuR8ZZBb+O38V2gYhW7ee38x1BB/pCOf+gDv/4dRcHu+gKqItT4WJY1zmDS8CGF0v+09Q/mTjBiogURzO99ZoiISz1kG1Vkk7/wefv9bu/VM3O1KY9rGBcHLtNZVMndDDk/jZSiAPUf6HmVFiNWyRXy/8hOPKLODHWQE29owcImf51xGhW7SFt8oq5y/XAKNBLYEAlgAxBgABEBAAGAzTSEZA3iAYICZAacD5Av8ByiH0jbOS/5MWEhbE+Sw7hGdiupjRIjsrAAAA4izsi/mHG2y6Xdb9vzo5OJqlNdYY49ejyp+vomBaLgeH69M/53F/YV/NE3f/zRfcOkfyXE4bdJ1kuVg+O+Tv/+ttulwq0zEv29lX/zLrf/wa+evSMafrRF++xiF//nc//9jfn/+lSdEv54Gw//9eJGvT0toCwTOv/9jqE2G/z2umDABmkh4JTDcUP/HLI++f/EOf988X/9tzmo+bzkxZvkOaRMSJxzauAK+RpF8aAS9//988Tdt+MW/6j4//VJXy7oYtvTv/jqKaRl6M3e7LI/1DxRQ3wBAWm+3aR6UcNJg4RM/oBIZiDMTG1keIpXAJG9FBC0CjQOGBASwAEQQAARAQABgJUF1gIcX/tZL/kxm/XbYTSFRup/N4TKTkEeD+AvYpwf9bUL01wI5tl9KWPqhX0ObHP9vTsRUSva9EHcnO178tZncoFv7cT9/lXtGfxMejXVbKwGXiiewDv//fD4xVHEVrl4+bDOxP4denkudcl+bLeiGb//0G3/labf+8HC9wRjrtzdRg5eEwqT6vLWWkzWACuf0g6zL718My5Rs9uSRED5snRkfvCo6DzTgxnqc7x+MSsYXpSj/8dFj/9H4BXlX8/rSay1UM//E0HNeDunFUB/9ugACjQQOBAcIA0QQAARAQABgHYBCQgfclTQ5/oQ8tR3FSAe+IMama81CB3Akggc6EPADhMg/5M9wF5Rw56/k+a6hjCzqFPbd9b3zv/1bP3zf+BxVmF8YDmR7+/Vj/03/Cf/1+O/JpwomGKUJ+L6tZvfhoP/Tn9r3NYQMTx9on+FwD/7KmEFYrKnCD5HVzDnJH8iNgAW3//lr6z//9m65/66dX6MM8tSvYbi/1oDhtOivdXjl0U0XCJcnIJhwqeGsffEe8eLcYwt//7N1zZ+bEcT0fq/zl7eis6q4Eh3DMQIaYZND2zY9L18uD+v1UAD/efAFc1bsh/2GM3mQG7+zMz/MnEe85TgAAo0D6gQJYAFEDAAEQEAAYATgFhAMMC7wDrTz/hhSkHyCoLK0A/vylcy/8o7Tf8hZ/HPKz7TASH/xOQTVDq79+5//yIwjte9ON1flAFfjbjKn/sg96Ktshwm2bvfYW23PnPX4YUIrT+0GxCe1vTf/YZbmz/gt2wZvz3VpxFH+i7W7+7Awm/3TYQ4gAADt80jNqh9Ae7A/z3Ld431QDB2iQ4a+w3Y6FDR8u+Q7WN3op/ogjEWP2f80gGRTj3drh47+KPS7rK234ewX6jjpS+WZUSx9h4PGgL0Jpck3f2Z/zPNrT9cSLUFe6+M74k4ACCLZirZAMDDk3V8qMAAAAAKNBLIEC7oBQCQCdASogACAAAEcIhYWIhYSIAgICxGwPoA0ADwAP6geif/q/8B8APkmu8DjAL3A/wH8gNwF/kP9X/HT9/+UB/QH2yV9/IIx1pc8oDcd/lusOeID+//39r891lB3TXrkN0IlWBV+s/86Dsf/0t4ZbGN/pRhAwb2wf/5QJ//D32nIB4e5CnjYY/YrcskOx06JuUDLbkK7eqPpC/2d/+6W2mUED3o0/aav36A9GJY/X47YlVr3/zU/6ZNH/pBbiqlKX/UfiBI0KZLdW7gVH4npErM2WA5Eb7/6ab9ebRsvr+z25Jg7vY1/Suf//DBt6//qAHklf9RIXUb/yKu//O04+JlItiqvlFvf2D5sI1S8bkdMOPHaO/NF/8IPukH/1+u3AP3ONGPloAKNA9YEDhACRBAABEBAAGARoD5IGKAi6Abrov9GGibIe8LAhu5AIg6ObqqgzsAD+//4TdocwYv/+vXJsA7sWL3mp8aNtt/kaf8kcgGf/8pzPp7OT1II2Nv/7NBVaOt/OnQPzv7N/3Xm991+gDhdV5/xo6bzvdJ4Bwn8Voz/Awk/0Fp8X//uIrWoWtjxgbVngP/CuEsIkaCPv//0YHP/Q/9Tpz+eIdOab3PjpbsbT39sdy0wxdtmfcZyf/9GBj+h6sX8MX//Kir6c6//sObKz5p9c0iQkDA+2a/QcM7tfh65e9LnADPwUyG8fsPeWLrO5Y4cMOU6i22gAo0EFgQQZAJEFAAUQEAAYB2SfwBpQP8B/GD+A7oD+QG6A78B5gOUAim0tHdl6e8DPUuCfJnkQ/mgbmsOUh/iX3yOe92nFCcEf400gbvjAYb/tb+Qp2Zj/tu/+dlk4En/n/8MUbgM6DP+b51D+m2QWAFZPj4MlH97bd/ID4W0+glquH/xDn++37/+2JP/7HH//2N2wyitp/FCSVcVwMOcIcR6A3qNC5jnoAHxX3/5nb9Pv6TU6LuuGIGAF47Vdmg0YP9O/9lP50eH+x3ODCms3rPJOj22OHrhe7S+AtquVSmCGhN+64BFyhUtrhGykFUlu5crke/i6VxJAlRGXbqOCXN3kazkjm/VgHFO7a6S7j7OBALeK94EB8YIBpvCBA7uRs4IC7reL94EB8YIBpvCCBUU='
12
13const SIZE = 96
14
15/** An animated GIF inside an SVG <image>. */
16export function gifInSvg(): string {
17  return (
18    `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
19    `viewBox="0 0 32 32" width="${SIZE}" height="${SIZE}">` +
20    `<image width="32" height="32" style="image-rendering:pixelated" ` +
21    `href="data:image/gif;base64,${TEST_GIF_BASE64}" ` +
22    `xlink:href="data:image/gif;base64,${TEST_GIF_BASE64}"/></svg>`
23  )
24}
25
26/** A looping, muted WebM inside an SVG <foreignObject> <video>. */
27export function webmInSvg(): string {
28  return (
29    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="${SIZE}" height="${SIZE}">` +
30    `<foreignObject x="0" y="0" width="32" height="32">` +
31    `<video xmlns="http://www.w3.org/1999/xhtml" autoplay="" muted="" loop="" playsinline="" ` +
32    `width="32" height="32" style="image-rendering:pixelated" ` +
33    `src="data:video/webm;base64,${TEST_WEBM_BASE64}"></video>` +
34    `</foreignObject></svg>`
35  )
36}
37
38export type MediaCase = { key: string; label: string; source: string; isInteractive: boolean }
39
40/** A and B differ only in how the surface draws the Svg; C tries real video. */
41export function mediaCases(): MediaCase[] {
42  return [
43    { key: 'gif-frame', label: 'A · GIF in SVG, sandboxed frame', source: gifInSvg(), isInteractive: true },
44    { key: 'gif-image', label: 'B · GIF in SVG, plain image', source: gifInSvg(), isInteractive: false },
45    { key: 'webm-frame', label: 'C · WebM in SVG, sandboxed frame', source: webmInSvg(), isInteractive: true },
46  ]
47}
48
hooks/net-probe.ts 39 lines
1// Network probes for /eidola: can hook code on this session's host reach
2// the places a Shrine bridge would post to? In a cloud session the host is
3// the cloud machine, so 127.0.0.1 there is the container, not your computer.
4
5// The probe itself (probeLine) lives in register.tsx: the engine follows $
6// only into functions declared in the same file.
7
8export type NetProbe = { label: string; url: string }
9
10export const NET_PROBES: readonly NetProbe[] = [
11  { label: 'eidola.app (hosted relay)', url: 'https://eidola.app/' },
12  { label: 'example.com (control)', url: 'https://example.com/' },
13  { label: 'api.github.com (a host cloud environments usually allow)', url: 'https://api.github.com/' },
14  { label: 'Shrine on this host (127.0.0.1:9743)', url: 'http://127.0.0.1:9743/shrine/' },
15]
16
17export const PROBE_TIMEOUT_MS = 6000
18
19export type Outcome =
20  | { kind: 'status'; status: number }
21  | { kind: 'error'; message: string }
22  | { kind: 'timeout' }
23
24function shorten(text: string, max = 140): string {
25  const one = text.replace(/\s+/g, ' ').trim()
26  return one.length > max ? `${one.slice(0, max - 1)}…` : one
27}
28
29export function describeOutcome(probe: NetProbe, outcome: Outcome): string {
30  switch (outcome.kind) {
31    case 'status':
32      return `${probe.label}: reached, HTTP ${outcome.status}`
33    case 'timeout':
34      return `${probe.label}: no answer in ${PROBE_TIMEOUT_MS / 1000}s`
35    case 'error':
36      return `${probe.label}: failed (${shorten(outcome.message) || 'no reason given'})`
37  }
38}
39
hooks/placement.ts 56 lines
1// Where the Vessel appears. The Engram suggests, the person decides:
2// the `placement` option (in /config, or `/eidola place <where>`) wins,
3// and its `default` value defers to the awake Engram's suggestion.
4
5import type { PluginOptions } from 'claude-code'
6
7export const PLACEMENT_SETTINGS = ['default', 'both', 'pane', 'band', 'auto'] as const
8export type PlacementSetting = (typeof PLACEMENT_SETTINGS)[number]
9export type Placement = Exclude<PlacementSetting, 'default'>
10
11/** What an Engram that names no placement gets. */
12export const FALLBACK_PLACEMENT: Placement = 'both'
13
14export const PLACEMENT_HELP: Record<PlacementSetting, string> = {
15  default: "the awake Engram's suggestion (both, if it has none)",
16  both: 'pane and band',
17  pane: 'pane only',
18  band: 'band only; the pane opens only when you ask (/eidola)',
19  auto: 'band only while no pane is placed',
20}
21
22export function isPlacementSetting(value: unknown): value is PlacementSetting {
23  return typeof value === 'string' && (PLACEMENT_SETTINGS as readonly string[]).includes(value)
24}
25
26export function readPlacement(options: PluginOptions): PlacementSetting {
27  const value = options['placement']
28  return isPlacementSetting(value) ? value : 'default'
29}
30
31export function readStatusLine(options: PluginOptions): boolean {
32  return options['statusLine'] !== false
33}
34
35export function effectivePlacement(setting: PlacementSetting, engramDefault: Placement | undefined): Placement {
36  return setting === 'default' ? (engramDefault ?? FALLBACK_PLACEMENT) : setting
37}
38
39/** Whether the pane opens by itself at session start and on attach. */
40export function opensPane(placement: Placement): boolean {
41  return placement !== 'band'
42}
43
44/** Whether the band draws, given whether the pane is placed right now. */
45export function bandShows(placement: Placement, isPanePlaced: boolean): boolean {
46  switch (placement) {
47    case 'both':
48    case 'band':
49      return true
50    case 'pane':
51      return false
52    case 'auto':
53      return !isPanePlaced
54  }
55}
56
hooks/resolver.ts 123 lines
1// Event -> Vessel state resolver. Pure: no `$`, no timers. A port of the
2// rules in eidola-runtime/docs/spec/vessel-reactivity.md, driven by engine
3// events instead of editor shell hooks.
4
5import type { VesselView } from '../types'
6
7export type Model = {
8  /** Tool calls running right now (parallel tools keep `working` steady). */
9  inFlight: number
10  /** Tool calls since the turn began: decides `success` vs `responding`. */
11  toolsThisTurn: number
12}
13
14export type TurnEndReason = 'answer' | 'aborted' | 'refusal' | 'error'
15
16export type Signal =
17  | { kind: 'session' }
18  | { kind: 'prompt' }
19  | { kind: 'turnStart' }
20  | { kind: 'toolStart'; tool: string }
21  | { kind: 'toolEnd'; tool: string; isOk: boolean }
22  | { kind: 'ask'; tool: string }
23  | { kind: 'compact' }
24  | { kind: 'turnEnd'; reason: TurnEndReason }
25
26export type Step = {
27  model: Model
28  /** Shown now. */
29  view: VesselView
30  /** Shown after `ms` unless another signal arrives first. */
31  then?: { ms: number; view: VesselView }
32}
33
34/** Timings, from vessel.yaml's `playback` defaults. */
35export const HOLD = {
36  waitingMs: 900,
37  errorMs: 1800,
38  successMs: 3000,
39  turnErrorMs: 4000,
40} as const
41
42export const EMPTY: Model = { inFlight: 0, toolsThisTurn: 0 }
43
44const IDLE: VesselView = { state: 'idle', detail: '' }
45const THINKING: VesselView = { state: 'thinking', detail: '' }
46
47function plural(n: number, word: string): string {
48  return `${n} ${word}${n === 1 ? '' : 's'}`
49}
50
51function working(tool: string, inFlight: number): VesselView {
52  return {
53    state: 'working',
54    detail: inFlight > 1 ? `${tool} · ${inFlight} running` : tool,
55  }
56}
57
58export function step(model: Model, signal: Signal): Step {
59  switch (signal.kind) {
60    case 'session':
61      return { model: EMPTY, view: IDLE }
62
63    case 'prompt':
64    case 'turnStart':
65      return { model: { inFlight: 0, toolsThisTurn: 0 }, view: THINKING }
66
67    case 'toolStart': {
68      const next = {
69        inFlight: model.inFlight + 1,
70        toolsThisTurn: model.toolsThisTurn + 1,
71      }
72      return { model: next, view: working(signal.tool, next.inFlight) }
73    }
74
75    case 'toolEnd': {
76      const next = { ...model, inFlight: Math.max(0, model.inFlight - 1) }
77      if (!signal.isOk) {
78        return {
79          model: next,
80          view: { state: 'error', detail: `${signal.tool} failed` },
81          then: { ms: HOLD.errorMs, view: THINKING },
82        }
83      }
84      if (next.inFlight > 0) {
85        return { model: next, view: working(signal.tool, next.inFlight) }
86      }
87      return {
88        model: next,
89        view: { state: 'waiting', detail: `${signal.tool} done` },
90        then: { ms: HOLD.waitingMs, view: THINKING },
91      }
92    }
93
94    case 'ask':
95      return {
96        model,
97        view: { state: 'attention', detail: `${signal.tool} needs your OK` },
98      }
99
100    case 'compact':
101      return { model, view: { state: 'attention', detail: 'Compacting' } }
102
103    case 'turnEnd': {
104      const next = { inFlight: 0, toolsThisTurn: model.toolsThisTurn }
105      if (signal.reason === 'aborted') {
106        return { model: next, view: IDLE }
107      }
108      if (signal.reason === 'error' || signal.reason === 'refusal') {
109        return {
110          model: next,
111          view: { state: 'error', detail: 'Turn ended in an error' },
112          then: { ms: HOLD.turnErrorMs, view: IDLE },
113        }
114      }
115      const view: VesselView =
116        model.toolsThisTurn > 0
117          ? { state: 'success', detail: plural(model.toolsThisTurn, 'tool call') }
118          : { state: 'responding', detail: 'Replied' }
119      return { model: next, view, then: { ms: HOLD.successMs, view: IDLE } }
120    }
121  }
122}
123
hooks/sprite.ts 296 lines
1// Built-in sprite: 20x20 pixel frames composed from layers, emitted as one
2// SVG whose frames cycle with CSS steps(). No redraw per frame: the pane
3// redraws only when the state changes. Bundled Engrams now ship baked SVGs
4// from the Vessel Forge; this is the fallback and the media test's control.
5
6import type { VesselState } from '../types'
7
8export const GRID = 20
9
10/** Character -> palette slot. '.' is transparent. */
11export type Palette = {
12  o: string // outline
13  b: string // body
14  h: string // highlight
15  e: string // eye
16  w: string // eye shine
17  c: string // cheek
18  m: string // mouth
19  a: string // accent (thought dots, z's)
20  r: string // alert
21  y: string // sparkle
22  d: string // sweat drop
23}
24
25/**
26 * The built-in sprite's colors. It draws the media test's control and
27 * stands in when a baked Vessel's file can't be read.
28 */
29export const SPRITE_PALETTE: Palette = {
30  o: '#3b2f8f',
31  b: '#cfc6ff',
32  h: '#f4f1ff',
33  e: '#1a1c2b',
34  w: '#ffffff',
35  c: '#ff9ec4',
36  m: '#3b2f8f',
37  a: '#9d93ff',
38  r: '#ff5c5c',
39  y: '#ffd25e',
40  d: '#6fc3ff',
41}
42
43type Grid = string[][]
44type Dot = [x: number, y: number, ch: keyof Palette]
45
46// The body: a domed spirit. Rows 0-4 stay free for accessories.
47const BODY = [
48  '....................',
49  '....................',
50  '....................',
51  '....................',
52  '....................',
53  '.......oooooo.......',
54  '.....oohhbbbboo.....',
55  '....ohhbbbbbbbbo....',
56  '...ohbbbbbbbbbbbo...',
57  '...obbbbbbbbbbbbo...',
58  '...obbbbbbbbbbbbo...',
59  '...obbbbbbbbbbbbo...',
60  '...obbbbbbbbbbbbo...',
61  '...obbbbbbbbbbbbo...',
62  '...obbbbbbbbbbbbo...',
63  '...obbbbbbbbbbbbo...',
64  '...obbbbbbbbbbbbo...',
65  '...obbbbbbbbbbbbo...',
66]
67
68// Two tail poses; frames alternate them so the spirit always drifts.
69const TAILS: [string[], string[]] = [
70  ['...obbbobbbbobbbo...', '....ooo.oooo.ooo....'],
71  ['...obbbbobbbbobbo...', '....oooo.oooo.oo....'],
72]
73
74// Faces: dots drawn over the body.
75const EYES = {
76  open: [
77    [6, 10, 'w'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
78    [12, 10, 'w'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
79  ],
80  closed: [[6, 11, 'e'], [7, 11, 'e'], [12, 11, 'e'], [13, 11, 'e']],
81  happy: [[6, 11, 'e'], [7, 10, 'e'], [8, 11, 'e'], [11, 11, 'e'], [12, 10, 'e'], [13, 11, 'e']],
82  up: [
83    [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'],
84    [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'],
85  ],
86  focus: [[6, 11, 'e'], [7, 11, 'e'], [6, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'], [13, 10, 'e']],
87  wide: [
88    [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
89    [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
90  ],
91  left: [
92    [5, 10, 'w'], [6, 10, 'e'], [5, 11, 'e'], [6, 11, 'e'],
93    [11, 10, 'w'], [12, 10, 'e'], [11, 11, 'e'], [12, 11, 'e'],
94  ],
95  right: [
96    [7, 10, 'w'], [8, 10, 'e'], [7, 11, 'e'], [8, 11, 'e'],
97    [13, 10, 'w'], [14, 10, 'e'], [13, 11, 'e'], [14, 11, 'e'],
98  ],
99  x: [
100    [5, 9, 'e'], [7, 9, 'e'], [6, 10, 'e'], [5, 11, 'e'], [7, 11, 'e'],
101    [12, 9, 'e'], [14, 9, 'e'], [13, 10, 'e'], [12, 11, 'e'], [14, 11, 'e'],
102  ],
103} satisfies Record<string, Dot[]>
104
105const CHEEKS: Dot[] = [[5, 12, 'c'], [14, 12, 'c']]
106
107const MOUTH = {
108  smile: [[8, 13, 'm'], [9, 14, 'm'], [10, 14, 'm'], [11, 13, 'm']],
109  small: [[9, 14, 'm'], [10, 14, 'm']],
110  open: [[9, 13, 'm'], [10, 13, 'm'], [9, 14, 'm'], [10, 14, 'm']],
111  frown: [[8, 14, 'm'], [9, 13, 'm'], [10, 13, 'm'], [11, 14, 'm']],
112  o: [[9, 14, 'm']],
113} satisfies Record<string, Dot[]>
114
115function plus(x: number, y: number, ch: keyof Palette): Dot[] {
116  return [[x, y - 1, ch], [x - 1, y, ch], [x, y, ch], [x + 1, y, ch], [x, y + 1, ch]]
117}
118
119const BANG: Dot[] = [[9, 0, 'r'], [10, 0, 'r'], [9, 1, 'r'], [10, 1, 'r'], [9, 2, 'r'], [10, 2, 'r'], [9, 4, 'r'], [10, 4, 'r']]
120const DROP: Dot[] = [[16, 6, 'd'], [16, 7, 'd'], [17, 7, 'd'], [16, 8, 'd']]
121const Z_SMALL: Dot[] = [[16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'], [17, 4, 'a'], [16, 5, 'a'], [17, 5, 'a'], [18, 5, 'a']]
122const Z_BIG: Dot[] = [
123  [15, 0, 'a'], [16, 0, 'a'], [17, 0, 'a'], [18, 0, 'a'], [17, 1, 'a'], [16, 2, 'a'],
124  [15, 3, 'a'], [16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'],
125]
126
127/** One frame: which tail, and which dots on top. */
128type Frame = { tail: 0 | 1; dots: Dot[] }
129
130export type Animation = {
131  frames: Frame[]
132  /** Seconds each frame shows. */
133  frameS: number
134  /** Seconds per bob up and down; 0 holds still. */
135  bobS: number
136}
137
138const face = (...parts: Dot[][]): Dot[] => parts.flat()
139
140export const ANIMATIONS: Record<VesselState, Animation> = {
141  idle: {
142    frameS: 0.5,
143    bobS: 2,
144    frames: [0, 1, 0, 1, 0, 1].map((tail, i) => ({
145      tail: tail as 0 | 1,
146      dots: face(i === 5 ? EYES.closed : EYES.open, CHEEKS, MOUTH.small),
147    })),
148  },
149  thinking: {
150    frameS: 0.45,
151    bobS: 1.6,
152    frames: [
153      { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a']]) },
154      { tail: 1, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a']]) },
155      { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a'], [18, 1, 'a']]) },
156    ],
157  },
158  working: {
159    frameS: 0.18,
160    bobS: 0.5,
161    frames: [
162      { tail: 0, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(18, 7, 'y')) },
163      { tail: 1, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(1, 9, 'y')) },
164    ],
165  },
166  attention: {
167    frameS: 0.4,
168    bobS: 0.8,
169    frames: [
170      { tail: 0, dots: face(EYES.wide, CHEEKS, MOUTH.o, BANG) },
171      { tail: 1, dots: face(EYES.wide, CHEEKS, MOUTH.o) },
172    ],
173  },
174  success: {
175    frameS: 0.3,
176    bobS: 0.6,
177    frames: [
178      { tail: 0, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(2, 4, 'y'), [[17, 2, 'y']]) },
179      { tail: 1, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(17, 3, 'y'), [[2, 6, 'y']]) },
180    ],
181  },
182  responding: {
183    frameS: 0.2,
184    bobS: 1.2,
185    frames: [
186      { tail: 0, dots: face(EYES.open, CHEEKS, MOUTH.open) },
187      { tail: 1, dots: face(EYES.open, CHEEKS, MOUTH.small) },
188    ],
189  },
190  error: {
191    frameS: 0.6,
192    bobS: 0,
193    frames: [
194      { tail: 0, dots: face(EYES.x, MOUTH.frown, DROP) },
195      { tail: 1, dots: face(EYES.x, MOUTH.frown) },
196    ],
197  },
198  waiting: {
199    frameS: 0.45,
200    bobS: 1.6,
201    frames: [
202      { tail: 0, dots: face(EYES.left, CHEEKS, MOUTH.small) },
203      { tail: 1, dots: face(EYES.right, CHEEKS, MOUTH.small) },
204    ],
205  },
206  sleeping: {
207    frameS: 0.9,
208    bobS: 3.6,
209    frames: [
210      { tail: 0, dots: face(EYES.closed, MOUTH.o, Z_SMALL) },
211      { tail: 1, dots: face(EYES.closed, MOUTH.o, Z_BIG) },
212    ],
213  },
214}
215
216export function compose(frame: Frame): Grid {
217  const rows = [...BODY, ...TAILS[frame.tail]]
218  const grid = rows.map(row => row.split(''))
219  for (const [x, y, ch] of frame.dots) {
220    const row = grid[y]
221    if (row !== undefined && x >= 0 && x < GRID) {
222      row[x] = ch
223    }
224  }
225  return grid
226}
227
228/** Horizontal runs of one color become one rect, to keep the SVG small. */
229export function rects(grid: Grid, palette: Palette): string {
230  const out: string[] = []
231  grid.forEach((row, y) => {
232    let x = 0
233    while (x < row.length) {
234      const ch = row[x]
235      if (ch === '.') {
236        x += 1
237        continue
238      }
239      let end = x + 1
240      while (end < row.length && row[end] === ch) end += 1
241      const fill = palette[ch as keyof Palette] ?? '#ff00ff'
242      out.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${fill}"/>`)
243      x = end
244    }
245  })
246  return out.join('')
247}
248
249const r = (n: number) => Math.round(n * 1000) / 1000
250
251/** The whole animated Vessel for one state, as SVG markup. */
252export function vesselSvg(state: VesselState, palette: Palette, size: number): string {
253  const anim = ANIMATIONS[state]
254  const n = anim.frames.length
255  const cycle = r(anim.frameS * n)
256  const slice = r(100 / n)
257
258  const delays = anim.frames
259    .map((_, i) => `.d${i}{animation-delay:-${r(((n - i) % n) * anim.frameS)}s}`)
260    .join('')
261  const bob =
262    anim.bobS > 0
263      ? `.bob{animation:bob ${anim.bobS}s steps(1,end) infinite}` +
264        '@keyframes bob{0%{transform:translateY(0)}50%{transform:translateY(-1px)}100%{transform:translateY(0)}}'
265      : ''
266  const css =
267    `.f{opacity:0;animation:fr ${cycle}s steps(1,end) infinite}.f0{opacity:1}` +
268    `@keyframes fr{0%{opacity:1}${slice}%{opacity:0}100%{opacity:0}}` +
269    delays +
270    bob +
271    '@media (prefers-reduced-motion:reduce){.f,.bob{animation:none}}'
272
273  const frames = anim.frames
274    .map((frame, i) => `<g class="f f${i === 0 ? '0' : 'n'} d${i}">${rects(compose(frame), palette)}</g>`)
275    .join('')
276
277  return (
278    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 ${GRID} ${GRID + 1}" ` +
279    `width="${size}" height="${Math.round((size * (GRID + 1)) / GRID)}" shape-rendering="crispEdges">` +
280    `<style>${css}</style><g class="bob">${frames}</g></svg>`
281  )
282}
283
284/** Terminal fallback: one line per state. */
285export const FACES: Record<VesselState, string> = {
286  idle: '( o_o )',
287  thinking: '( o_o ) . o O',
288  working: '( >_< ) *',
289  attention: '( O_O ) !',
290  success: '( ^_^ ) +',
291  responding: '( o0o )',
292  error: '( x_x )',
293  waiting: '( o_o)>',
294  sleeping: '( -_- ) z',
295}
296
types/index.d.ts 27 lines
1export type VesselState =
2  | 'idle'
3  | 'thinking'
4  | 'working'
5  | 'attention'
6  | 'success'
7  | 'responding'
8  | 'error'
9  | 'waiting'
10  | 'sleeping'
11
12/** What the pane shows: the state and a short line of detail under it. */
13export type VesselView = { state: VesselState; detail: string }
14
15declare module 'claude-code' {
16  interface PluginState {
17    eidola: {
18      /** The live Vessel state, resolved from engine events. */
19      view: VesselView
20      /** The awake Engram's id, or null while asleep. Mirrors $.store. */
21      engram: string | null
22      /** True while the pane shows the media test (`/eidola media`). */
23      media: boolean
24    }
25  }
26}
27