SLOPSHOPPER

Skill Heaven console (preview)

Optional PREVIEW console for Claude Code that observes Skill Heaven summons and never changes them: a status entry, a Lens band above the prompt, and a /heaven…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-heaven-console
│ ┃ Skill Heaven ✕ › fix the failing auth test and add an audit log call │ ┃ Skill Heaven PREVIEW console · observes, n │ ┃ ◇ entropy ‹‹ [NATIVE] ›› · 0 skills / 0 summ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Session ] [ Scope ] [ Flow ] [ Trust ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Nothing summoned yet. Try /skill-heaven:summ ⏺ Bash(bun test) │ ┃ /lens <need>. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Evidence: observed = this console saw it · r ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ the summon engine said so · inferred = deriv │ ┃ unknown = no source. ✻ Worked for 42s · done 4:20 PM │ │ › /heaven │ ⎿ skill-heaven-console: Skill Heaven console opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ skill-heaven-console: ◇ entropy ‹‹ [NATIVE] ›› · 0 skills

Draws

Pane · Skill Heaven
Skill Heaven PREVIEW console · observes, never changes ◇ entropy ‹‹ [NATIVE] ›› · 0 skills / 0 summons [ Session ] [ Scope ] [ Flow ] [ Trust ] Nothing summoned yet. Try /skill-heaven:summon <need> or /lens <need>. Evidence: observed = this console saw it · reported = the summon engine said so · inferred = derived here · unknown = no source.
README

Skill Heaven console (preview)

An optional console for Skill Heaven in Claude Code. It shows what a summon returned, whether the skill body actually entered context, and what you are trusting. It observes; it never changes a summon, refuses a tool call, or runs one on its own. Skill Heaven works exactly the same without it.

Status: preview. It is built on Claude Code's early-access Mods API (function hooks, first available in 2.1.293), which may change between releases. It has passed claude plugin validate and the engine's own test kit on both the terminal and desktop surfaces, and a live probe in a logged-in terminal session on 2.1.294 (below). How it paints in the desktop app has not been probed — treat the desktop look as unconfirmed.

What it adds

SurfaceWhat you see
Status entry◇ entropy ‹‹ [NATIVE] ›› · 2 skills beside your own status line. It is appended; your statusLine setting is never touched.
Lens bandEmpty by default. After a summon it shows the result in two lines and whether the skill body was read (card returned · body not read or in context · body read by main agent). Inspect opens the pane, Dismiss hides it. It also hides on your next prompt.
/lens <intent>Previews which skill would be summoned, with preview: true. Nothing is materialized; the summon engine still logs the query in its own session directory. The preview and its detail appear in the band, not in the conversation. For a single candidate the band offers Summon, which only fills your prompt with /skill-heaven:summon <name> — you press Enter. If a summon tool ignores preview and materializes a skill, the band shows that real summon and the count includes it.
/heavenA pane with four sections: Session (receipts, each field labelled observed, reported, inferred or unknown), Scope (what Skill Heaven can see, what is active, rung controls that pre-fill commands), Flow (agents the host reported and what each summoned) and Trust (what this plugin reads and writes).

Selecting a rung (/skill-heaven:skill-heaven low, /skill-heaven:skill-hell high, /skill-heaven:skill-ultra, /skill-heaven:skill-zero) changes the bracketed reading in the status entry. That reading is a preference you expressed and the console saw; Skill Heaven does not enforce it. /skill-ultra reads [ULTRA]; the Ultra controller is provisioned, not built. The compact status entry shows only [ULTRA] and the skill count — controller unavailable is spelled out in full mode, in the Scope section and in the pane.

Install

Add the marketplace once, then install:

/plugin marketplace add gaia-research/gaia-skill-heaven
/plugin install skill-heaven-console@gaia-skill-heaven

It needs the skill-heaven plugin (or the claude-zero launcher) to have anything to observe; without a summon tool the pane says summon tool: not connected.

A mod installed this way is a terminal action: a local session started from the desktop app loads a user-scope install, but the install command itself is typed in a terminal session.

What it reads and writes

  • Reads: the results of the Skill Heaven summon tool, the /skill-* commands you submit, and Read and Agent tool calls — only to observe them.
  • Writes: nothing of its own to disk and nothing to your settings. State lives in the session only and is gone when it ends. (The summon engine keeps its own session directory, including for a /lens preview.)
  • Network: none of its own. /lens calls the bundled summon tool, which fetches the skill source.
  • What the model can read: the one-line result of /lens and /heaven is a row the model can see. It is fixed text and carries nothing a skill source, a tool error or a refusal supplied; all detail stays in the band and the pane.
  • Never: rewrites or refuses a tool call, submits a prompt, or edits ~/.claude. Buttons pre-fill a command for you to submit. All text that came from a skill source or a model is sanitized before it is drawn.

Options

status (Settings, or pluginConfigs in settings.json): off, compact (default) or full. off removes the status entry; the band and pane stay available.

Disable or remove

/plugin disable skill-heaven-console
/plugin uninstall skill-heaven-console@gaia-skill-heaven

The status entry, band and pane disappear. Skill Heaven is unchanged.

What a live probe established (Claude Code 2.1.294, terminal)

PR #187, macOS, a logged-in session in a visible Herdr pane, both plugins loaded from the checkout with --plugin-dir and a session-only --settings that supplied a user statusLine:

  • Status entry renders under the prompt beside the user's own statusLine; neither replaces the other. The host draws it as ⚠ skill-heaven-console: ◇ entropy ‹‹ [NATIVE] ›› · 0 skills; the ⚠ <plugin>: prefix is the host's.
  • /lens shows the band and leaves the count at 0 skills. /skill-heaven:summon takes it to 1 skill. A Read of the materialized SKILL.md moves the band from card returned · body not read to in context · body read by main agent.
  • Result shape: an MCP tool's result reaches tool.call and $.tool.call as { ref, result, text }, with result the server's JSON text as a string. structuredContent appears nowhere; the console reads the JSON text.
  • $.tool.list() names deferred MCP tools (the summon tool is behind ToolSearch and is listed), but only once MCP has connected: at session.start it lists no MCP tool at all, so /lens re-checks for a few seconds before saying "not connected".
  • Agent ids reach subagent calls: tool.call and turn.complete inside a subagent carry its agentId, and the Agent tool's result carries the same id. Flow attributes the subagent's summons to it.
  • Ordering: for a typed slash command, command.run fires before prompt.submit, and prompt.submit.text is the raw typed text (/skill-heaven:skill-hell high), not the expanded body. A subagent's hand-back arrives as prompt.submit with origin.kind: "peer"; the console ignores it.
  • Bare spellings are refused: /skill-zero or /skill-hell high resolves to the plugin's portable skill and the host says it "can only be invoked by Claude". Every console pre-fill uses /skill-heaven:<surface>.
  • Observe-only: across the sessions, ~/.claude/settings.json, settings.local.json and installed_plugins.json were byte-identical. claude plugin validate resolves the console's calls to $.state, $.ui.*, $.prompt.fill, $.tool.list/call (the /lens preview) and $.command.register: no file system, settings or process calls.

Still unconfirmed:

  • How the status entry, band and pane paint in the desktop app. Probing it here would have meant a user-scope install, which writes the user's plugin registry, plus driving the desktop UI.
  • Whether the host reports agent ids for every kind of agent (workflows and engine forks carry ids no list names).

Develop

node scripts/build-status.mjs                 # regenerate hooks/status-model.mjs from packages/status
claude plugin validate plugins/skill-heaven-console
claude plugin test plugins/skill-heaven-console

hooks/status-model.mjs is generated and committed; CI checks it against a fresh build. Do not edit it.

Source 6 files
hooks/register.tsx 443 lines
1// Skill Heaven console (preview) — a Claude Code mod.
2//
3// It projects the one status model (status-model.mjs) into the desktop and the
4// terminal: a status entry, the Lens band above the prompt, and the /heaven
5// pane. It OBSERVES summons; it never rewrites, refuses or issues one. Every
6// tool.call hook below calls `next`, records what came back, and returns the
7// result untouched. A button pre-fills a command for the person to submit; the
8// UI is not an authority channel. Nothing is written to disk or to settings.
9//
10// The engine reads this file's source: every helper that is handed `$` is a
11// function declared at the top level of this file.
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, PluginOptions, Register, RenderSurface } from 'claude-code'
15
16import {
17  eventFromSummonResult,
18  renderStatusSegments,
19  sanitizeDisplay,
20  selectionFromCommand,
21  toPlain,
22} from './status-model.mjs'
23import type { StatusMode, SummonEvent } from './status-model.mjs'
24import { PANE_ID } from './meta.ts'
25import {
26  LAUNCHER_SUMMON_TOOL,
27  MARKETPLACE_SUMMON_TOOL,
28  SUMMON_TOOL,
29  agentCallLabel,
30  agentLabelFor,
31  agentReturned,
32  dropAgentCall,
33  finishAgentCall,
34  fromData,
35  initialState,
36  noteAgentId,
37  qualifyCommand,
38  recordEvent,
39  recordRead,
40  recordSelection,
41  startAgentCall,
42  structuredOf,
43  toData,
44} from './model.ts'
45import type { ConsoleSection, ConsoleState } from './model.ts'
46import { renderBand, renderPane } from './views.tsx'
47import type { BandActions, PaneActions } from './views.tsx'
48
49// The shape tag: a reload whose code names another tag starts from a clean state
50// instead of reading a value an older layout wrote.
51const stateAtom = atom({ plugin: 'skill-heaven-console', key: 'state' } as const, toData(initialState()), {
52  shape: 'skill-heaven-console/state@2',
53})
54
55const SECTIONS: readonly ConsoleSection[] = ['session', 'scope', 'flow', 'trust']
56
57// True while this console's own /lens preview is in flight, so the summon
58// observer does not record the same call twice.
59let lensInFlight = false
60
61function modeOf(options: PluginOptions): StatusMode {
62  const v = options.status
63  return v === 'off' || v === 'full' ? v : 'compact'
64}
65
66const isoOf = (ms: number): string => new Date(ms).toISOString()
67
68/** Re-set the status entry from state. `off` removes it; it never touches statusLine. */
69function paintStatus($: EngineInterface, mode: StatusMode, state: ConsoleState): void {
70  $.ui.status(mode === 'off' ? undefined : toPlain(renderStatusSegments(state.status, mode)))
71}
72
73async function load($: EngineInterface): Promise<ConsoleState> {
74  return fromData(await read($, stateAtom))
75}
76
77/** Change state, then re-set the status entry. */
78async function change($: EngineInterface, mode: StatusMode, fn: (s: ConsoleState) => ConsoleState): Promise<ConsoleState> {
79  const next = fromData(await update($, stateAtom, (data) => toData(fn(fromData(data)))))
80  paintStatus($, mode, next)
81  return next
82}
83
84async function openPane($: EngineInterface): Promise<void> {
85  await $.ui.open({ id: PANE_ID, title: 'Skill Heaven' })
86}
87
88/** Fire and forget, but never leave a rejection unhandled. */
89function later(work: Promise<unknown>): void {
90  work.catch(() => {})
91}
92
93/** Pre-fill the prompt without wiping a draft; say so when the box could not take it.
94 * Skill Heaven commands are filled in the spelling the session accepts. */
95async function fillPrompt($: EngineInterface, command: string): Promise<void> {
96  const text = qualifyCommand(command)
97  try {
98    const filled = await $.prompt.fill({ text, mode: 'insert' })
99    if (!filled.isFilled) $.ui.toast(`Type this: ${text}`)
100  } catch {
101    $.ui.toast(`Type this: ${text}`)
102  }
103}
104
105async function copyText($: EngineInterface, text: string, surface: RenderSurface): Promise<void> {
106  try {
107    const copied = await $.ui.copy({ text, surface })
108    if (!copied.isCopied) $.ui.toast(`Copy this: ${text}`)
109  } catch {
110    $.ui.toast(`Copy this: ${text}`)
111  }
112}
113
114type Preview = { event: SummonEvent; reached: boolean } | { notConnected: true }
115
116/**
117 * Ask the summon tool what it would summon, with `preview: true`.
118 *
119 * A tool is absent only when it is not in the session's tool list. A call to a
120 * tool the list names that is rejected was aborted or failed: that is an error
121 * event, and says nothing about whether the tool is connected.
122 */
123/** MCP servers connect in the background after session.start: observed on
124 * 2.1.294, `$.tool.list()` names no MCP tool at session.start and a /lens typed in
125 * the first seconds found no summon tool. An absent tool is checked again, a few
126 * times, before the band says "not connected". */
127const CONNECT_CHECKS = 4
128const CONNECT_WAIT_MS = 1500
129
130async function previewSummon($: EngineInterface, query: string): Promise<Preview> {
131  for (let check = 1; ; check++) {
132    const outcome = await previewOnce($, query)
133    if (!('notConnected' in outcome) || check >= CONNECT_CHECKS) return outcome
134    try {
135      await $.clock.sleep(CONNECT_WAIT_MS)
136    } catch {
137      return outcome
138    }
139  }
140}
141
142async function previewOnce($: EngineInterface, query: string): Promise<Preview> {
143  const listed: string[] = []
144  try {
145    for (const t of await $.tool.list()) if (SUMMON_TOOL.test(t.name)) listed.push(t.name)
146  } catch {
147    // listing is a convenience; the two known spellings are tried next
148  }
149  const names = [...listed]
150  for (const known of [MARKETPLACE_SUMMON_TOOL, LAUNCHER_SUMMON_TOOL]) if (!names.includes(known)) names.push(known)
151  const at = isoOf(await $.clock.now())
152  const failed = (text: string): Preview => ({
153    event: eventFromSummonResult(undefined, { at, agent: null, preview: true, query }, { isError: true, text }),
154    reached: false,
155  })
156  for (const name of names) {
157    let result
158    try {
159      result = await $.tool.call({
160        tool: name as `mcp__${string}__${string}`,
161        query,
162        surface: 'any',
163        preview: true,
164      })
165    } catch {
166      if (listed.includes(name)) return failed('preview aborted or failed')
167      continue // not in this session
168    }
169    if (result.deny !== undefined) return failed(`preview refused: ${result.deny}`)
170    const failure = result.isError === true ? ({ isError: true, text: result.text ?? null } as const) : undefined
171    return {
172      event: eventFromSummonResult(failure ? undefined : structuredOf(result), { at, agent: null, preview: true, query }, failure),
173      reached: true,
174    }
175  }
176  return { notConnected: true }
177}
178
179/** The one-line result of /lens. The model reads it, so it is fixed text: nothing a
180 * skill source, a tool error or a refusal supplied ever goes in it. The detail is in the band. */
181function lensResultText(event: SummonEvent): string {
182  if (event.kind === 'summoned' && event.delta > 0) {
183    return 'Lens: this summon tool ignored preview; a skill was materialized. See the band.'
184  }
185  if (event.kind === 'previewed') return 'Lens preview shown in the band. Nothing was summoned.'
186  if (event.kind === 'no-match') return 'Lens: no match. Nothing was summoned.'
187  return 'Lens: the preview failed; see the band.'
188}
189
190export const register: Register = (on, options) => {
191  const mode = modeOf(options)
192
193  /* ----------------------------------------------------------------------- *
194   * Session
195   * ----------------------------------------------------------------------- */
196
197  on('session.start', async ($, e, next) => {
198    await $.command.register({ name: 'heaven', description: 'Open the Skill Heaven console', argumentHint: '[session|scope|flow|trust]' })
199    await $.command.register({
200      name: 'lens',
201      description: 'Preview which skill would be summoned — nothing is materialized',
202      argumentHint: '<intent>',
203    })
204    let hostVersion: string | null = null
205    try {
206      hostVersion = (await $.session.version()).version
207    } catch {
208      hostVersion = null
209    }
210    await change($, mode, (s) => ({ ...s, hostVersion }))
211    return next(e)
212  })
213
214  // A /clear ends the conversation without a new session.start: what the console
215  // counted belongs to the conversation that is gone.
216  on('session.end', async ($, e, next) => {
217    if (e.reason === 'clear') {
218      try {
219        await change($, mode, (s) => ({ ...initialState(), hostVersion: s.hostVersion }))
220      } catch {
221        // leave the old state rather than break the session
222      }
223    }
224    return next(e)
225  })
226
227  /* ----------------------------------------------------------------------- *
228   * Commands
229   * ----------------------------------------------------------------------- */
230
231  on('command.run', { command: 'heaven' }, async ($, e) => {
232    const arg = e.args.trim().toLowerCase()
233    const section = SECTIONS.find((s) => s === arg)
234    if (section) await change($, mode, (s) => ({ ...s, section }))
235    await openPane($)
236    // The model reads this row: fixed text only.
237    return { text: 'Skill Heaven console opened.' }
238  })
239
240  on('command.run', { command: 'lens' }, async ($, e) => {
241    const query = sanitizeDisplay(e.args, 200)
242    if (query === '') return { text: 'Usage: /lens <intent>. Nothing was summoned.' }
243    await change($, mode, (s) => ({ ...s, band: { kind: 'looking', query } }))
244    lensInFlight = true
245    try {
246      let outcome: Preview
247      try {
248        outcome = await previewSummon($, query)
249      } finally {
250        lensInFlight = false
251      }
252      if ('notConnected' in outcome) {
253        await change($, mode, (s) => ({
254          ...s,
255          band: { kind: 'not-connected' },
256          status: { ...s.status, summonTool: 'not-connected' },
257        }))
258        return { text: 'Lens: the summon tool is not connected.' }
259      }
260      const { event, reached } = outcome
261      await change($, mode, (s) => recordEvent(s, event, null, 'lens', reached))
262      return { text: lensResultText(event) }
263    } finally {
264      // however it ended, the in-flight band does not stay up
265      try {
266        await change($, mode, (s) => (s.band !== null && s.band.kind === 'looking' ? { ...s, band: null } : s))
267      } catch {
268        // nothing more to do
269      }
270    }
271  })
272
273  /* ----------------------------------------------------------------------- *
274   * Observing (never changing) tool calls
275   * ----------------------------------------------------------------------- */
276
277  // Agents: the host reports an agent id on calls made inside a subagent, and the
278  // Agent tool starts one. Both only add rows to Flow.
279  on('tool.call', async ($, e, next) => {
280    const args = e as unknown as Record<string, unknown>
281    const isAgentCall = String(e.tool) === 'Agent' || String(e.tool) === 'Task'
282    const key = typeof e.tool_use_id === 'string' ? e.tool_use_id : ''
283    const background = args.run_in_background !== false
284    try {
285      const reported = typeof e.agentId === 'string' ? e.agentId : null
286      if (reported !== null) {
287        const seen = await load($)
288        if (!seen.agentIdsSeen || !seen.agents.some((a) => a.id === reported)) {
289          await change($, mode, (s) => noteAgentId(s, reported))
290        }
291      }
292      if (isAgentCall && key) {
293        const label = agentCallLabel(args.subagent_type, args.description)
294        await change($, mode, (s) => startAgentCall(s, key, label, background))
295      }
296    } catch {
297      // observing must never get in the way of the call
298    }
299    let r
300    try {
301      r = await next(e)
302    } catch (error) {
303      // aborted: the call started nothing
304      if (isAgentCall && key) later(change($, mode, (s) => dropAgentCall(s, key)))
305      throw error
306    }
307    try {
308      if (isAgentCall && key) {
309        if (r.deny !== undefined || r.isError === true) {
310          // refused or failed: no agent was started, so no row is left
311          await change($, mode, (s) => dropAgentCall(s, key))
312        } else {
313          const record = (r as { result?: unknown }).result
314          const rec = typeof record === 'object' && record !== null ? (record as Record<string, unknown>) : {}
315          const id = typeof rec.agentId === 'string' ? rec.agentId : typeof rec.agent_id === 'string' ? rec.agent_id : null
316          await change($, mode, (s) => finishAgentCall(s, key, id, background))
317        }
318      }
319    } catch {
320      // see above
321    }
322    return r
323  }).catch(($, e, next) => next(e))
324
325  // Summons: record what came back. The result is returned exactly as received.
326  on('tool.call', { tool: /^mcp__(?:plugin_skill-heaven_)?skill-summon__summon$/ }, async ($, e, next) => {
327    const r = await next(e)
328    try {
329      const args = e as unknown as Record<string, unknown>
330      const preview = args.preview === true
331      if (r.deny === undefined && !(lensInFlight && preview)) {
332        const at = isoOf(await $.clock.now())
333        const agent = typeof e.agentId === 'string' ? e.agentId : null
334        const failure = r.isError === true ? ({ isError: true, text: r.text ?? null } as const) : undefined
335        const event = eventFromSummonResult(
336          failure ? undefined : structuredOf(r),
337          { at, agent, preview, query: typeof args.query === 'string' ? args.query : undefined },
338          failure,
339        )
340        await change($, mode, (s) => recordEvent(s, event, agent, 'tool'))
341      }
342    } catch {
343      // see above
344    }
345    return r
346  }).catch(($, e, next) => next(e))
347
348  // Body reads: a Read of a summoned skill's materialized SKILL.md is what puts
349  // it in context. The result is returned exactly as received.
350  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
351    const r = await next(e)
352    try {
353      const args = e as unknown as Record<string, unknown>
354      // a partial read (an offset or a limit) is not the body read
355      const isWhole = (args.offset === undefined || args.offset === null) && (args.limit === undefined || args.limit === null)
356      if (isWhole && r.deny === undefined && r.isError !== true && typeof e.file_path === 'string') {
357        const path = e.file_path
358        const current = await load($)
359        const reader = agentLabelFor(current, typeof e.agentId === 'string' ? e.agentId : null)
360        if (recordRead(current, path, reader) !== current) {
361          await change($, mode, (s) => recordRead(s, path, reader))
362        }
363      }
364    } catch {
365      // see above
366    }
367    return r
368  }).catch(($, e, next) => next(e))
369
370  on('turn.complete', async ($, e, next) => {
371    try {
372      if (typeof e.agentId === 'string') {
373        const id = e.agentId
374        await change($, mode, (s) => agentReturned(s, id))
375      }
376    } catch {
377      // see above
378    }
379    return next(e)
380  })
381
382  // Rung selection: the person typed /skill-heaven, /skill-hell, /skill-ultra or
383  // /skill-zero. The console reads it; the prompt goes on unchanged.
384  on('prompt.submit', async ($, e, next) => {
385    // A subagent's hand-back arrives as a prompt too (origin `peer`, observed on
386    // 2.1.294): it is not the person's next prompt and selects no rung.
387    if ((e as { origin?: { kind?: string } }).origin?.kind === 'peer') return next(e)
388    try {
389      const reading = selectionFromCommand(e.text)
390      await change($, mode, (s) => {
391        const seen = reading ? recordSelection(s, reading, e.text.trim()) : s
392        // The band hides on the next prompt (a /lens in flight keeps its own).
393        return seen.band !== null && seen.band.kind !== 'looking' ? { ...seen, band: null } : seen
394      })
395    } catch {
396      // see above
397    }
398    return next(e)
399  }).catch(($, e, next) => next(e))
400
401  /* ----------------------------------------------------------------------- *
402   * Drawing
403   * ----------------------------------------------------------------------- */
404
405  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
406    if (e.props.hasSurvey) return next(e)
407    const state = await load($)
408    if (state.band === null) return next(e)
409    const actions: BandActions = {
410      inspect: (id) => {
411        later(change($, mode, (s) => ({ ...s, section: 'session', openEntry: id })).then(() => openPane($)))
412      },
413      dismiss: () => {
414        later(change($, mode, (s) => ({ ...s, band: null })))
415      },
416      summon: (name) => {
417        later(fillPrompt($, `/summon ${name}`))
418      },
419    }
420    const tree = renderBand($.ui.resolve(e), state, actions, e.props.bodyColumns)
421    return tree ?? next(e)
422  })
423
424  on('ui.render', { component: 'Pane', requestId: 'skill-heaven' }, async ($, e) => {
425    const state = await load($)
426    const actions: PaneActions = {
427      section: (section) => {
428        later(change($, mode, (s) => ({ ...s, section })))
429      },
430      toggle: (id) => {
431        later(change($, mode, (s) => ({ ...s, openEntry: s.openEntry === id ? null : id })))
432      },
433      fill: (text) => {
434        later(fillPrompt($, text))
435      },
436      copy: (text) => {
437        later(copyText($, text, e.surface))
438      },
439    }
440    return renderPane($.ui.resolve(e), state, actions, e.props.bodyColumns)
441  })
442}
443
hooks/status-model.mjs 828 lines
1// GENERATED by scripts/build-status.mjs from packages/status/src — do not edit.
2// One status model for every projection (docs/CONTROL-PLANE.md).
3// packages/status/src/model.ts
4var RUNGS = ["zero", "low", "med", "high", "xhigh", "max", "ultra"];
5var RUNG_BAND = Object.freeze({
6  zero: "zero",
7  low: "heaven",
8  med: "heaven",
9  high: "hell",
10  xhigh: "hell",
11  max: "hell",
12  ultra: "ultra"
13});
14function bandOf(rung) {
15  return RUNG_BAND[rung];
16}
17function readingRung(reading) {
18  if (reading.kind === "selected") return reading.rung;
19  if (reading.kind === "boot" && reading.posture === "product-floor") return "zero";
20  return null;
21}
22function isRung(value) {
23  return typeof value === "string" && RUNGS.includes(value);
24}
25function emptyStatus() {
26  return {
27    reading: { kind: "unknown" },
28    skills: null,
29    summons: null,
30    lastArrival: null,
31    controller: { kind: "not-selected" },
32    summonTool: "unknown"
33  };
34}
35
36// packages/status/src/sanitize.ts
37var ANSI = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g;
38var CONTROLS = /[\u0000-\u001f\u007f-\u009f]/g;
39var BIDI = /[‎‏‪-‮⁦-⁩]/g;
40var RESERVED = /* @__PURE__ */ new Map([
41  ["\u25C7", "*"],
42  ["\u25C6", "*"],
43  ["\u2039", "<"],
44  ["\u203A", ">"],
45  ["\xD7", "x"]
46]);
47var DEFAULT_FIELD_LIMIT = 48;
48function sanitizeDisplay(value, limit = DEFAULT_FIELD_LIMIT) {
49  if (value === null || value === void 0) return "";
50  let text = String(value).replace(ANSI, "").replace(BIDI, "").replace(CONTROLS, " ");
51  let out = "";
52  for (const ch of text) out += RESERVED.get(ch) ?? ch;
53  text = out.replace(/\s+/g, " ").trim();
54  const chars = Array.from(text);
55  if (limit > 0 && chars.length > limit) return chars.slice(0, Math.max(1, limit - 1)).join("") + "\u2026";
56  return text;
57}
58function cellWidth(text) {
59  let width = 0;
60  for (const ch of text) {
61    const code = ch.codePointAt(0) ?? 0;
62    width += isWide(code) ? 2 : 1;
63  }
64  return width;
65}
66function isWide(code) {
67  return code >= 4352 && code <= 4447 || code >= 11904 && code <= 42191 || code >= 44032 && code <= 55203 || code >= 63744 && code <= 64255 || code >= 65072 && code <= 65103 || code >= 65280 && code <= 65376 || code >= 65504 && code <= 65510 || code >= 127744 && code <= 129791 || code >= 131072 && code <= 262141;
68}
69
70// packages/status/src/tokens.ts
71var ROLE_COLORS = Object.freeze({
72  umbrella: { hex: "#a58ae0", ansi256: 141, ansi16: 35, cssVar: "--sh-violet" },
73  ultra: { hex: "#d9b25c", ansi256: 179, ansi16: 33, cssVar: "--sh-ultra" },
74  zero: { hex: "#5fc2d6", ansi256: 80, ansi16: 36, cssVar: "--sh-zero" },
75  heaven: { hex: "#6f96d8", ansi256: 68, ansi16: 34, cssVar: "--sh-heaven" },
76  hell: { hex: "#e094c8", ansi256: 175, ansi16: 95, cssVar: "--sh-hell" },
77  ink: { hex: "#eeebe6", ansi256: 255, ansi16: 39, cssVar: "--sh-bone" },
78  dim: { hex: "#9a9691", ansi256: 246, ansi16: 90, cssVar: "--sh-dim" },
79  arbor: { hex: "#55c878", ansi256: 77, ansi16: 32, cssVar: "--sh-arbor" },
80  stop: { hex: "#c81e1e", ansi256: 160, ansi16: 31, cssVar: "--sh-stop" },
81  amber: { hex: "#e0b45c", ansi256: 179, ansi16: 33, cssVar: "--sh-amber" }
82});
83var GROUND_HEX = "#1b1a1c";
84
85// packages/status/src/render.ts
86var SEP = { text: " \xB7 ", role: "dim" };
87var seg = (text, role) => ({ text, role });
88function readingToken(reading) {
89  switch (reading.kind) {
90    case "selected":
91      return reading.rung.toUpperCase();
92    case "boot":
93      return reading.posture === "product-floor" ? "ZERO" : reading.posture === "curated" ? "CURATED" : "NATIVE";
94    case "native":
95      return "NATIVE";
96    case "unknown":
97      return "?";
98  }
99}
100function readingRole(reading) {
101  const rung = readingRung(reading);
102  if (!rung) return "ink";
103  const band = RUNG_BAND[rung];
104  return band === "zero" ? "zero" : band === "heaven" ? "heaven" : band === "hell" ? "hell" : "ultra";
105}
106function isUltra(status) {
107  return readingRung(status.reading) === "ultra";
108}
109function skillsText(n, short) {
110  if (n === null) return short ? "?" : "? skills";
111  if (short) return String(n);
112  return `${n} ${n === 1 ? "skill" : "skills"}`;
113}
114function readingSegments(status) {
115  return [
116    seg("\u2039\u2039", "heaven"),
117    seg(" ", "dim"),
118    seg(`[${readingToken(status.reading)}]`, readingRole(status.reading)),
119    seg(" ", "dim"),
120    seg("\u203A\u203A", "hell")
121  ];
122}
123function headSegments(status, withWord) {
124  const ultra = isUltra(status);
125  const out = [];
126  if (status.fixture || status.controller.kind === "fixture") out.push(seg("FIXTURE", "amber"), seg(" ", "dim"));
127  out.push(seg(ultra ? "\u25C6" : "\u25C7", ultra ? "ultra" : "umbrella"));
128  if (withWord) out.push(seg(" entropy", "umbrella"));
129  out.push(seg(" ", "dim"), ...readingSegments(status));
130  return out;
131}
132function controllerSegments(controller, opts = { progress: true }) {
133  switch (controller.kind) {
134    case "not-selected":
135      return [];
136    case "unavailable":
137      return [SEP, seg("controller unavailable", "dim")];
138    case "reported":
139    case "fixture": {
140      const out = [SEP, seg(sanitizeDisplay(controller.state, 24).toUpperCase(), "ultra")];
141      if (controller.effective) {
142        const band = RUNG_BAND[controller.effective];
143        const role = band === "heaven" ? "heaven" : "hell";
144        out.push(SEP, seg("now ", "dim"), seg(controller.effective.toUpperCase(), role), seg(band === "heaven" ? " \u2039" : " \u203A", role));
145      }
146      if (opts.progress && controller.progress) {
147        out.push(SEP, seg(`${controller.progress.completed}/${controller.progress.total}`, "ink"));
148      }
149      return out;
150    }
151  }
152}
153function statusLevels(status, mode) {
154  if (mode === "off") return [];
155  const ultra = isUltra(status);
156  const controller = ultra ? status.controller : { kind: "not-selected" };
157  const skills = [SEP, seg(skillsText(status.skills, false), "ink")];
158  const summons = status.summons === null ? [] : [seg(" / ", "dim"), seg(`${status.summons} ${status.summons === 1 ? "summon" : "summons"}`, "dim")];
159  const arrival = status.lastArrival ? [SEP, seg(`+${sanitizeDisplay(status.lastArrival, 32)}`, "ink")] : [];
160  const full = [
161    [...headSegments(status, true), ...skills, ...summons, ...controllerSegments(controller), ...arrival],
162    [...headSegments(status, true), ...skills, ...summons, ...controllerSegments(controller)],
163    [...headSegments(status, true), ...skills, ...controllerSegments(controller)],
164    [...headSegments(status, true), ...skills, ...controllerSegments(controller, { progress: false })]
165  ];
166  const compact = [
167    [...headSegments(status, true), ...skills],
168    [...headSegments(status, false), SEP, seg(skillsText(status.skills, true), "ink")],
169    [...headSegments(status, false)]
170  ];
171  const bare = [];
172  if (status.fixture || status.controller.kind === "fixture") bare.push(seg("FIXTURE", "amber"), seg(" ", "dim"));
173  bare.push(seg(`[${readingToken(status.reading)}]`, readingRole(status.reading)));
174  return mode === "full" ? [...full, ...compact, bare] : [...compact, bare];
175}
176function segmentsWidth(segments) {
177  return segments.reduce((sum, s) => sum + cellWidth(s.text), 0);
178}
179function renderStatusSegments(status, mode, width) {
180  const levels = statusLevels(status, mode);
181  if (levels.length === 0) return [];
182  if (width === void 0 || !Number.isFinite(width)) return levels[0];
183  return levels.find((level) => segmentsWidth(level) <= width) ?? levels[levels.length - 1];
184}
185function directionGlyph(direction) {
186  if (direction === "converge") return seg("\u2039", "heaven");
187  if (direction === "explore") return seg("\u203A", "hell");
188  return seg("\u25C7", "umbrella");
189}
190function formatScore(value) {
191  if (value === null || !Number.isFinite(value)) return null;
192  const clamped = Math.max(0, Math.min(1, value));
193  return clamped >= 1 ? "1.00" : clamped.toFixed(2).replace(/^0/, "");
194}
195function formatMs(ms) {
196  if (ms === null || !Number.isFinite(ms) || ms < 0) return null;
197  return ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(2)}s`;
198}
199var CLOSE_CALL_MARGIN = 0.1;
200function receiptDetail(skill, delta) {
201  const parts = [];
202  if (skill.matchKind !== "unknown") parts.push(skill.matchKind);
203  const score = formatScore(skill.score);
204  if (score) parts.push(score);
205  const margin = formatScore(skill.margin);
206  if (margin) parts.push(`\u0394 ${margin}`);
207  if (skill.cache !== "unknown") parts.push(skill.cache);
208  const ms = formatMs(skill.ms);
209  if (ms) parts.push(ms);
210  if (delta !== null) parts.push(`+${delta}`);
211  return [seg("  ", "dim"), seg(parts.join(" \xB7 ") || "no ranking disclosure", "dim")];
212}
213function eventLines(event) {
214  const lines = eventLinesInner(event);
215  if (event.evidence === "fixture" && lines[0]) lines[0] = [seg("FIXTURE", "amber"), seg(" ", "dim"), ...lines[0]];
216  return lines;
217}
218function eventLinesInner(event) {
219  switch (event.kind) {
220    case "summoned": {
221      const [first, ...rest] = event.skills;
222      const line1 = [directionGlyph(event.direction), seg(" summoned  ", "dim")];
223      line1.push(seg(first ? sanitizeDisplay(first.name, 40) : "nothing", "ink"));
224      if (rest.length > 0) line1.push(seg(` +${rest.length} more`, "dim"));
225      const line2 = first ? receiptDetail(first, event.delta) : [seg("  ", "dim"), seg("no skill in result", "dim")];
226      if (event.sourceHealth.kind === "stale") line2.push(SEP, seg("? index stale", "amber"));
227      return [line1, line2];
228    }
229    case "previewed": {
230      const [first] = event.skills;
231      const line1 = [seg("\u25C7", "umbrella"), seg(" lens  ", "dim")];
232      if (event.skills.length === 1 && first) line1.push(seg(sanitizeDisplay(first.name, 40), "ink"));
233      else line1.push(seg(`${event.skills.length} candidates`, "ink"));
234      const parts = [seg("  ", "dim"), seg("nothing materialized", "dim")];
235      if (first && event.skills.length > 1) parts.push(SEP, seg(`top ${sanitizeDisplay(first.name, 32)}`, "dim"));
236      if (first?.margin !== null && first?.margin !== void 0 && first.margin < CLOSE_CALL_MARGIN && event.skills.length > 1) {
237        parts.push(SEP, seg(`\u0394 ${formatScore(first.margin)} close call (retrieval)`, "dim"));
238      }
239      return [line1, parts];
240    }
241    case "no-match": {
242      const detail = event.considered === null ? "0 admitted" : `${event.considered} considered \xB7 0 admitted`;
243      const reason = event.reason ? ` \xB7 ${sanitizeDisplay(event.reason, 60)}` : "";
244      const tail = event.preview ? " \xB7 nothing materialized" : "";
245      return [
246        [seg("\u25C7", "umbrella"), seg(event.preview ? " lens  " : " summon  ", "dim"), seg("\xD7", "ink"), seg(" no match", "ink")],
247        [seg("  ", "dim"), seg(detail + reason + tail, "dim")]
248      ];
249    }
250    case "unavailable":
251      return [
252        [seg("\u25C7", "umbrella"), seg(" summon  ", "dim"), seg("?", "amber"), seg(" unavailable", "ink")],
253        [seg("  ", "dim"), seg(sanitizeDisplay(event.reason, 72), "dim")]
254      ];
255    case "error":
256      return [
257        [seg("\u25C7", "umbrella"), seg(" summon  ", "dim"), seg("!", "stop"), seg(" failed", "ink")],
258        [seg("  ", "dim"), seg(sanitizeDisplay(event.reason, 72), "dim")]
259      ];
260  }
261}
262function noticeLines(notice) {
263  if (notice.kind === "looking") {
264    return [
265      [seg("\u25C7", "umbrella"), seg(" lens  ", "dim"), seg("looking\u2026", "ink")],
266      [seg("  ", "dim"), seg(`${sanitizeDisplay(notice.query, 48)} \xB7 nothing materialized`, "dim")]
267    ];
268  }
269  return [
270    [seg("\u25C7", "umbrella"), seg(" summon  ", "dim"), seg("?", "amber"), seg(" not connected", "ink")],
271    [seg("  ", "dim"), seg("the summon tool is not available in this session", "dim")]
272  ];
273}
274function describeReading(reading) {
275  switch (reading.kind) {
276    case "selected":
277      return `${reading.rung}, the rung you selected (not enforced)`;
278    case "boot":
279      return reading.posture === "product-floor" ? "zero, the Skill Zero floor set at launch" : reading.posture === "curated" ? "curated, set at launch" : "native, your harness's own skills";
280    case "native":
281      return "native, your harness's own skills";
282    case "unknown":
283      return "unknown";
284  }
285}
286function describeStatus(status) {
287  const parts = [`Skill entropy reading: ${describeReading(status.reading)}.`];
288  parts.push(
289    status.skills === null ? "Skills in this session: unknown." : `${status.skills} ${status.skills === 1 ? "skill" : "skills"} materialized in this session.`
290  );
291  if (isUltra(status)) {
292    const c = status.controller;
293    if (c.kind === "unavailable") parts.push("Ultra controller: unavailable; nothing is choosing for you yet.");
294    else if (c.kind === "reported" || c.kind === "fixture") {
295      parts.push(`Ultra controller: ${sanitizeDisplay(c.state, 24).toLowerCase()}${c.effective ? `, working at ${c.effective}` : ""}.`);
296    }
297  }
298  if (status.fixture || status.controller.kind === "fixture") parts.unshift("Fixture, design state only.");
299  return parts.join(" ");
300}
301function describeEvent(event) {
302  const text = describeEventInner(event);
303  return event.evidence === "fixture" ? `Fixture, design state only. ${text}` : text;
304}
305function describeEventInner(event) {
306  const how = event.direction === "converge" ? "converging" : event.direction === "explore" ? "exploring" : event.direction === "manual" ? "by hand" : "";
307  switch (event.kind) {
308    case "summoned": {
309      const names = event.skills.map((s) => sanitizeDisplay(s.name, 40)).join(", ") || "nothing";
310      return `Summoned ${names}${how ? `, ${how}` : ""}. A card was returned; ${event.delta} added to the session.`;
311    }
312    case "previewed":
313      return `Lens preview: ${event.skills.length} candidate${event.skills.length === 1 ? "" : "s"}; nothing materialized.`;
314    case "no-match":
315      return event.preview ? "Lens preview found no match. Nothing was materialized." : "Summon found no match. Nothing was added to the session.";
316    case "unavailable":
317      return `Summon unavailable: ${sanitizeDisplay(event.reason, 72)}.`;
318    case "error":
319      return `Summon failed: ${sanitizeDisplay(event.reason, 72)}.`;
320  }
321}
322function resolveColorDepth(env, isTTY = true) {
323  if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return "none";
324  if (!isTTY && env.FORCE_COLOR === void 0) return "none";
325  const term = env.TERM ?? "";
326  if (term === "dumb") return "none";
327  const colorterm = (env.COLORTERM ?? "").toLowerCase();
328  if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor";
329  if (term.includes("256")) return "256";
330  return "16";
331}
332function sgr(role, depth) {
333  const c = ROLE_COLORS[role];
334  if (depth === "truecolor") {
335    const n = parseInt(c.hex.slice(1), 16);
336    return `\x1B[38;2;${n >> 16 & 255};${n >> 8 & 255};${n & 255}m`;
337  }
338  if (depth === "256") return `\x1B[38;5;${c.ansi256}m`;
339  return `\x1B[${c.ansi16}m`;
340}
341function paintAnsi(segments, depth) {
342  if (depth === "none") return toPlain(segments);
343  return segments.map((s) => s.role === "dim" && s.text.trim() === "" ? s.text : `${sgr(s.role, depth)}${s.text}\x1B[39m`).join("");
344}
345function toPlain(segments) {
346  return segments.map((s) => s.text).join("");
347}
348
349// packages/status/src/adapters.ts
350var isRec = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
351var str = (v) => typeof v === "string" && v.length > 0 ? v : null;
352var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
353function readingFromProfileManifest(manifest) {
354  if (!isRec(manifest) || manifest.schema !== "claude-zero/profile@1") return { kind: "unknown" };
355  const posture = manifest.posture;
356  if (posture === "product-floor" || posture === "curated" || posture === "native") {
357    return { kind: "boot", posture, source: "launcher-manifest" };
358  }
359  return { kind: "unknown" };
360}
361var BAND_COMMANDS = [
362  { command: "skill-zero", rungs: ["zero"], fallback: "zero" },
363  { command: "skill-heaven", rungs: ["low", "med"], fallback: "low" },
364  { command: "skill-hell", rungs: ["high", "xhigh", "max"], fallback: "high" },
365  { command: "skill-ultra", rungs: ["ultra"], fallback: "ultra" }
366];
367function selectionFromCommand(text) {
368  const match = /^\s*\/(?:[a-z0-9-]+:)?(skill-zero|skill-heaven|skill-hell|skill-ultra)(?:\s+(\S+))?\s*$/i.exec(text);
369  if (!match) return null;
370  const entry = BAND_COMMANDS.find((b) => b.command === match[1].toLowerCase());
371  const arg = match[2]?.toLowerCase();
372  if (!arg) return { kind: "selected", rung: entry.fallback, source: "observed-command" };
373  if (entry.command === "skill-zero") return arg === "all" ? { kind: "selected", rung: "zero", source: "observed-command" } : null;
374  if (isRung(arg) && entry.rungs.includes(arg)) return { kind: "selected", rung: arg, source: "observed-command" };
375  return null;
376}
377function withReading(status, reading) {
378  const ultra = reading.kind === "selected" && reading.rung === "ultra";
379  const boot = status.boot ?? (status.reading.kind === "selected" || status.reading.kind === "unknown" ? void 0 : status.reading);
380  const controller = ultra ? status.controller.kind === "reported" ? status.controller : { kind: "unavailable" } : { kind: "not-selected" };
381  return { ...status, reading, controller, ...boot ? { boot } : {} };
382}
383function skillsFromSessionManifest(manifest) {
384  if (!isRec(manifest) || !Array.isArray(manifest.skills)) return { skills: null, last: null };
385  const entries = manifest.skills.filter((s) => isRec(s) && typeof s.id === "string");
386  if (entries.length !== manifest.skills.length) return { skills: null, last: null };
387  const last = entries.length > 0 ? entries[entries.length - 1] : null;
388  return { skills: entries.length, last: last ? str(last.name) ?? str(last.id) : null };
389}
390function directionFromSurface(surface) {
391  if (surface === "any") return "manual";
392  if (surface === "heaven") return "converge";
393  if (surface === "hell") return "explore";
394  return "unspecified";
395}
396function laneOf(skill) {
397  if (skill.invocation === "human") return "human-led";
398  if (skill.invocation === "model") return "model-led";
399  if (skill.invocation === "any") return "unspecified";
400  return "unknown";
401}
402function receiptFrom(skill, stage, agent) {
403  const retrieval = isRec(skill.retrieval) ? skill.retrieval : {};
404  const matchKind = retrieval.matchKind === "exact" || retrieval.matchKind === "ranked" ? retrieval.matchKind : "unknown";
405  const cacheRaw = skill.cacheState ?? skill.cache;
406  const totalSeconds = num(skill.totalSeconds);
407  const installability = isRec(skill.installability) ? str(skill.installability.state) ?? "unknown" : "unknown";
408  return {
409    id: str(skill.id) ?? "unknown",
410    name: str(skill.name) ?? str(skill.id) ?? "unknown",
411    stage,
412    matchKind,
413    score: num(retrieval.score) ?? num(skill.score),
414    margin: num(retrieval.margin) ?? num(skill.margin),
415    cache: cacheRaw === "cold" || cacheRaw === "warm" ? cacheRaw : "unknown",
416    ms: totalSeconds === null ? null : Math.round(totalSeconds * 1e3),
417    lane: laneOf(skill),
418    source: str(skill.source),
419    repoUrl: str(skill.repoUrl),
420    ref: str(skill.branch),
421    sourceUrl: str(skill.sourceUrl),
422    subpath: str(skill.subpath),
423    sha256: str(skill.sha256),
424    path: str(skill.path),
425    installability,
426    agent
427  };
428}
429function sourceHealthOf(result) {
430  const ranking = isRec(result.ranking) ? result.ranking : null;
431  if (!ranking || typeof ranking.stale !== "boolean") return { kind: "unknown" };
432  const age = num(ranking.indexAgeDays);
433  const indexAgeDays = age === null ? null : Math.floor(age);
434  return ranking.stale ? { kind: "stale", indexAgeDays } : { kind: "fresh", indexAgeDays };
435}
436function arborOf(result, summoned) {
437  const arbor = isRec(result.arbor) ? result.arbor : null;
438  if (!arbor) return "unknown";
439  if (arbor.publicationState === "unavailable" || arbor.publicationState === "unreadable") return "unavailable";
440  if (arbor.publicationState !== "loaded") return "unknown";
441  return summoned.some((s) => isRec(s.arbor) && s.arbor.join === "content-pinned") ? "governed-record" : "no-record";
442}
443var NO_MATCH_REASON = {
444  no_candidates: "no candidates in the source",
445  below_floor: "nothing cleared the relevance floor",
446  all_filtered: "every candidate was outside this lane"
447};
448function eventFromSummonResult(structured, call = {}, failure) {
449  const at = call.at ?? null;
450  const agent = call.agent ?? null;
451  if (failure?.isError) {
452    const text = failure.text ?? "the summon tool reported an error";
453    const unavailable = /unresolvable|unreachable|ENOTFOUND|EAI_AGAIN|fetch failed|network|offline/i.test(text);
454    return {
455      kind: unavailable ? "unavailable" : "error",
456      direction: "unspecified",
457      query: call.query ?? "",
458      preview: call.preview === true,
459      reason: text,
460      at,
461      evidence: "observed"
462    };
463  }
464  if (!isRec(structured)) {
465    return {
466      kind: "error",
467      direction: "unspecified",
468      query: call.query ?? "",
469      preview: call.preview === true,
470      reason: "the summon result had no structured content",
471      at,
472      evidence: "observed"
473    };
474  }
475  const direction = directionFromSurface(structured.surface);
476  const query = str(structured.query) ?? call.query ?? "";
477  const sourceHealth = sourceHealthOf(structured);
478  if (structured.noMatch !== null && structured.noMatch !== void 0) {
479    const noMatch = isRec(structured.noMatch) ? structured.noMatch : {};
480    const reason = typeof noMatch.reason === "string" ? NO_MATCH_REASON[noMatch.reason] ?? null : null;
481    return { kind: "no-match", direction, query, preview: call.preview === true, considered: null, reason, at, evidence: "reported", sourceHealth };
482  }
483  const summoned = Array.isArray(structured.summoned) ? structured.summoned.filter(isRec) : [];
484  const previewed = Array.isArray(structured.previewed) ? structured.previewed.filter(isRec) : [];
485  if (summoned.length === 0 && (previewed.length > 0 || call.preview === true)) {
486    return {
487      kind: "previewed",
488      direction,
489      query,
490      skills: previewed.map((s) => receiptFrom(s, "previewed", agent)),
491      delta: 0,
492      preview: true,
493      at,
494      evidence: "reported",
495      sourceHealth
496    };
497  }
498  const skipped = Array.isArray(structured.skipped) ? structured.skipped : [];
499  if (summoned.length === 0 && skipped.length > 0) {
500    const first = skipped[0];
501    const why = isRec(first) ? str(first.why) ?? str(first.reason) ?? str(first.error) : str(first);
502    return {
503      kind: "error",
504      direction,
505      query,
506      preview: call.preview === true,
507      reason: why ? `nothing materialized: ${why}` : "nothing materialized: every candidate was skipped",
508      at,
509      evidence: "reported"
510    };
511  }
512  const composition = isRec(structured.composition) && structured.composition.mode === "relevance-only" ? "relevance-only" : "unknown";
513  return {
514    kind: "summoned",
515    direction,
516    query,
517    skills: summoned.map((s) => receiptFrom(s, "materialized", agent)),
518    delta: summoned.length,
519    preview: false,
520    at,
521    evidence: "reported",
522    composition,
523    arbor: arborOf(structured, summoned),
524    sourceHealth
525  };
526}
527function reduceStatus(status, event) {
528  if (event.kind === "previewed" || event.kind !== "summoned" && event.preview) return status;
529  const summons = status.summons === null ? null : status.summons + 1;
530  if (event.kind !== "summoned") return { ...status, summons };
531  const skills = status.skills === null ? null : status.skills + event.delta;
532  const last = event.skills[event.skills.length - 1];
533  return { ...status, skills, summons, lastArrival: last ? last.name : status.lastArrival };
534}
535function markRead(event, readPath) {
536  if (event.kind !== "summoned") return event;
537  const norm = readPath.replace(/\\/g, "/");
538  let changed = false;
539  const skills = event.skills.map((s) => {
540    if (!s.path || s.stage === "in-context") return s;
541    let root = s.path.replace(/\\/g, "/");
542    while (root.endsWith("/")) root = root.slice(0, -1);
543    if (norm === `${root}/SKILL.md`) {
544      changed = true;
545      return { ...s, stage: "in-context" };
546    }
547    return s;
548  });
549  return changed ? { ...event, skills } : event;
550}
551var STEERING_DECISIONS = {
552  hold: "HOLD",
553  explore: "EXPLORE",
554  recover: "RECOVER",
555  reopen: "REOPEN",
556  checkpoint: "CHECKPOINT",
557  close: "CLOSE",
558  stop: "STOP"
559};
560function controllerFromSteeringEntry(entry) {
561  if (!isRec(entry) || typeof entry.decision !== "string") return null;
562  const state = STEERING_DECISIONS[entry.decision];
563  if (!state) return null;
564  const to = isRec(entry.to) ? entry.to : null;
565  const rung = to && isRung(to.rung) && to.rung !== "zero" && to.rung !== "ultra" ? to.rung : void 0;
566  const from = isRec(entry.from) && typeof entry.from.rung === "string" ? entry.from.rung : null;
567  const explanation = str(entry.explanation);
568  return {
569    kind: "reported",
570    source: "steering-trace",
571    state,
572    ...rung ? { effective: rung } : {},
573    ...from && explanation && entry.changed === true ? { transition: { from: from.toUpperCase(), to: state, reason: explanation } } : {}
574  };
575}
576var PROPOSED_CONTROLLER_SCHEMA = "skill-heaven/controller-status@0";
577function controllerFromCampaignStatus(value) {
578  if (!isRec(value) || value.schema !== PROPOSED_CONTROLLER_SCHEMA) return null;
579  const allowed = /* @__PURE__ */ new Set(["schema", "state", "effective", "progress", "transition"]);
580  if (Object.keys(value).some((k) => !allowed.has(k))) return null;
581  if (typeof value.state !== "string" || !/^[A-Z][A-Z0-9 ·_-]{0,31}$/.test(value.state)) return null;
582  const out = { kind: "reported", source: "campaign", state: value.state };
583  if (value.effective !== void 0) {
584    if (!isRung(value.effective) || value.effective === "zero" || value.effective === "ultra") return null;
585    out.effective = value.effective;
586  }
587  if (value.progress !== void 0) {
588    const p = value.progress;
589    if (!isRec(p) || !Number.isInteger(p.completed) || !Number.isInteger(p.total)) return null;
590    const completed = p.completed;
591    const total = p.total;
592    if (completed < 0 || total < 1 || completed > total) return null;
593    out.progress = { completed, total };
594  }
595  if (value.transition !== void 0) {
596    const t = value.transition;
597    if (!isRec(t) || typeof t.from !== "string" || typeof t.to !== "string" || typeof t.reason !== "string") return null;
598    out.transition = { from: t.from, to: t.to, reason: t.reason };
599  }
600  return out;
601}
602
603// packages/status/src/compat.ts
604var AGENT_PLUGIN_INSTALL = {
605  posix: "curl -fsSL https://gaia-research.github.io/gaia-skill-heaven/install-agent-plugin.sh | sh",
606  windows: "irm https://gaia-research.github.io/gaia-skill-heaven/install-agent-plugin.ps1 | iex",
607  root: "$HOME/.local/share/gaia-skill-heaven-agent-plugin",
608  marketplace: "$HOME/.local/share/gaia-skill-heaven-agent-plugin/marketplace",
609  plugin: "$HOME/.local/share/gaia-skill-heaven-agent-plugin/marketplace/plugins/skill-heaven",
610  uninstall: "$HOME/.local/share/gaia-skill-heaven-agent-plugin/uninstall.sh"
611};
612var LAUNCHER_INSTALL = {
613  posix: "curl -fsSL https://gaia-research.github.io/gaia-skill-heaven/install.sh | sh",
614  windows: "irm https://gaia-research.github.io/gaia-skill-heaven/install.ps1 | iex",
615  uninstall: "$HOME/.local/share/gaia-skill-heaven/uninstall.sh"
616};
617var REPO = "https://github.com/gaia-research/gaia-skill-heaven/blob/main";
618var HARNESS_PATHS = [
619  {
620    id: "claude",
621    name: "Claude Code",
622    bin: "claude",
623    chip: "verified",
624    probedVersion: "2.1.288",
625    evidence: "Fresh-install receipt, 76/76 checks, GitHub main @ fdfd94e.",
626    evidenceHref: `${REPO}/docs/RELEASE-ACCEPTANCE.md`,
627    needsInstaller: false,
628    commands: [
629      "/plugin marketplace add gaia-research/gaia-skill-heaven",
630      "/plugin install skill-heaven@gaia-skill-heaven"
631    ],
632    inHarness: true,
633    blocked: null,
634    update: "claude plugin marketplace update gaia-skill-heaven && claude plugin update skill-heaven@gaia-skill-heaven",
635    remove: ["claude plugin uninstall skill-heaven@gaia-skill-heaven"],
636    statusIntegration: "APPEND",
637    statusNote: "The optional console (preview) adds a status entry beside yours \u2014 it never replaces your statusLine. Probed live in the terminal on 2.1.294; its desktop paint is not probed.",
638    launcher: "claude-zero",
639    // 2.1.294 refuses a bare /summon: it resolves to the portable skill, which is user-invocable: false.
640    firstRun: "/skill-heaven:summon <what you need>"
641  },
642  {
643    id: "codex",
644    name: "Codex",
645    bin: "codex",
646    chip: "compatible",
647    probedVersion: "0.161.0",
648    evidence: "Live smoke on 0.161.0: plugin added, codex mcp list resolves skill-summon, a summon call completed. Headless codex exec asks approval for MCP calls. Surfaces last probed on 0.146.0.",
649    evidenceHref: `${REPO}/plugins/skill-heaven/PROBE.md`,
650    needsInstaller: true,
651    commands: [
652      `codex plugin marketplace add "${AGENT_PLUGIN_INSTALL.marketplace}"`,
653      "codex plugin add skill-heaven@gaia-skill-heaven"
654    ],
655    inHarness: false,
656    blocked: null,
657    update: "Re-run the installer, then reinstall in Codex \u2014 Codex keeps its own cached copy.",
658    remove: ["codex plugin remove skill-heaven@gaia-skill-heaven", AGENT_PLUGIN_INSTALL.uninstall],
659    statusIntegration: "UNSUPPORTED",
660    statusNote: "Codex has no public status contribution API. Summon receipts still print in the transcript.",
661    launcher: "codex-zero",
662    firstRun: "/summon <what you need>"
663  },
664  {
665    id: "pi",
666    name: "Pi",
667    bin: "pi",
668    chip: "compatible",
669    probedVersion: "1.0.4",
670    evidence: "Live probe on 1.0.4: pi install, five commands, summon materialized a skill, the four surfaces expanded. The adapter keeps its own summon tool rather than Pi's MCP runtime, which an MCP extension can replace.",
671    evidenceHref: `${REPO}/plugins/skill-heaven/dev.skill-heaven.pi/PROBE.md`,
672    needsInstaller: true,
673    commands: [`pi install "${AGENT_PLUGIN_INSTALL.plugin}" --approve`],
674    inHarness: false,
675    blocked: null,
676    update: "Re-run the installer, then pi update.",
677    remove: [`pi remove "${AGENT_PLUGIN_INSTALL.plugin}"`, AGENT_PLUGIN_INSTALL.uninstall],
678    statusIntegration: "NATIVE SLOT",
679    statusNote: "Pi's status API takes the canonical entropy line (probed on 1.0.4, appended under its own key); the plugin does not draw it yet. The pi-zero extension draws a widget.",
680    launcher: "pi-zero",
681    firstRun: "/summon <what you need>"
682  },
683  {
684    id: "grok",
685    name: "Grok",
686    bin: "grok",
687    chip: "compatible",
688    probedVersion: "1.0.5",
689    evidence: "Live probe on 1.0.5. On 1.0.46, grok plugin install and details find the skills, commands and MCP server (static: no signed-in session to run a summon).",
690    evidenceHref: `${REPO}/plugins/skill-heaven/PROBE.md`,
691    needsInstaller: true,
692    commands: [`grok plugin install "${AGENT_PLUGIN_INSTALL.plugin}" --trust`],
693    inHarness: false,
694    blocked: null,
695    update: "Re-run the installer, then grok plugin update.",
696    remove: ["grok plugin uninstall skill-heaven", AGENT_PLUGIN_INSTALL.uninstall],
697    statusIntegration: "UNSUPPORTED",
698    statusNote: "A command-backed status line is possible on Grok but not built.",
699    launcher: "grok-zero",
700    firstRun: "/summon <what you need>"
701  },
702  {
703    id: "hermes",
704    name: "Hermes",
705    bin: "hermes",
706    chip: "compatible",
707    probedVersion: "0.20.0",
708    evidence: "Live probe, repeated on 0.20.0 (2026-10-08): plugin installed and enabled, a summon call completed.",
709    evidenceHref: `${REPO}/plugins/skill-heaven/PROBE.md`,
710    needsInstaller: true,
711    commands: [`hermes plugins install "file://${AGENT_PLUGIN_INSTALL.plugin}" --enable`],
712    inHarness: false,
713    blocked: null,
714    update: "Re-run the installer, then reinstall in Hermes. Remove the Hermes registration with Hermes's own plugin manager.",
715    // Hermes's own removal command was not verified on the machine that wrote
716    // this table (its `--help` could not run), so none is printed.
717    remove: [AGENT_PLUGIN_INSTALL.uninstall],
718    statusIntegration: "UNSUPPORTED",
719    statusNote: "Hermes has no stable public status contribution. Receipts print in the transcript.",
720    launcher: "hermes-zero",
721    firstRun: "/summon <what you need>"
722  },
723  {
724    id: "agy",
725    name: "Antigravity",
726    bin: "agy",
727    chip: "compatible",
728    probedVersion: "1.3.1",
729    evidence: "Live probe on 1.3.1 with your real HOME: agy plugin install loads the skills, commands and the summon server; /skill-heaven:summon and the four surfaces ran.",
730    evidenceHref: `${REPO}/packages/agy-zero/PROBE.md`,
731    needsInstaller: true,
732    commands: [`agy plugin install "${AGENT_PLUGIN_INSTALL.plugin}"`],
733    inHarness: false,
734    blocked: null,
735    update: "Re-run the installer, then agy plugin uninstall skill-heaven and install it again \u2014 Antigravity keeps its own copy.",
736    remove: ["agy plugin uninstall skill-heaven", AGENT_PLUGIN_INSTALL.uninstall],
737    statusIntegration: "UNSUPPORTED",
738    statusNote: "A stacked status command is the target; not built.",
739    launcher: "agy-zero",
740    // Antigravity namespaces plugin skills: its / menu lists /skill-heaven:summon, and a bare /summon is not a command.
741    firstRun: "/skill-heaven:summon <what you need>"
742  },
743  {
744    id: "other",
745    name: "Another Agent Plugins client",
746    bin: null,
747    chip: "unverified",
748    probedVersion: null,
749    evidence: "The package follows Agent Plugins 1.0.0. Your client owns registration; it has not been probed here.",
750    evidenceHref: "https://agent-plugins.org/specification",
751    needsInstaller: true,
752    commands: [],
753    inHarness: false,
754    blocked: `Point your client's own plugin install at ${AGENT_PLUGIN_INSTALL.plugin}. There is no universal registration command.`,
755    update: "Re-run the installer; your client may keep its own cached copy.",
756    remove: [AGENT_PLUGIN_INSTALL.uninstall],
757    statusIntegration: "UNSUPPORTED",
758    statusNote: "Depends on your client.",
759    launcher: null,
760    firstRun: "/summon <what you need>, or the spelling your client lists for the skill-heaven plugin"
761  }
762];
763var CHIP_LABEL = {
764  verified: "Verified",
765  compatible: "Compatible",
766  partial: "Partial",
767  unverified: "Unverified",
768  "needs-local-probe": "Needs local probe"
769};
770var CHIP_MEANING = {
771  verified: "A fresh install was verified end to end on the pinned version.",
772  compatible: "A live probe passed on the pinned version. Yours may differ.",
773  partial: "Part of the plugin loads; something is proven missing.",
774  unverified: "Not probed. The package is portable; this client is untested.",
775  "needs-local-probe": "Could not be established without a logged-in session."
776};
777function harnessById(id) {
778  const found = HARNESS_PATHS.find((h) => h.id === id);
779  if (!found) throw new Error(`unknown harness ${id}`);
780  return found;
781}
782export {
783  AGENT_PLUGIN_INSTALL,
784  CHIP_LABEL,
785  CHIP_MEANING,
786  CLOSE_CALL_MARGIN,
787  DEFAULT_FIELD_LIMIT,
788  GROUND_HEX,
789  HARNESS_PATHS,
790  LAUNCHER_INSTALL,
791  PROPOSED_CONTROLLER_SCHEMA,
792  ROLE_COLORS,
793  RUNGS,
794  RUNG_BAND,
795  bandOf,
796  cellWidth,
797  controllerFromCampaignStatus,
798  controllerFromSteeringEntry,
799  controllerSegments,
800  describeEvent,
801  describeReading,
802  describeStatus,
803  directionFromSurface,
804  emptyStatus,
805  eventFromSummonResult,
806  eventLines,
807  formatMs,
808  formatScore,
809  harnessById,
810  isRung,
811  markRead,
812  noticeLines,
813  paintAnsi,
814  readingFromProfileManifest,
815  readingRung,
816  readingToken,
817  reduceStatus,
818  renderStatusSegments,
819  resolveColorDepth,
820  sanitizeDisplay,
821  segmentsWidth,
822  selectionFromCommand,
823  skillsFromSessionManifest,
824  statusLevels,
825  toPlain,
826  withReading
827};
828
hooks/meta.ts 16 lines
1// What the Trust section states about the installed code. A mod cannot read its
2// own manifest, so these are written here and a repository test holds each to
3// the version in its plugin.json.
4
5/** plugins/skill-heaven/.claude-plugin/plugin.json `version`. */
6export const SKILL_HEAVEN_VERSION = '0.1.2'
7
8/** plugins/skill-heaven-console/.claude-plugin/plugin.json `version`. */
9export const CONSOLE_VERSION = '0.1.0'
10
11/** The pane's id, the status values' plugin name. */
12export const PANE_ID = 'skill-heaven'
13
14/** What a logged-in probe established about this console (PR #187), for Trust. */
15export const CONSOLE_PROBE = 'the console was probed live in the terminal on 2.1.294; desktop paint is not probed'
16
hooks/model.ts 347 lines
1// The console's pure logic: how observed facts fold into session state.
2//
3// No `$`, no engine, no I/O here. Every function takes a ConsoleState and
4// returns the next one; the hooks in register.tsx do the observing and the
5// engine does the keeping. The meaning of every field comes from the one status
6// model (status-model.mjs), never from this file.
7
8import {
9  emptyStatus,
10  markRead,
11  reduceStatus,
12  sanitizeDisplay,
13  withReading,
14} from './status-model.mjs'
15import type { EntropyReading, SkillHeavenStatus, SkillReceipt, SummonEvent } from './status-model.mjs'
16import type { ConsoleAgent, ConsoleBand, ConsoleEntryData, ConsoleSection, ConsoleStateData } from '../types'
17
18export type { ConsoleAgent, ConsoleBand, ConsoleSection }
19
20/** The state contract (types/index.d.ts) keeps `status` and `event` opaque because
21 * the engine wants that file self-contained; these are their real types, and
22 * `fromData` / `toData` are the only place the two meet. */
23export type ConsoleEntry = Omit<ConsoleEntryData, 'event'> & { event: SummonEvent }
24export type ConsoleState = Omit<ConsoleStateData, 'status' | 'entries'> & {
25  status: SkillHeavenStatus
26  entries: ConsoleEntry[]
27}
28export const fromData = (data: ConsoleStateData): ConsoleState => data as unknown as ConsoleState
29export const toData = (state: ConsoleState): ConsoleStateData => state as unknown as ConsoleStateData
30
31export const MAX_ENTRIES = 50
32/** Agents kept in Flow, newest last. */
33export const MAX_AGENTS = 100
34/** Skills stored per entry; the rest are counted in `omitted`. */
35export const MAX_SKILLS_PER_ENTRY = 20
36
37/** The summon tool, however the session spells it:
38 * `mcp__plugin_skill-heaven_skill-summon__summon` (marketplace install) or
39 * `mcp__skill-summon__summon` (launcher). Anchored at both ends: a server that
40 * merely ends in "skill-summon" (`mcp__evil-skill-summon__summon`) is not it.
41 * register.tsx spells the same pattern as a literal in its tool.call matcher;
42 * a repository test holds the two equal. */
43export const SUMMON_TOOL = /^mcp__(?:plugin_skill-heaven_)?skill-summon__summon$/
44export const MARKETPLACE_SUMMON_TOOL = 'mcp__plugin_skill-heaven_skill-summon__summon'
45export const LAUNCHER_SUMMON_TOOL = 'mcp__skill-summon__summon'
46
47/** The console watches the whole session, so zero is honest at the start. */
48export function initialState(): ConsoleState {
49  return {
50    status: {
51      ...emptyStatus(),
52      reading: { kind: 'native', source: 'no-launcher' },
53      skills: 0,
54      summons: 0,
55      summonTool: 'unknown',
56    },
57    entries: [],
58    band: null,
59    agents: [],
60    agentIdsSeen: false,
61    section: 'session',
62    openEntry: null,
63    selectedFrom: null,
64    hostVersion: null,
65    seq: 0,
66  }
67}
68
69/* ------------------------------------------------------------------------- *
70 * Reading a tool result
71 * ------------------------------------------------------------------------- */
72
73type Rec = Record<string, unknown>
74const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v)
75
76const looksLikeSummon = (v: unknown): v is Rec =>
77  isRec(v) && ('summoned' in v || 'previewed' in v || 'noMatch' in v)
78
79/**
80 * The summon tool's `structuredContent`, wherever the host put it.
81 *
82 * Observed on Claude Code 2.1.294 (PR #187, a `tool.call` hook and the console's
83 * own `$.tool.call`): an MCP tool's result is `{ ref, result, text }` where
84 * `result` is a STRING — the server's JSON text block — and `text` is the same
85 * text; `structuredContent` appears nowhere. The object forms stay as
86 * fallbacks for a host that does pass it through.
87 */
88export function structuredOf(r: unknown): unknown {
89  if (!isRec(r)) return undefined
90  const result = r.result
91  if (isRec(result)) {
92    if (looksLikeSummon(result.structuredContent)) return result.structuredContent
93    if (looksLikeSummon(result)) return result
94  }
95  if (looksLikeSummon(r.structuredContent)) return r.structuredContent
96  const texts: string[] = []
97  if (typeof r.text === 'string') texts.push(r.text)
98  if (typeof result === 'string' && result !== r.text) texts.push(result)
99  if (isRec(result) && Array.isArray(result.content)) {
100    for (const block of result.content) {
101      if (isRec(block) && typeof block.text === 'string') texts.push(block.text)
102    }
103  }
104  for (const text of texts) {
105    try {
106      const parsed: unknown = JSON.parse(text)
107      if (looksLikeSummon(parsed)) return parsed
108    } catch {
109      // not all JSON: the object may be followed by resource-link text
110    }
111    const embedded = firstJsonObject(text)
112    if (looksLikeSummon(embedded)) return embedded
113  }
114  return undefined
115}
116
117/** The first balanced `{ ... }` in `text` that parses as JSON. Strings are skipped
118 * over so a brace inside one does not unbalance the scan. */
119export function firstJsonObject(text: string): unknown {
120  for (let start = text.indexOf('{'); start !== -1; start = text.indexOf('{', start + 1)) {
121    let depth = 0
122    let inString = false
123    for (let i = start; i < text.length; i++) {
124      const ch = text[i]
125      if (inString) {
126        if (ch === '\\') i++
127        else if (ch === '"') inString = false
128      } else if (ch === '"') inString = true
129      else if (ch === '{') depth++
130      else if (ch === '}' && --depth === 0) {
131        try {
132          return JSON.parse(text.slice(start, i + 1))
133        } catch {
134          break // not JSON from here; try the next opening brace
135        }
136      }
137    }
138  }
139  return undefined
140}
141
142/* ------------------------------------------------------------------------- *
143 * Agents (Flow)
144 * ------------------------------------------------------------------------- */
145
146const shortId = (id: string) => sanitizeDisplay(id, 12)
147
148/** A label for an agent the host named only by id. */
149export function agentLabelFor(state: ConsoleState, id: string | null | undefined): string {
150  if (id === null || id === undefined) return 'main agent'
151  const row = state.agents.find((a) => a.id === id)
152  return row ? row.label : `agent ${shortId(id)}`
153}
154
155/** subagent type + the call's own description, both sanitized (they are the model's words). */
156export function agentCallLabel(subagentType: unknown, description: unknown): string {
157  const type = typeof subagentType === 'string' && subagentType ? sanitizeDisplay(subagentType, 24) : 'agent'
158  const desc = typeof description === 'string' && description ? sanitizeDisplay(description, 48) : ''
159  return desc ? `${type}  "${desc}"` : type
160}
161
162/** The host reported an agent id on a call: remember that it did, and the agent. */
163export function noteAgentId(state: ConsoleState, id: string): ConsoleState {
164  if (state.agents.some((a) => a.id === id)) return state.agentIdsSeen ? state : { ...state, agentIdsSeen: true }
165  // An Agent call that has not been given its id yet and is still open: leave it for finishAgentCall.
166  const agents: ConsoleAgent[] = [
167    ...state.agents,
168    { key: id, id, label: `agent ${shortId(id)}`, state: 'running' as const },
169  ].slice(-MAX_AGENTS)
170  return { ...state, agentIdsSeen: true, agents }
171}
172
173export function startAgentCall(state: ConsoleState, key: string, label: string, background: boolean): ConsoleState {
174  if (state.agents.some((a) => a.key === key)) return state
175  return {
176    ...state,
177    agents: [...state.agents, { key, id: null, label, state: background ? ('started' as const) : ('running' as const) }].slice(-MAX_AGENTS),
178  }
179}
180
181/** The Agent call was refused or failed: it started nothing, so it leaves no row. */
182export function dropAgentCall(state: ConsoleState, key: string): ConsoleState {
183  return state.agents.some((a) => a.key === key) ? { ...state, agents: state.agents.filter((a) => a.key !== key) } : state
184}
185
186/** The Agent call's `next` resolved. The result may carry the new agent's id. */
187export function finishAgentCall(state: ConsoleState, key: string, agentId: string | null, background: boolean): ConsoleState {
188  // The agent may have shown up by id (a stub row) before its call resolved: keep one, labelled row.
189  const agents = state.agents
190    .filter((a) => agentId === null || a.key !== agentId || a.key === key)
191    .map((a) =>
192      a.key === key ? { ...a, id: agentId ?? a.id, state: background ? ('started' as const) : ('returned' as const) } : a,
193    )
194  return { ...state, agents, agentIdsSeen: state.agentIdsSeen || agentId !== null }
195}
196
197/** The agent's own loop finished (its `turn.complete`). */
198export function agentReturned(state: ConsoleState, agentId: string): ConsoleState {
199  if (!state.agents.some((a) => a.id === agentId && a.state !== 'returned')) return state
200  return { ...state, agents: state.agents.map((a) => (a.id === agentId ? { ...a, state: 'returned' as const } : a)) }
201}
202
203/* ------------------------------------------------------------------------- *
204 * Events
205 * ------------------------------------------------------------------------- */
206
207/** Keep a bounded number of skills per entry; the rest are counted, not stored. */
208function bounded(event: SummonEvent): { event: SummonEvent; omitted: number } {
209  if ((event.kind === 'summoned' || event.kind === 'previewed') && event.skills.length > MAX_SKILLS_PER_ENTRY) {
210    return { event: { ...event, skills: event.skills.slice(0, MAX_SKILLS_PER_ENTRY) }, omitted: event.skills.length - MAX_SKILLS_PER_ENTRY }
211  }
212  return { event, omitted: 0 }
213}
214
215/**
216 * Fold one observed summon-tool call into state. `reached` is false when the
217 * call never got an answer from the tool (a /lens preview that was aborted or
218 * refused): that says nothing about whether the tool is connected.
219 */
220export function recordEvent(
221  state: ConsoleState,
222  event: SummonEvent,
223  agent: string | null,
224  via: ConsoleEntry['via'],
225  reached = true,
226): ConsoleState {
227  const id = state.seq + 1
228  const kept = bounded(event)
229  const entry: ConsoleEntry = {
230    id,
231    event: kept.event,
232    omitted: kept.omitted,
233    agent,
234    via,
235    readBy: 'skills' in kept.event ? kept.event.skills.map(() => null) : [],
236  }
237  const entries = [...state.entries, entry].slice(-MAX_ENTRIES)
238  const withAgent = agent !== null ? noteAgentId(state, agent) : state
239  const status = reduceStatus(state.status, event)
240  return {
241    ...withAgent,
242    status: reached ? { ...status, summonTool: 'connected' } : status,
243    entries,
244    band: { kind: 'event', id },
245    seq: id,
246  }
247}
248
249/** A Read of a path: any summoned skill whose materialized SKILL.md it is, is now in context. */
250export function recordRead(state: ConsoleState, path: string, reader: string): ConsoleState {
251  let changed = false
252  const entries = state.entries.map((entry) => {
253    const next = markRead(entry.event, path)
254    if (next === entry.event) return entry
255    changed = true
256    const readBy = [...entry.readBy]
257    if (next.kind === 'summoned' && entry.event.kind === 'summoned') {
258      const before = entry.event.skills
259      next.skills.forEach((skill, i) => {
260        if (skill.stage === 'in-context' && before[i]?.stage !== 'in-context') readBy[i] = reader
261      })
262    }
263    return { ...entry, event: next, readBy }
264  })
265  return changed ? { ...state, entries } : state
266}
267
268export function recordSelection(state: ConsoleState, reading: EntropyReading, typed: string): ConsoleState {
269  return { ...withStatusReading(state, reading), selectedFrom: sanitizeDisplay(typed, 40) }
270}
271
272function withStatusReading(state: ConsoleState, reading: EntropyReading): ConsoleState {
273  return { ...state, status: withReading(state.status, reading) }
274}
275
276export function entryById(state: ConsoleState, id: number): ConsoleEntry | undefined {
277  return state.entries.find((e) => e.id === id)
278}
279
280/* ------------------------------------------------------------------------- *
281 * What the person sees about an entry
282 * ------------------------------------------------------------------------- */
283
284export interface StageText {
285  text: string
286  /** True when the claim is derived by this UI (no read seen), not observed. */
287  inferred: boolean
288}
289
290/** §2.3 — the card versus context distinction, in the console's own words. */
291export function stageText(entry: ConsoleEntry): StageText {
292  const event = entry.event
293  if (event.kind !== 'summoned') return { text: 'nothing materialized', inferred: false }
294  const skills = event.skills
295  const inContext = skills.filter((s) => s.stage === 'in-context')
296  if (skills.length > 0 && inContext.length === skills.length) {
297    const readers = Array.from(new Set(skills.map((_, i) => entry.readBy[i] ?? 'an agent')))
298    return { text: `in context · body read by ${readers.join(', ')}`, inferred: false }
299  }
300  if (inContext.length > 0) {
301    return { text: `${inContext.length} of ${skills.length} in context · the rest: card returned · body not read`, inferred: false }
302  }
303  return { text: 'card returned · body not read', inferred: true }
304}
305
306/** A name that may be put in the prompt after the summon command: plain words
307 * only. Tree skills carry display names with spaces ("Frontend Code Review",
308 * observed live), which are safe and are what the engine matches exactly. */
309export function summonableName(skill: SkillReceipt | undefined): string | null {
310  if (!skill) return null
311  const name = sanitizeDisplay(skill.name, 64)
312  return /^[A-Za-z0-9][A-Za-z0-9._:/-]*(?: [A-Za-z0-9._:/-]+)*$/.test(name) ? name : null
313}
314
315/** The five Skill Heaven surfaces a pre-fill may name. */
316const SURFACE_COMMANDS = new Set(['summon', 'skill-zero', 'skill-heaven', 'skill-hell', 'skill-ultra'])
317
318/**
319 * The spelling of a Skill Heaven command that this session accepts, for a
320 * pre-fill. Observed on Claude Code 2.1.294: a bare `/skill-zero` (or
321 * `/skill-hell high`) resolves to the plugin's portable skill, which is
322 * `user-invocable: false`, and the host refuses it ("can only be invoked by
323 * Claude"); the plugin-qualified `/skill-heaven:skill-zero`, the spelling the
324 * `/` menu shows, reaches the command. Always the one plugin's own name: a
325 * look-alike `other:summon` is never filled.
326 */
327export function qualifyCommand(text: string): string {
328  const m = /^\/([a-z-]+)(\s.*)?$/.exec(text)
329  if (!m || !SURFACE_COMMANDS.has(m[1]!)) return text
330  return `/skill-heaven:${m[1]!}${m[2] ?? ''}`
331}
332
333/** §5.2: Summon is offered for a /lens preview with exactly one candidate. */
334export function summonSuggestion(entry: ConsoleEntry): string | null {
335  const event = entry.event
336  if (event.kind !== 'previewed' || event.skills.length !== 1) return null
337  return summonableName(event.skills[0])
338}
339
340export function timeLabel(at: string | null): string {
341  if (!at) return ''
342  const d = new Date(at)
343  if (Number.isNaN(d.getTime())) return ''
344  const two = (n: number) => String(n).padStart(2, '0')
345  return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
346}
347
hooks/views.tsx 557 lines
1// Drawing: the Lens band and the /heaven pane, as trees of the surface's own
2// elements. Pure functions of state — no `$`, no writes. A press is handed back
3// to the hook that drew the tree through the `actions` objects below, so this
4// file cannot change anything by itself.
5//
6// Every string that came from the network or from a model (skill names, queries,
7// sources, reasons, agent labels) reaches the screen through the status model's
8// sanitizer or its renderers. Colour is never the only carrier of meaning: each
9// state keeps its glyph and its word.
10
11import type { Elements, RenderElement } from 'claude-code'
12
13import {
14  CHIP_LABEL,
15  HARNESS_PATHS,
16  ROLE_COLORS,
17  eventLines,
18  formatMs,
19  formatScore,
20  noticeLines,
21  readingRung,
22  readingToken,
23  renderStatusSegments,
24  sanitizeDisplay,
25} from './status-model.mjs'
26import type { EvidenceClass, Role, Segment, SkillReceipt } from './status-model.mjs'
27import { CONSOLE_PROBE, CONSOLE_VERSION, SKILL_HEAVEN_VERSION } from './meta.ts'
28import { agentLabelFor, entryById, qualifyCommand, stageText, summonSuggestion, timeLabel } from './model.ts'
29import type { ConsoleAgent, ConsoleEntry, ConsoleSection, ConsoleState } from './model.ts'
30
31/** The elements every surface that draws a band or a pane has in common. */
32export type UI = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button'>
33
34export interface BandActions {
35  inspect: (id: number) => void
36  dismiss: () => void
37  /** Pre-fills `/summon <name>` into the prompt. The person submits it. */
38  summon: (name: string) => void
39}
40
41export interface PaneActions {
42  section: (section: ConsoleSection) => void
43  toggle: (id: number) => void
44  /** Pre-fills a command into the prompt. The person submits it. */
45  fill: (text: string) => void
46  copy: (text: string) => void
47}
48
49/* ------------------------------------------------------------------------- *
50 * Colour: roles from the one palette. `ink` is the theme's own text colour,
51 * `dim` is the theme's dim; every other role is its palette hex.
52 * ------------------------------------------------------------------------- */
53
54type StyleProps = { color?: string; dimColor?: boolean; italic?: boolean; bold?: boolean }
55
56function styleOf(role: Role): StyleProps {
57  if (role === 'ink') return {}
58  if (role === 'dim') return { dimColor: true }
59  return { color: ROLE_COLORS[role].hex }
60}
61
62function runs(ui: UI, segments: readonly Segment[], extra: StyleProps = {}): RenderElement {
63  const { Text } = ui
64  return (
65    <Text>
66      {segments.map((s) => (
67        <Text {...styleOf(s.role)} {...extra}>
68          {s.text}
69        </Text>
70      ))}
71    </Text>
72  )
73}
74
75/* ------------------------------------------------------------------------- *
76 * The Lens band — empty by default (the hook returns next(e) for null).
77 * ------------------------------------------------------------------------- */
78
79export function renderBand(ui: UI, state: ConsoleState, actions: BandActions, columns: number): RenderElement | null {
80  const { Box, Text, Button } = ui
81  const band = state.band
82  if (band === null) return null
83  const width = Math.max(20, columns)
84
85  if (band.kind === 'looking' || band.kind === 'not-connected') {
86    const [first, second] = noticeLines(band.kind === 'looking' ? { kind: 'looking', query: band.query } : { kind: 'not-connected' })
87    return (
88      <Box flexDirection="column" width={width}>
89        {first ? runs(ui, first) : null}
90        {second ? runs(ui, second) : null}
91        <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => actions.dismiss()} />
92      </Box>
93    )
94  }
95
96  const entry = entryById(state, band.id)
97  if (!entry) return null
98  const [line1, line2] = eventLines(entry.event)
99  const stage = stageText(entry)
100  const name = summonSuggestion(entry)
101  return (
102    <Box flexDirection="column" width={width}>
103      {line1 ? runs(ui, line1) : null}
104      {line2 ? runs(ui, line2) : null}
105      {/* a preview's second line already says "nothing materialized" (seen twice live on 2.1.294) */}
106      {entry.event.kind === 'previewed' || (entry.event.kind === 'no-match' && entry.event.preview) ? null : (
107        <Text key="stage" dimColor italic={stage.inferred}>
108          {'  '}
109          {stage.text}
110          {stage.inferred ? ' (inferred)' : ''}
111        </Text>
112      )}
113      <Box flexDirection="row" gap={1}>
114        {name !== null ? <Button key="summon" label="Summon" variant="primary" onPress={() => actions.summon(name)} /> : null}
115        <Button key="inspect" label="Inspect" onPress={() => actions.inspect(entry.id)} />
116        <Button key="dismiss" label="Dismiss" role="dismiss" onPress={() => actions.dismiss()} />
117      </Box>
118    </Box>
119  )
120}
121
122/* ------------------------------------------------------------------------- *
123 * The /heaven pane
124 * ------------------------------------------------------------------------- */
125
126const SECTIONS: ReadonlyArray<{ id: ConsoleSection; label: string; hotkey: string }> = [
127  { id: 'session', label: 'Session', hotkey: '1' },
128  { id: 'scope', label: 'Scope', hotkey: '2' },
129  { id: 'flow', label: 'Flow', hotkey: '3' },
130  { id: 'trust', label: 'Trust', hotkey: '4' },
131]
132
133export function renderPane(ui: UI, state: ConsoleState, actions: PaneActions, columns: number): RenderElement {
134  const { Box, Text, Button } = ui
135  const width = Math.max(24, columns)
136  return (
137    <Box flexDirection="column" width={width} gap={1}>
138      <Box flexDirection="column">
139        <Text>
140          <Text bold>Skill Heaven</Text>
141          <Text color={ROLE_COLORS.amber.hex}>{'  PREVIEW'}</Text>
142          <Text dimColor>{'  console · observes, never changes'}</Text>
143        </Text>
144        {runs(ui, renderStatusSegments(state.status, 'full'))}
145      </Box>
146      <Box flexDirection="row" gap={1} flexWrap="wrap">
147        {SECTIONS.map((s) => (
148          <Button
149            key={`section-${s.id}`}
150            label={s.label}
151            hotkey={s.hotkey}
152            variant={state.section === s.id ? 'primary' : 'secondary'}
153            onPress={() => actions.section(s.id)}
154          />
155        ))}
156      </Box>
157      {state.section === 'session' ? sessionSection(ui, state, actions) : null}
158      {state.section === 'scope' ? scopeSection(ui, state, actions) : null}
159      {state.section === 'flow' ? flowSection(ui, state) : null}
160      {state.section === 'trust' ? trustSection(ui, state) : null}
161    </Box>
162  )
163}
164
165/* -- evidence-labelled rows ------------------------------------------------ */
166
167interface Field {
168  name: string
169  value: string | null
170  evidence: EvidenceClass
171}
172
173const FIELD_WIDTH = 16
174
175function fieldRow(ui: UI, f: Field, key: string): RenderElement {
176  const { Box, Text } = ui
177  const known = f.value !== null && f.value !== '' && f.evidence !== 'unknown'
178  const label = f.evidence === 'inferred' ? 'inferred' : f.evidence === 'unknown' || !known ? 'unknown' : f.evidence
179  return (
180    <Box key={key} flexDirection="row" flexWrap="wrap">
181      <Text dimColor>{f.name.padEnd(FIELD_WIDTH)}</Text>
182      <Text italic={f.evidence === 'inferred'}>{known ? f.value : '—'}</Text>
183      <Text dimColor>{`  ${label}`}</Text>
184    </Box>
185  )
186}
187
188const DIRECTION_WORD: Record<string, string> = {
189  manual: 'any (explicit /summon)',
190  converge: 'heaven (converge)',
191  explore: 'hell (explore)',
192  unspecified: 'unspecified',
193}
194
195const LANE_WORD: Record<string, string> = {
196  'human-led': 'human-led (Skill Heaven lane)',
197  'model-led': 'model-led (Skill Hell lane)',
198  unspecified: 'unspecified (the source did not classify it)',
199}
200
201function healthText(entry: ConsoleEntry): string | null {
202  const event = entry.event
203  if (!('sourceHealth' in event)) return null
204  const h = event.sourceHealth
205  if (h.kind === 'unknown') return null
206  const age = h.indexAgeDays === null ? '' : ` · index ${h.indexAgeDays} days old`
207  return h.kind === 'stale' ? `? stale${age} · ranking still ran` : `fresh${age}`
208}
209
210function skillFields(skill: SkillReceipt, index: number, entry: ConsoleEntry): Field[] {
211  const stage: Field =
212    skill.stage === 'in-context'
213      ? { name: 'what entered', value: `body read (in context) by ${entry.readBy[index] ?? 'an agent'}`, evidence: 'observed' }
214      : skill.stage === 'materialized'
215        ? { name: 'what entered', value: 'card returned · body not read', evidence: 'inferred' }
216        : skill.stage === 'previewed'
217          ? { name: 'what entered', value: 'previewed · nothing materialized', evidence: 'reported' }
218          : { name: 'what entered', value: 'materialized · read not observed', evidence: 'inferred' }
219  const rank: string[] = []
220  if (skill.matchKind !== 'unknown') rank.push(skill.matchKind)
221  const score = formatScore(skill.score)
222  if (score) rank.push(score)
223  const margin = formatScore(skill.margin)
224  if (margin) rank.push(`Δ ${margin} (margin is a retrieval diagnostic)`)
225  const from: string[] = []
226  if (skill.source) from.push(sanitizeDisplay(skill.source, 48))
227  if (skill.repoUrl) from.push(sanitizeDisplay(skill.repoUrl, 64) + (skill.ref ? `@${sanitizeDisplay(skill.ref, 24)}` : ''))
228  if (skill.subpath) from.push(sanitizeDisplay(skill.subpath, 48))
229  if (skill.sha256) from.push(`sha256 ${sanitizeDisplay(skill.sha256, 12)}…`)
230  const cache: string[] = []
231  if (skill.cache !== 'unknown') cache.push(skill.cache)
232  const ms = formatMs(skill.ms)
233  if (ms) cache.push(ms)
234  const installability =
235    skill.installability === 'unknown' ? null : sanitizeDisplay(skill.installability, 40)
236  return [
237    stage,
238    { name: 'ranking', value: rank.length ? rank.join(' · ') : null, evidence: rank.length ? 'reported' : 'unknown' },
239    { name: 'from', value: from.length ? from.join(' · ') : null, evidence: from.length ? 'reported' : 'unknown' },
240    {
241      name: 'lane',
242      value: LANE_WORD[skill.lane] ?? null,
243      evidence: LANE_WORD[skill.lane] ? 'reported' : 'unknown',
244    },
245    {
246      name: 'installability',
247      value: installability,
248      evidence: installability ? 'reported' : 'unknown',
249    },
250    { name: 'cache', value: cache.length ? cache.join(' · ') : null, evidence: cache.length ? 'reported' : 'unknown' },
251    { name: 'on disk', value: skill.path ? sanitizeDisplay(skill.path, 96) : null, evidence: skill.path ? 'reported' : 'unknown' },
252  ]
253}
254
255function receiptFields(entry: ConsoleEntry, state: ConsoleState): Field[][] {
256  const event = entry.event
257  const common: Field[] = []
258  const query = 'query' in event && event.query ? `query "${sanitizeDisplay(event.query, 80)}" · surface ${DIRECTION_WORD[event.direction] ?? 'unspecified'}` : null
259  const health = healthText(entry)
260  const agent: Field = {
261    name: 'agent',
262    value: agentLabelFor(state, entry.agent),
263    evidence: 'observed',
264  }
265  if (event.kind === 'summoned' || event.kind === 'previewed') {
266    const groups: Field[][] = []
267    common.push({ name: 'why', value: query, evidence: query ? 'reported' : 'unknown' })
268    if (event.kind === 'summoned') {
269      const comp =
270        event.composition === 'relevance-only'
271          ? `relevance-only · Arbor: ${event.arbor === 'governed-record' ? 'governed record' : event.arbor === 'no-record' ? 'no governed record' : event.arbor}`
272          : null
273      common.push({ name: 'composition', value: comp, evidence: comp ? 'reported' : 'unknown' })
274    }
275    common.push({ name: 'source health', value: health, evidence: health ? 'reported' : 'unknown' }, agent)
276    groups.push(common)
277    event.skills.slice(0, 3).forEach((skill, i) =>
278      groups.push([{ name: 'skill', value: sanitizeDisplay(skill.name, 48), evidence: 'reported' }, ...skillFields(skill, i, entry)]),
279    )
280    const unshown = event.skills.length - 3 + entry.omitted
281    if (unshown > 0) groups.push([{ name: 'more', value: `+${unshown} more not shown here`, evidence: 'observed' }])
282    return groups
283  }
284  if (event.kind === 'no-match') {
285    return [
286      [
287        { name: 'what entered', value: 'nothing materialized', evidence: 'reported' },
288        { name: 'why', value: query, evidence: query ? 'reported' : 'unknown' },
289        {
290          name: 'refusal',
291          value:
292            (event.considered === null ? '0 admitted' : `${event.considered} considered · 0 admitted`) +
293            (event.reason ? ` · ${sanitizeDisplay(event.reason, 60)}` : ''),
294          evidence: 'reported',
295        },
296        { name: 'source health', value: health, evidence: health ? 'reported' : 'unknown' },
297        agent,
298      ],
299    ]
300  }
301  return [
302    [
303      { name: 'what entered', value: 'nothing materialized', evidence: 'reported' },
304      { name: 'reason', value: sanitizeDisplay(event.reason, 120), evidence: 'observed' },
305      agent,
306    ],
307  ]
308}
309
310/* -- Session ----------------------------------------------------------------- */
311
312function sessionSection(ui: UI, state: ConsoleState, actions: PaneActions): RenderElement {
313  const { Box, Text, Button } = ui
314  const rows = [...state.entries].reverse()
315  return (
316    <Box flexDirection="column" gap={1}>
317      {state.status.summonTool === 'not-connected' ? (
318        <Text>
319          <Text color={ROLE_COLORS.amber.hex}>{'? '}</Text>
320          <Text>{'summon tool: not connected'}</Text>
321          <Text dimColor>{`  /summon and /lens cannot reach the Skill Heaven plugin. ${HARNESS_PATHS.find((h) => h.id === 'claude')?.commands[1] ?? ''}`}</Text>
322        </Text>
323      ) : null}
324      {rows.length === 0 ? (
325        <Text>
326          {'Nothing summoned yet. Try '}
327          <Text bold>{`${qualifyCommand('/summon')} <need>`}</Text>
328          {' or '}
329          <Text bold>{'/lens <need>'}</Text>
330          {'.'}
331        </Text>
332      ) : null}
333      {rows.map((entry) => {
334        const lines = eventLines(entry.event)
335        const open = state.openEntry === entry.id
336        const meta = [timeLabel(entry.event.at), agentLabelFor(state, entry.agent), entry.via === 'lens' ? '/lens' : ''].filter(Boolean).join(' · ')
337        return (
338          <Box key={`entry-${entry.id}`} flexDirection="column">
339            {lines[0] ? runs(ui, [...lines[0], { text: meta ? `   ${meta}` : '', role: 'dim' }]) : null}
340            {lines[1] ? runs(ui, lines[1]) : null}
341            <Button
342              key={`toggle-${entry.id}`}
343              label={open ? 'Hide receipt' : 'Receipt'}
344              plain
345              dimColor
346              onPress={() => actions.toggle(entry.id)}
347            />
348            {open
349              ? (
350                  <Box flexDirection="column" marginLeft={2} gap={1}>
351                    {receiptFields(entry, state).map((group, gi) => (
352                      <Box key={`group-${gi}`} flexDirection="column">
353                        {group.map((f, fi) => fieldRow(ui, f, `f-${gi}-${fi}`))}
354                      </Box>
355                    ))}
356                  </Box>
357                )
358              : null}
359          </Box>
360        )
361      })}
362      <Text dimColor>
363        {'Evidence: observed = this console saw it · reported = the summon engine said so · inferred = derived here · unknown = no source.'}
364      </Text>
365    </Box>
366  )
367}
368
369/* -- Scope ------------------------------------------------------------------- */
370
371export const ULTRA_COPY =
372  'Skill Ultra · provisioned. Controller unavailable — no controller is choosing direction or depth yet. Ultra is the rung you selected; each summon is still judged per use. The long-horizon controller is tracked in #126 and is not yet empirically validated.'
373
374function latestSource(state: ConsoleState): { source: string | null; health: string | null; unavailable: boolean } {
375  const last = state.entries[state.entries.length - 1]
376  const unavailable = last?.event.kind === 'unavailable'
377  for (let i = state.entries.length - 1; i >= 0; i--) {
378    const e = state.entries[i]!
379    const event = e.event
380    if ('skills' in event) {
381      const withSource = event.skills.find((s) => s.source)
382      if (withSource?.source) return { source: sanitizeDisplay(withSource.source, 56), health: healthText(e), unavailable }
383    }
384  }
385  return { source: null, health: last ? healthText(last) : null, unavailable }
386}
387
388function scopeSection(ui: UI, state: ConsoleState, actions: PaneActions): RenderElement {
389  const { Box, Text, Button } = ui
390  const { source, health, unavailable } = latestSource(state)
391  const reading = state.status.reading
392  const boot = state.status.boot ?? (reading.kind === 'selected' ? ({ kind: 'native', source: 'no-launcher' } as const) : reading)
393  const rung = reading.kind === 'selected' ? reading.rung : null
394  const ultra = readingRung(reading) === 'ultra'
395  const skills = state.status.skills
396  const fields: Field[] = [
397    {
398      name: 'can see',
399      value: unavailable
400        ? `skill source ${source ?? ''} · unreachable`.replace('  ', ' ')
401        : source
402          ? `skill source ${source}${health ? ` · ${health}` : ''}`
403          : null,
404      evidence: source || unavailable ? 'reported' : 'unknown',
405    },
406    {
407      name: 'active',
408      value:
409        skills === null
410          ? null
411          : `${skills} ${skills === 1 ? 'skill' : 'skills'} materialized this session (temporary — gone when the session ends)`,
412      evidence: skills === null ? 'unknown' : 'observed',
413    },
414    {
415      name: 'inherited',
416      value:
417        boot.kind === 'native' || boot.kind === 'unknown'
418          ? `boot reading ${readingToken(boot)} — no launcher observed; this console cannot see how the session was started`
419          : `boot reading ${readingToken(boot)} — set at launch`,
420      evidence: boot.kind === 'native' || boot.kind === 'unknown' ? 'inferred' : 'reported',
421    },
422    {
423      name: 'selected',
424      value:
425        rung === null
426          ? 'none — no rung selected in this session'
427          : ultra
428            ? 'rung ULTRA · controller unavailable'
429            : `rung ${rung.toUpperCase()} — observed from ${state.selectedFrom ?? 'a rung command'} · not enforced`,
430      evidence: rung === null ? 'unknown' : 'observed',
431    },
432    { name: 'allowed', value: '/summon by hand: yes · zero cut: temporary (default)', evidence: 'reported' },
433    { name: 'keep small', value: '/skill-zero cuts temporary skills · claude-zero --level zero starts clean', evidence: 'reported' },
434  ]
435  return (
436    <Box flexDirection="column" gap={1}>
437      <Box flexDirection="column">{fields.map((f, i) => fieldRow(ui, f, `scope-${i}`))}</Box>
438      {rung !== null && !ultra ? (
439        <Text dimColor>{`You selected ${rung.toUpperCase()}. Skill Heaven does not enforce a rung; each summon is still judged per use.`}</Text>
440      ) : null}
441      {ultra ? <Text color={ROLE_COLORS.ultra.hex}>{ULTRA_COPY}</Text> : null}
442      <Box flexDirection="column">
443        <Text bold>Choose a rung</Text>
444        <Text dimColor>These fill your prompt. You press Enter; nothing runs until you do.</Text>
445        <Box flexDirection="row" gap={1} flexWrap="wrap">
446          <Button key="fill-heaven" label={qualifyCommand('/skill-heaven low')} onPress={() => actions.fill('/skill-heaven low')} />
447          <Button key="fill-hell" label={qualifyCommand('/skill-hell high')} onPress={() => actions.fill('/skill-hell high')} />
448          <Button key="fill-ultra" label={qualifyCommand('/skill-ultra')} onPress={() => actions.fill('/skill-ultra')} />
449        </Box>
450        <Text dimColor>{`Other rungs: ${qualifyCommand('/skill-heaven med')} · ${qualifyCommand('/skill-hell xhigh')} · ${qualifyCommand('/skill-hell max')}`}</Text>
451      </Box>
452      <Box flexDirection="column">
453        <Text bold>Keep context small</Text>
454        <Box flexDirection="row" gap={1} flexWrap="wrap">
455          <Button key="fill-zero" label={qualifyCommand('/skill-zero')} onPress={() => actions.fill('/skill-zero')} />
456          <Button key="fill-zero-all" label={qualifyCommand('/skill-zero all')} onPress={() => actions.fill('/skill-zero all')} />
457          <Button key="copy-clean" label="Copy: claude-zero --level zero" onPress={() => actions.copy('claude-zero --level zero')} />
458        </Box>
459        <Text dimColor>Start clean is run in a terminal before a session: it is copied, not run.</Text>
460      </Box>
461    </Box>
462  )
463}
464
465/* -- Flow -------------------------------------------------------------------- */
466
467const MAX_AGENT_ROWS = 12
468
469function agentLine(state: ConsoleState, agent: ConsoleAgent): string {
470  const mine = state.entries.filter((e) => e.agent !== null && e.agent === agent.id && e.event.kind === 'summoned')
471  const skills = mine.reduce((n, e) => n + (e.event.kind === 'summoned' ? e.event.delta : 0), 0)
472  const word = agent.state === 'started' ? 'started in background' : agent.state
473  return `${word} · ${mine.length === 0 ? 'no skills' : `${mine.length} ${mine.length === 1 ? 'summon' : 'summons'}`}${skills > 0 && skills !== mine.length ? ` (${skills} skills)` : ''}`
474}
475
476function flowSection(ui: UI, state: ConsoleState): RenderElement {
477  const { Box, Text } = ui
478  const mainSummons = state.entries.filter((e) => e.agent === null && e.event.kind === 'summoned')
479  const mainInContext = mainSummons.reduce(
480    (n, e) => n + (e.event.kind === 'summoned' ? e.event.skills.filter((s) => s.stage === 'in-context').length : 0),
481    0,
482  )
483  const shown = state.agents.slice(0, MAX_AGENT_ROWS)
484  const more = state.agents.length - shown.length
485  return (
486    <Box flexDirection="column" gap={1}>
487      <Box flexDirection="column">
488        <Text>
489          <Text bold>main</Text>
490          <Text dimColor>{`   ${mainSummons.length} ${mainSummons.length === 1 ? 'summon' : 'summons'} · ${mainInContext} in context`}</Text>
491        </Text>
492        {shown.map((agent, i) => {
493          const last = i === shown.length - 1 && more <= 0
494          return (
495            <Text>
496              <Text dimColor>{last ? '└─ ' : '├─ '}</Text>
497              <Text>{sanitizeDisplay(agent.label, 64)}</Text>
498              <Text dimColor>{`   ${agentLine(state, agent)}`}</Text>
499            </Text>
500          )
501        })}
502        {more > 0 ? (
503          <Text dimColor>{`└─ +${more} more`}</Text>
504        ) : null}
505      </Box>
506      {!state.agentIdsSeen ? (
507        <Text dimColor>
508          {'This host did not report agent ids. Summons are attributed to main.'}
509        </Text>
510      ) : null}
511      {state.entries.length >= 50 ? <Text dimColor>Counts cover the last 50 events.</Text> : null}
512      <Text dimColor>Read-only. Only agents the host reported appear.</Text>
513    </Box>
514  )
515}
516
517/* -- Trust ------------------------------------------------------------------- */
518
519function trustSection(ui: UI, state: ConsoleState): RenderElement {
520  const { Box, Text } = ui
521  const claude = HARNESS_PATHS.find((h) => h.id === 'claude')!
522  const mcp = state.status.summonTool
523  const mcpText =
524    mcp === 'connected' ? 'connected (a summon result was seen)' : mcp === 'not-connected' ? 'not connected' : 'unknown until the first summon or /lens'
525  const row = (name: string, value: string, key: string) => (
526    <Box key={key} flexDirection="row" flexWrap="wrap">
527      <Text dimColor>{name.padEnd(FIELD_WIDTH)}</Text>
528      <Text>{value}</Text>
529    </Box>
530  )
531  return (
532    <Box flexDirection="column" gap={1}>
533      <Box flexDirection="column">
534        {row('skill-heaven', `built for ${SKILL_HEAVEN_VERSION} · gaia-research/gaia-skill-heaven · bundles one MCP server (skill-summon)`, 't1')}
535        {row('console (preview)', `${CONSOLE_VERSION} · same repository · runs inside Claude Code as local code`, 't2')}
536      </Box>
537      <Box flexDirection="column" marginLeft={2}>
538        {row('reads', 'summon tool results · your /skill-* commands · Read/Agent tool calls (to observe, never to change)', 't3')}
539        {row('writes', 'nothing to disk · session-only $.state', 't4')}
540        {row('command text', '/lens and /heaven print a one-line result that the model can read. It is fixed text and carries nothing a skill source supplied.', 't4b')}
541        {row('network', "none of its own; /lens calls the bundled summon tool, which fetches the skill source", 't5')}
542        {row('disable', '/plugin disable skill-heaven-console — the status entry, band and pane disappear; Skill Heaven is unchanged', 't6')}
543      </Box>
544      <Box flexDirection="column">
545        {row('summon tool', `MCP: ${mcpText}`, 't7')}
546        {row(
547          'harness',
548          `Claude Code ${state.hostVersion ? sanitizeDisplay(state.hostVersion, 24) : '(version not reported)'} · ${CHIP_LABEL[claude.chip]} for the plugin at ${claude.probedVersion ?? 'unknown'}; ${CONSOLE_PROBE}`,
549          't8',
550        )}
551        {row('evidence', claude.evidence, 't9')}
552      </Box>
553      <Text dimColor>This lists what the code can do. It does not rate it.</Text>
554    </Box>
555  )
556}
557
types/index.d.ts 72 lines
1// The console's session state contract: the one `$.state` value its drawings read.
2//
3// The engine requires this file to be self-contained (no import), so the status
4// model's own types cannot be named here. `status` and each entry's `event` are
5// therefore opaque plain data (`ConsoleData`) at this seam; hooks/model.ts gives
6// them their real types — SkillHeavenStatus and SummonEvent from the one status
7// model — in exactly one place, and nothing else casts.
8
9/** Plain, JSON-shaped data. */
10export type ConsoleData = string | number | boolean | null | ConsoleData[] | { [key: string]: ConsoleData }
11
12export type ConsoleSection = 'session' | 'scope' | 'flow' | 'trust'
13
14/** One summon-tool call the console observed (a real summon, or a /lens preview). */
15export type ConsoleEntryData = {
16  id: number
17  /** A SummonEvent (status model). */
18  event: ConsoleData
19  /** Which agent the host reported for the call; null = the main conversation. */
20  agent: string | null
21  /** How the console came to know: a tool call it watched, or its own /lens preview. */
22  via: 'tool' | 'lens'
23  /** One slot per stored skill: who read its materialized SKILL.md (a label, never a path), or null. */
24  readBy: (string | null)[]
25  /** Skills the result named beyond the bounded number stored here. */
26  omitted: number
27}
28
29/** What the Lens band shows. null = nothing (the default). */
30export type ConsoleBand =
31  | { kind: 'event'; id: number }
32  | { kind: 'looking'; query: string }
33  | { kind: 'not-connected' }
34  | null
35
36/** An agent the host reported (an `agentId` on a tool call, or an Agent call). */
37export type ConsoleAgent = {
38  /** The Agent call's tool_use_id until the host names the agent, then its agentId. */
39  key: string
40  /** The host's agent id, when it has been reported. */
41  id: string | null
42  /** Sanitized: subagent type and the call's own description. */
43  label: string
44  /** running = a foreground call is open; started = launched in the background, end not observed; returned = observed to finish. */
45  state: 'running' | 'started' | 'returned'
46}
47
48export type ConsoleStateData = {
49  /** A SkillHeavenStatus (status model). */
50  status: ConsoleData
51  /** Newest last, at most 50. */
52  entries: ConsoleEntryData[]
53  band: ConsoleBand
54  agents: ConsoleAgent[]
55  /** True once the host reported any agent id this session. */
56  agentIdsSeen: boolean
57  section: ConsoleSection
58  /** The receipt row that is expanded in the Session section. */
59  openEntry: number | null
60  /** The rung command the person typed, sanitized, for the Scope section. */
61  selectedFrom: string | null
62  /** Claude Code's version string, when the host reported it. */
63  hostVersion: string | null
64  seq: number
65}
66
67declare module 'claude-code' {
68  interface PluginState {
69    'skill-heaven-console': { state: Shaped<ConsoleStateData> }
70  }
71}
72