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

A pixel Vessel in a pane that reacts to what Claude is doing, plus the awake Engram's Soul in the system prompt.
/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, /engramsBoth are in /config once the mod is installed.
| Option | Default | What it does |
|---|---|---|
placement | default | Where 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. |
statusLine | on | The 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.
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./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.
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 .
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 502 lines1import { 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}
502hooks/engrams.ts 103 lines1// 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}
103hooks/media-test.ts 48 lines1// 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 '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'
9
10export const TEST_WEBM_BASE64 =
11 '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'
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}
48hooks/net-probe.ts 39 lines1// 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}
39hooks/placement.ts 56 lines1// 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}
56hooks/resolver.ts 123 lines1// 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}
123hooks/sprite.ts 296 lines1// 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}
296types/index.d.ts 27 lines1export 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