Drive the Apeiron Engine editor from Claude Code: the engine's agent skills, an MCP server that starts the editor for your project on the first call, slash…

The official Claude Code marketplace for the Apeiron Engine. It carries two plugins with the same skills, recipes and agents; they differ only in which editor they reach:
| Plugin | Reaches | MCP server | You need |
|---|---|---|---|
apeiron | the editor installed on this PC | ngine mcp bridge (local, starts the editor for your project) | the Apeiron Engine installed |
apeiron-web | the web editor at engine.apeironengine.com, in your browser | the hosted relay, https://relay.apeironengine.com/mcp (signed in with your Apeiron account) | nothing else |
From the first prompt your agent knows the editor, follows the engine's own workflows, and drives the scene with measured evidence.
apeiron)You need the Apeiron Engine installed first (the installer puts the ngine command on your PATH; the launcher's Connect your AI agent card runs these two commands for you). In a terminal:
claude plugin marketplace add STE-FalconSoftware/apeiron-agent
claude plugin install apeiron@apeiron
(or /plugin marketplace add … and /plugin install … inside Claude Code). Restart Claude Code. The first time your agent calls an ngine.* tool, the editor opens on the project in the folder you started Claude Code in — and closes again when the session ends. Nothing to configure: no ports, no tokens, no config files. The launcher keeps the plugin current after each engine update (claude plugin marketplace update apeiron). Without the plugin, ngine mcp install --client claude-code --scope user --skills registers the same MCP server and skills from the installed engine; Codex, Cursor, Gemini CLI and VS Code use ngine mcp install --client <name>.
apeiron-web)Nothing to install but the plugin:
claude plugin marketplace add STE-FalconSoftware/apeiron-agent
claude plugin install apeiron-web@apeiron
Then in Claude Code run /mcp, choose apeiron-web, choose Authenticate, and sign in with your Apeiron account in the page that opens. Open engine.apeironengine.com and sign in there with the same account: every signed-in tab is reachable by your agent with nothing pasted (tab.list lists them, tab.attach picks one). /apeiron-web:connect-web walks through it; /apeiron-web:status diagnoses it. Agents other than Claude Code use the tab's Help > Connect AI Agent (a key-paired command) instead.
From an engine source checkout (developers): /plugin marketplace add ./ then /plugin install apeiron@apeiron-dev (or apeiron-web@apeiron-dev).
| Piece | What it does |
|---|---|
MCP server ngine (.mcp.json) | ngine mcp bridge --stdio --autolaunch: the version-agnostic launcher. It picks the engine version your project is pinned to (else the newest installed), attaches to the editor already open on this folder's project (or starts one) and closes an editor it started when your session ends. It keeps working after an engine upgrade. |
Skills apeiron-editor, apeiron-game-code | The engine's agent skills, copied verbatim from the package the engine ships. They route into the engine's own live recipes and guides (ngine.recipes, ngine.guide), which always match the installed version. |
Recipe skills recipe-<name> | One generated stub per authoring and gameplay recipe the engine carries (table below), so Claude Code's own skill matching can pick the right workflow. A stub is a pointer: it tells the agent to load the real recipe from the running editor. |
| Slash commands (skills, user-invoked) | /apeiron:status (diagnose every link, with fixes), /apeiron:launch, /apeiron:new-project, /apeiron:lookdev, /apeiron:report-issue (files the report with the Apeiron team from the editor — never on GitHub), /apeiron:connect-web (pair a browser tab through the apeiron-web plugin). |
| Agents | verifier (read-only checks with measurements), lookdev-judge, docs-scout. |
| SessionStart hook | Runs ngine mcp bridge --context and tells the session which editor, if any, belongs to this folder (at most 40 lines). |
Live editor mod (hooks/live.tsx) | A Claude Code hooks module. A band above the prompt shows the open level, play state, what you have selected (by name) and the agent's running job. Each prompt quietly carries your selection and camera, so "make this bigger" or "put a campfire here" needs no follow-up question. Editor tool calls read as actions with the editor's own one-line answer instead of JSON. After a turn that changed the scene, Undo turn (u) reverts exactly that turn's edits on the agent's own undo history (never the edits you made yourself, even mid-turn) and tells the agent it happened. /apeiron:editor connects and shows what the editor has open. The mod never starts an editor on its own. |
apeiron-web carries the same skills, recipe stubs and agents (generated from the same sources), its own three commands (/apeiron-web:connect-web, /apeiron-web:status, /apeiron-web:report-issue), the relay as an HTTP MCP server with MCP OAuth, the same live editor mod (/apeiron-web:editor), and no SessionStart hook (there is no local editor to look for).
The mod's tests run against Claude Code itself with a fake editor: claude plugin test sdk/claude-plugin/plugins/apeiron.
The engine compiles its task playbooks (ngine.recipes) into the editor. Each authoring and gameplay recipe has a generated skill here, named recipe-<name>, whose description is the recipe's own and whose body says to run ngine.recipes {name: "<id>"}. The recipe text is never copied, so a stub cannot go stale against your engine; only its one-line description can. The session-discipline recipes (ngine/00-orientation to ngine/04-conventions) have no stub of their own: apeiron-editor routes to them, and its references/recipes.md says which recipe answers which task.
<!-- recipes:begin (generated by scripts/gen_sdk_skills.py) -->
| Domain | Stub skill | Recipe it points at | Ops it names |
|---|---|---|---|
| authoring | recipe-character-rig | authoring/character-rig | 85 |
| authoring | recipe-cinematic-sequence | authoring/cinematic-sequence | 25 |
| authoring | recipe-lighting | authoring/lighting | 28 |
| authoring | recipe-lookdev | authoring/lookdev | 21 |
| authoring | recipe-loom-kitbash-architecture | authoring/loom-kitbash-architecture | 25 |
| authoring | recipe-loom-subassets | authoring/loom-subassets | 12 |
| authoring | recipe-material-graph | authoring/material-graph | 22 |
| authoring | recipe-motion-generation | authoring/motion-generation | 20 |
| authoring | recipe-pose-by-text | authoring/pose-by-text | 18 |
| authoring | recipe-post-process | authoring/post-process | 23 |
| authoring | recipe-procedural-cliffs | authoring/procedural-cliffs | 14 |
| authoring | recipe-rig-verify-fix | authoring/rig-verify-fix | 22 |
| authoring | recipe-scene-recreation | authoring/scene-recreation | 20 |
| authoring | recipe-source-control | authoring/source-control | 12 |
| authoring | recipe-terrain-erosion | authoring/terrain-erosion | 6 |
| authoring | recipe-terrain-slab-lookdev | authoring/terrain-slab-lookdev | 0 |
| authoring | recipe-terrain-worldgraph | authoring/terrain-worldgraph | 10 |
| authoring | recipe-texture-graph | authoring/texture-graph | 15 |
| authoring | recipe-vfx-from-reference | authoring/vfx-from-reference | 20 |
| gameplay | recipe-abilities-and-state-trees | gameplay/abilities-and-state-trees | 15 |
| gameplay | recipe-blueprints | gameplay/blueprints | 17 |
| gameplay | recipe-multiplayer | gameplay/multiplayer | 35 |
| gameplay | recipe-play-step-verify | gameplay/play-step-verify | 9 |
| gameplay | recipe-rhai-scripting | gameplay/rhai-scripting | 11 |
<!-- recipes:end -->
The engine developer's own skills (.claude/skills/ngine-*: building, verifying and landing engine changes) are deliberately NOT part of this plugin. They describe the engine's source checkout, which a customer does not have.
plugins/apeiron/.claude-plugin/plugin.json records the engine range its skills were written for (metadata.engine). The server entry hands the same range to the bridge; when the running engine is outside it, the bridge adds a SKILL MISMATCH warning to the session's instructions — once — and the skills tell the agent to prefer the live surface. editor.skills_manifest returns digests of the recipes and guides the running engine ships.
A plugin cannot ship a permission allowlist, so nothing here is pre-approved. Claude Code will ask before an ngine.* tool runs the first time. The engine's discovery tools (ngine.guide, ngine.catalog, ngine.describe, ngine.find, ngine.search, ngine.recipes) only read; destructive ops carry the MCP destructiveHint, so Claude Code keeps prompting for them. Approve the discovery tools once in your own settings if you want fewer prompts.
experimental.monitors) is declared. Claude Code marks that field experimental, and a monitor is only worth its context cost if something streams a line worth interrupting for; the editor has no such stream (ngine mcp status and /apeiron:status answer on demand), so a monitor would be noise, not a capability.ngine directly, and Claude Code spawns it without a shell. The installer therefore puts a REAL ngine.exe (a copy of the CLI) in <root>\bin\, on your PATH — not a .cmd forwarder, which a no-shell spawner cannot run. UNVERIFIED: this has not been run on a real Windows install with a real Claude Code; if ngine does not start, run ngine mcp install --client claude-code --scope user from a terminal (it writes whichever spelling your PATH resolves) and ngine doctor to see which link is broken.verifier and lookdev-judge agents are ADVISORY about being read-only: their instructions say to call only read ops, but Claude Code cannot restrict an agent to the engine's readOnlyHint ops (the engine's tools are the meta tools ngine.call / ngine.batch, which carry write ops too). Do not treat them as a safety boundary.The files under plugins/apeiron/skills/, plugins/apeiron/.claude-plugin/plugin.json, plugins/apeiron/.mcp.json and both marketplace.json files are generated from the engine repository by python scripts/gen_sdk_skills.py; --check (run by the pre-push hook) byte-compares them. The command skills (skills/status, launch, new-project, lookdev, report-issue), agents, hook and this README are edited in place. To publish, run python scripts/gen_sdk_skills.py --plugin <checkout of the public repo> and commit there.
hooks/live.tsx 513 lines1// Apeiron Engine, live in Claude Code.
2//
3// Four things, one module, both plugins (`apeiron` reaches the installed editor through the
4// `ngine` bridge, `apeiron-web` the browser editor through the relay; the mod calls whichever
5// this plugin's manifest lists):
6// 1. A band above the prompt: the editor's level, play state, selection and the agent's
7// running job, live.
8// 2. Selection-aware prompts: "make THIS bigger" carries what is selected and where the
9// camera looks, so the agent never asks which one.
10// 3. Readable editor tool rows: `ngine.call {op,args}` drawn as an action and the editor's
11// own one-line answer, not JSON.
12// 4. Undo turn: the agent's last turn counted on its own undo history, reverted with one key,
13// and the model told it happened.
14//
15// It never launches an editor by itself: the installed plugin's bridge auto-launches on any
16// tools/call, so the mod stays quiet until the agent (or the person, via Connect) has reached
17// a live editor.
18
19import type { Engine, Register } from 'claude-code'
20
21import type { EditorSnap, Job, Link, Picked, TurnEdits } from '../types'
22
23const LINK = { plugin: 'apeiron', key: 'link' } as const
24const EDITOR = { plugin: 'apeiron', key: 'editor' } as const
25const LAST_TURN = { plugin: 'apeiron', key: 'lastTurn' } as const
26const BAND_HIDDEN = { plugin: 'apeiron', key: 'isBandHidden' } as const
27
28// The MCP server keys the two shipping plugins use, in the order tried.
29const SERVER_KEYS = ['ngine', 'apeiron-web']
30// `mcp__<server>__ngine_call` / `..._batch` however the client spells the dot.
31const NGINE_TOOL = /^mcp__.+__ngine[._](call|batch)$/
32const POLL_MS = 2000
33
34type Reply = { ok: boolean; text: string; data: Record<string, unknown> | undefined }
35
36// Module state: a hot reload starts it over, which is fine (it only caches and counts).
37const S = {
38 server: undefined as string | undefined,
39 polling: false,
40 turnBaseline: null as number | null,
41 turnOps: [] as string[],
42 turnPrompt: '',
43 names: new Map<string, Picked>(),
44}
45
46// ---------------------------------------------------------------- state
47
48async function getLink($: Engine): Promise<Link> {
49 return (await $.state.get(LINK)).value ?? { state: 'unknown' }
50}
51
52async function getEditor($: Engine): Promise<EditorSnap | null> {
53 return (await $.state.get(EDITOR)).value ?? null
54}
55
56async function getLastTurn($: Engine): Promise<TurnEdits | null> {
57 return (await $.state.get(LAST_TURN)).value ?? null
58}
59
60async function getBandHidden($: Engine): Promise<boolean> {
61 return (await $.state.get(BAND_HIDDEN)).value ?? false
62}
63
64async function setLink($: Engine, next: Link) {
65 const cur = await getLink($)
66 if (cur.state === next.state && cur.message === next.message && cur.server === next.server) return
67 await $.state.set(LINK, next)
68 $.ui.status(next.state === 'down' ? 'Apeiron: editor not connected' : undefined)
69}
70
71// ---------------------------------------------------------------- the editor door
72
73async function resolveServer($: Engine): Promise<string | undefined> {
74 if (S.server) return S.server
75 for (const key of SERVER_KEYS) {
76 try {
77 const got = await $.mcp.connect(key)
78 if (got.isConnected) {
79 S.server = got.server
80 return S.server
81 }
82 } catch {
83 // not this plugin's key
84 }
85 }
86 return undefined
87}
88
89async function call($: Engine, op: string, args: Record<string, unknown> = {}): Promise<Reply> {
90 const name = await resolveServer($)
91 if (!name) return { ok: false, text: 'no Apeiron MCP server in this plugin', data: undefined }
92 try {
93 const r = await $.mcp.call(name, 'ngine.call', { op, args })
94 const text = (r.content ?? [])
95 .filter(b => b.type === 'text' && typeof b.text === 'string')
96 .map(b => b.text as string)
97 .join('\n')
98 const sc = (r as { structuredContent?: Record<string, unknown> }).structuredContent
99 const data = sc && typeof sc.json === 'object' && sc.json !== null
100 ? (sc.json as Record<string, unknown>)
101 : { ...(jsonIn(text) ?? {}), ...(sc ?? {}) }
102 return { ok: !r.isError, text, data }
103 } catch (err) {
104 return { ok: false, text: String(err), data: undefined }
105 }
106}
107
108// ---------------------------------------------------------------- reading the editor
109
110// `editor.context` is `- key: value` lines; keep the few the band and the prompt need.
111function contextField(text: string, key: string): string | undefined {
112 const m = text.match(new RegExp(`^- ${key}: (.+)$`, 'mi'))
113 return m ? m[1].trim() : undefined
114}
115
116async function nameOf($: Engine, id: string): Promise<Picked> {
117 const hit = S.names.get(id)
118 if (hit) return hit
119 const r = await call($, 'scene.inspect', { entity: id })
120 // The first line is `Entity e6v0 "Rock_07"`; an unnamed entity keeps its token.
121 const quoted = firstLine(r.text).match(/"([^"]{1,64})"/)
122 const one: Picked = { id, name: quoted ? quoted[1].trim() : id }
123 if (r.ok) S.names.set(id, one)
124 return one
125}
126
127async function readEditor($: Engine): Promise<EditorSnap | null> {
128 const [ctx, sel, follow, cam] = await Promise.all([
129 call($, 'editor.context'),
130 call($, 'scene.selection'),
131 call($, 'ui.follow_agent'),
132 call($, 'camera.get'),
133 ])
134 if (!ctx.ok && !sel.ok) {
135 const down = /not running|no editor|503|not connected|refused|ECONN/i.test(ctx.text)
136 await setLink($, { state: down ? 'down' : 'unknown', server: S.server, message: firstLine(ctx.text) })
137 return null
138 }
139 await setLink($, { state: 'live', server: S.server })
140
141 // `N selected — primary e6v0; set: e7v0, e6v0` then, on engines that have it, a
142 // `names: e7v0 "Moss_Ball", e6v0 "Rock_07"` line. Primary first; older engines cost one
143 // scene.inspect per shown entity for its name.
144 const primary = sel.text.match(/primary (e\d+v\d+)/)?.[1]
145 const set = (sel.text.match(/set: ([^\n—]+)/)?.[1] ?? '').match(/e\d+v\d+/g) ?? []
146 const count = sel.ok ? Number(sel.text.match(/^(\d+) selected/)?.[1] ?? set.length) : 0
147 const ordered = primary ? [primary, ...set.filter(t => t !== primary)] : set
148 const namesLine = sel.text.match(/^names: (.+)$/m)?.[1]
149 const named = new Map<string, string>()
150 for (const m of (namesLine ?? '').matchAll(/(e\d+v\d+) "([^"]*)"/g)) named.set(m[1], m[2])
151 const shown = ordered.slice(0, 3)
152 const selection = await Promise.all(
153 shown.map(id => (namesLine !== undefined ? { id, name: named.get(id) ?? id } : nameOf($, id))),
154 )
155
156 const running = follow.data?.running_job as Job | null | undefined
157 const eye = cam.data?.eye as number[] | undefined
158 const target = cam.data?.target as number[] | undefined
159
160 const location = contextField(ctx.text, 'location') ?? ''
161 const level = /^LAUNCHER/.test(location)
162 ? 'launcher (no project open)'
163 : location.match(/level '([^']+)'/)?.[1] ?? (location || undefined)
164
165 const snap: EditorSnap = {
166 level,
167 play: contextField(ctx.text, 'play state') ?? contextField(ctx.text, 'play'),
168 mode: contextField(ctx.text, 'mode'),
169 selection,
170 selectionCount: count,
171 job: running && running.total ? running : null,
172 camera: eye && target ? { eye, target } : undefined,
173 at: Date.now(),
174 }
175 await $.state.set(EDITOR, snap)
176 return snap
177}
178
179async function undoableNow($: Engine): Promise<number | null> {
180 const r = await call($, 'agents.list')
181 const convs = (r.data?.conversations as Array<{ is_you?: boolean; undoable?: number }> | undefined) ?? []
182 const me = convs.find(c => c.is_you)
183 return r.ok && me ? Number(me.undoable ?? 0) : null
184}
185
186async function poll($: Engine) {
187 if (S.polling) return
188 if ((await getLink($)).state !== 'live') return
189 S.polling = true
190 try {
191 await readEditor($)
192 } finally {
193 S.polling = false
194 }
195}
196
197// A person's gesture: allowed to wake the bridge (and so launch the installed editor).
198async function connect($: Engine): Promise<string> {
199 const snap = await readEditor($)
200 if (!snap) {
201 const l = await getLink($)
202 return `Apeiron editor not reachable: ${l.message ?? 'no answer'}`
203 }
204 return `Apeiron editor connected: ${snap.level ?? 'scene'} · ${snap.play ?? 'edit'} · ${snap.selectionCount} selected`
205}
206
207function promptContext(snap: EditorSnap): string {
208 const lines = ['Apeiron editor, as the person submitted this prompt:']
209 if (snap.level) lines.push(`- level: ${snap.level}`)
210 if (snap.play) lines.push(`- play state: ${snap.play}`)
211 if (snap.selectionCount === 0) {
212 lines.push('- selection: nothing selected')
213 } else {
214 const list = snap.selection.map(p => `${p.name} (${p.id}${p.kind ? `, ${p.kind}` : ''})`).join(', ')
215 const rest = snap.selectionCount - snap.selection.length
216 lines.push(`- selection (primary first): ${list}${rest > 0 ? ` and ${rest} more (scene.selection lists all)` : ''}`)
217 }
218 if (snap.camera) {
219 const f = (v: number[]) => v.map(n => Math.round(n * 100) / 100).join(', ')
220 lines.push(`- camera: eye [${f(snap.camera.eye)}] looking at [${f(snap.camera.target)}]`)
221 }
222 lines.push(
223 'When the prompt says "this", "these", "it" or "the selected ...", it means the selection above; "here" means the camera target. Do not ask which entity.',
224 )
225 return lines.join('\n')
226}
227
228// The turn's edits are counted on the agent's OWN undo history (agents.list `undoable` for this
229// conversation), so Undo turn steps exactly those back with per-conversation edit.undo — never the
230// person's edits or another agent's, even ones made in the middle of the turn.
231async function endTurn($: Engine): Promise<TurnEdits | null> {
232 const now = await undoableNow($)
233 const edits = now === null || S.turnBaseline === null ? 0 : Math.max(0, now - S.turnBaseline)
234 if (edits === 0) return null
235 return { edits, ops: S.turnOps.slice(-40), prompt: S.turnPrompt, undone: false }
236}
237
238async function undoTurn($: Engine) {
239 const turn = await getLastTurn($)
240 if (!turn || turn.undone) return
241 let undid = 0
242 for (let i = 0; i < turn.edits; i++) {
243 const r = await call($, 'edit.undo')
244 if (!r.ok) break
245 undid += 1
246 }
247 await $.state.set(LAST_TURN, { ...turn, undone: true, edits: undid })
248 $.ui.toast(undid === turn.edits ? `Reverted the agent's last turn (${undid} edits)` : `Reverted ${undid} of ${turn.edits} edits`)
249 // The model must not believe its edits still stand.
250 try {
251 await $.session.append({
252 message: {
253 type: 'user',
254 content: [{
255 type: 'text',
256 text: `[Apeiron] The person pressed "Undo turn" in Claude Code: ${undid} of your editor edits from the turn answering "${turn.prompt.slice(0, 120)}" were reverted on your undo history. The scene no longer has them; re-read it before building on that work.`,
257 }],
258 },
259 })
260 } catch {
261 // headless or refused: the toast already told the person
262 }
263 void readEditor($)
264}
265
266async function keepTurn($: Engine) {
267 await $.state.set(LAST_TURN, null)
268}
269
270// ---------------------------------------------------------------- hooks
271
272export const register: Register = on => {
273 on('session.start', async ($, e, next) => {
274 await $.command.register({
275 name: 'editor',
276 description: 'Connect to the Apeiron editor and show what it has open',
277 })
278 $.clock.every(POLL_MS, () => void poll($))
279 // The web relay never launches anything, so it is safe to look right away.
280 const name = await resolveServer($)
281 if (name && /apeiron-web/.test(name)) void readEditor($)
282 return next(e)
283 })
284
285 on('command.run', { command: 'editor' }, async $ => ({ text: await connect($) }))
286
287 // The agent's own editor calls: the first good answer turns the band on, and the op names
288 // feed the turn summary.
289 on('tool.call', async ($, e, next) => {
290 if (!NGINE_TOOL.test(e.tool)) return next(e)
291 const input = e as unknown as { op?: unknown }
292 S.turnOps.push(typeof input.op === 'string' ? input.op : e.tool.endsWith('batch') ? 'batch' : 'call')
293 const ran = await next(e)
294 const failed = ran.deny !== undefined || (ran as { isError?: boolean }).isError === true
295 if (!failed && (await getLink($)).state !== 'live') {
296 await setLink($, { state: 'live', server: S.server })
297 // The editor may have just launched, so this conversation's history began empty.
298 if (S.turnBaseline === null) S.turnBaseline = 0
299 void readEditor($)
300 }
301 return ran
302 })
303
304 // 2. Selection-aware prompts.
305 on('prompt.submit', async ($, e, next) => {
306 S.turnOps = []
307 S.turnPrompt = e.text
308 S.turnBaseline = null
309 if ((await getLink($)).state !== 'live') return next(e)
310 const snap = (await readEditor($)) ?? (await getEditor($))
311 S.turnBaseline = await undoableNow($)
312 if (!snap) return next(e)
313 return next({ ...e, context: [...(e.context ?? []), promptContext(snap)] })
314 })
315
316 // 4. Undo turn: count the turn's edits on the agent's own undo history.
317 on('turn.complete', async ($, e, next) => {
318 const result = await next(e)
319 if ((await getLink($)).state === 'live' && S.turnBaseline !== null) {
320 const turn = await endTurn($)
321 if (turn) {
322 await $.state.set(LAST_TURN, turn)
323 await $.state.set(BAND_HIDDEN, false)
324 }
325 void readEditor($)
326 }
327 S.turnBaseline = null
328 return result
329 })
330
331 // 1. The band above the prompt.
332 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
333 const l = await getLink($)
334 if (e.props.hasSurvey || l.state === 'unknown' || (await getBandHidden($))) return next(e)
335 const snap = await getEditor($)
336 const turn = await getLastTurn($)
337 const { Box, Text, Button } = $.ui.resolve(e)
338
339 if (l.state === 'down') {
340 return (
341 <Box flexDirection="row" gap={1}>
342 <Text color="yellow">○ Apeiron</Text>
343 <Text dimColor wrap="truncate">editor not connected</Text>
344 <Button key="connect" label="Connect" hotkey="c" plain onPress={() => void connect($)} />
345 </Box>
346 )
347 }
348
349 const picked = snap?.selectionCount ?? 0
350 const sel = !snap || picked === 0
351 ? 'nothing selected'
352 : `${picked} selected: ${snap.selection.map(p => p.name).join(', ')}${picked > snap.selection.length ? ', …' : ''}`
353 const job = snap?.job ?? null
354
355 return (
356 <Box flexDirection="column" width={e.props.bodyColumns}>
357 <Box flexDirection="row" gap={1}>
358 <Text color="cyan" bold>◆ Apeiron</Text>
359 <Text color="green">●</Text>
360 <Text wrap="truncate">{snap?.level ?? 'editor'}</Text>
361 {snap?.play ? <Text dimColor>· {snap.play}</Text> : null}
362 <Text dimColor>·</Text>
363 <Text color={picked ? 'magenta' : undefined} dimColor={!picked} wrap="truncate">
364 ▸ {sel}
365 </Text>
366 </Box>
367 {job ? (
368 <Box flexDirection="row" gap={1}>
369 <Text color="cyan">⟳</Text>
370 <Text>{humanOp(job.op)}</Text>
371 <Text color="cyan">{progressBar(job.done / Math.max(1, job.total), 12)}</Text>
372 <Text dimColor>{job.done}/{job.total}{job.phase ? ` · ${job.phase}` : ''}</Text>
373 </Box>
374 ) : null}
375 {turn && turn.edits > 0 && !e.props.isWorking ? (
376 turn.undone ? (
377 <Text dimColor>↶ Last turn reverted ({turn.edits} edits)</Text>
378 ) : (
379 <Box flexDirection="row" gap={1}>
380 <Text color="yellow">✎</Text>
381 <Text>Last turn changed the scene: {turn.edits} edit{turn.edits === 1 ? '' : 's'}</Text>
382 <Text dimColor wrap="truncate">
383 ({summarizeOps(turn.ops)})
384 </Text>
385 <Button key="undo" label="Undo turn" hotkey="u" variant="primary" onPress={() => void undoTurn($)} />
386 <Button key="keep" label="Keep" hotkey="k" plain onPress={() => void keepTurn($)} />
387 </Box>
388 )
389 ) : null}
390 </Box>
391 )
392 })
393
394 // 3. Readable editor tool rows.
395 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
396 if (!NGINE_TOOL.test(e.props.tool)) return next(e)
397 const { Box, Text } = $.ui.resolve(e)
398 const input = (e.props.input ?? {}) as { op?: unknown; args?: unknown; ops?: unknown }
399 const isBatch = /batch$/.test(e.props.tool)
400 const op = isBatch ? 'batch' : String(input.op ?? 'call')
401 const steps = isBatch && Array.isArray(input.ops) ? (input.ops as Array<{ op?: unknown }>) : []
402 const title = isBatch ? `${steps.length} editor actions` : humanOp(op)
403 const detail = isBatch ? summarizeOps(steps.map(s => String(s?.op ?? '?'))) : argHint(input.args)
404 const answer = firstLine(outputText(e.props.output))
405 const mark = e.props.isRunning ? '…' : e.props.isErrored ? '✗' : e.props.isInterrupted ? '■' : '◆'
406 const color = e.props.isErrored ? 'red' : e.props.isRunning ? 'cyan' : 'green'
407
408 return (
409 <Box flexDirection="column">
410 <Box flexDirection="row" gap={1}>
411 <Text color={color}>{mark}</Text>
412 <Text bold>{title}</Text>
413 {isBatch || !domainOf(op) ? null : <Text dimColor>{domainOf(op)}</Text>}
414 {detail ? <Text dimColor wrap="truncate">{detail}</Text> : null}
415 </Box>
416 {answer && !e.props.isRunning ? (
417 <Box paddingLeft={2}>
418 <Text color={e.props.isErrored ? 'red' : undefined} dimColor={!e.props.isErrored} wrap="truncate">
419 ⎿ {answer}
420 </Text>
421 </Box>
422 ) : null}
423 </Box>
424 )
425 })
426}
427
428// ---------------------------------------------------------------- text helpers
429
430const VERB: Record<string, string> = {
431 spawn: 'Spawn', place: 'Place', set: 'Set', get: 'Read', add: 'Add', remove: 'Remove', delete: 'Delete',
432 despawn: 'Remove', select: 'Select', describe: 'Describe', inspect: 'Inspect', query: 'Query',
433 screenshot: 'Screenshot', frame: 'Frame', duplicate: 'Duplicate', rename: 'Rename', group: 'Group',
434 undo: 'Undo', redo: 'Redo', apply: 'Apply', revert: 'Revert', capture: 'Capture', save: 'Save',
435}
436
437// `scene.set_transform` -> "Set transform"; `place.actor` -> "Place actor" (the domain IS the verb);
438// `render.gpu_timings` -> "Gpu timings" with the domain shown beside it.
439function humanOp(op: string): string {
440 const dot = op.indexOf('.')
441 const domain = dot >= 0 ? op.slice(0, dot) : ''
442 const words = (dot >= 0 ? op.slice(dot + 1) : op).split('_')
443 if (VERB[words[0]]) return [VERB[words[0]], ...words.slice(1)].join(' ')
444 if (VERB[domain]) return [VERB[domain], ...words].join(' ')
445 return [cap(words[0]), ...words.slice(1)].join(' ')
446}
447
448// The domain, unless humanOp already spoke it as the verb.
449function domainOf(op: string): string {
450 const dot = op.indexOf('.')
451 if (dot < 0) return ''
452 const domain = op.slice(0, dot)
453 return VERB[domain] && !VERB[op.slice(dot + 1).split('_')[0]] ? '' : domain
454}
455
456function cap(s: string): string {
457 return s ? s[0].toUpperCase() + s.slice(1) : s
458}
459
460function argHint(args: unknown): string {
461 if (!args || typeof args !== 'object') return ''
462 const a = args as Record<string, unknown>
463 for (const k of ['name', 'asset', 'entity', 'path', 'preset', 'mode', 'field']) {
464 if (typeof a[k] === 'string') return `${k} ${a[k]}`
465 }
466 const n = Object.keys(a).length
467 return n ? `${n} arg${n === 1 ? '' : 's'}` : ''
468}
469
470function summarizeOps(ops: string[]): string {
471 const counts = new Map<string, number>()
472 for (const op of ops) {
473 const k = humanOp(op).toLowerCase()
474 counts.set(k, (counts.get(k) ?? 0) + 1)
475 }
476 const shown = [...counts].slice(0, 4).map(([k, n]) => (n > 1 ? `${k} ×${n}` : k))
477 return shown.join(', ') + (counts.size > 4 ? ', …' : '')
478}
479
480function outputText(out: unknown): string {
481 if (out == null) return ''
482 if (typeof out === 'string') return out
483 if (Array.isArray(out)) {
484 return out.map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '')).join('\n')
485 }
486 if (typeof out === 'object' && Array.isArray((out as { content?: unknown }).content)) {
487 return outputText((out as { content: unknown }).content)
488 }
489 return ''
490}
491
492// Deferred ops answer `[applied] op: {json}` (or a lead line, then JSON): the object after the
493// first brace, when it parses.
494function jsonIn(text: string): Record<string, unknown> | undefined {
495 const at = text.indexOf('{')
496 if (at < 0) return undefined
497 try {
498 const v = JSON.parse(text.slice(at))
499 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined
500 } catch {
501 return undefined
502 }
503}
504
505function firstLine(s: string): string {
506 return (s.split('\n').find(l => l.trim()) ?? '').trim()
507}
508
509function progressBar(fraction: number, cells: number): string {
510 const n = Math.round(Math.min(1, Math.max(0, fraction)) * cells)
511 return '█'.repeat(n) + '░'.repeat(cells - n)
512}
513types/index.d.ts 42 lines1/** Whether the editor answers, and through which MCP server (`plugin:<plugin>:ngine` or `...:apeiron-web`). */
2export type Link = {
3 state: 'unknown' | 'live' | 'down'
4 server?: string
5 message?: string
6}
7
8export type Picked = { id: string; name: string; kind?: string }
9
10export type Job = { op: string; done: number; total: number; phase?: string }
11
12/** One read of the running editor: what the band draws and what a prompt carries. */
13export type EditorSnap = {
14 level?: string
15 play?: string
16 mode?: string
17 selection: Picked[]
18 selectionCount: number
19 job: Job | null
20 camera?: { eye: number[]; target: number[] }
21 at: number
22}
23
24/** What the agent's last turn did to the scene, counted on its own undo history. */
25export type TurnEdits = {
26 edits: number
27 ops: string[]
28 prompt: string
29 undone: boolean
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'apeiron': {
35 link: Link
36 editor: EditorSnap | null
37 lastTurn: TurnEdits | null
38 isBandHidden: boolean
39 }
40 }
41}
42