Status line with context, cost and rate limits, plus a live state-machine map and query-plan style trace for reactive skill runs.

Event-Driven Hierarchical State Machine Engine and Immutable Event Store for Agentic Skills
🚀 What's New in v0.19.0:
- New
reactive-skills-axi vet <skill|dir>statically checks a skill's guard code, text, and dependencies for risky patterns before you run it. It is the first part of the trust work; a trust record and a hardened mode are still to come.validatecatches a guard file in the wrong module format before it runs, and<command> --helpnow works on every command.invokerejects a wrappedcontextUpdatespayload and shows the expected shape;jobsandresetfind runs by skill path or name.- Judgments can limit what the AI judge sees with
context_pathsandinclude_payload; a legacy mixed run store migrates instead of crashinginvoke.
Conventional agent skills are static markdown instruction files (SKILL.md). An LLM reads all instructions upfront, enters an unstructured execution loop, and guesses its next steps without state verification or deterministic progress guarantees.
Reactive Skills upgrade passive agent skills into Hierarchical State Machines (HSM) driven by a reactive Event/Signal Bus:
REQUIREMENTS_GATHERED, TESTS_PASSED, USER_APPROVED).true (preventing hallucinated completion).events.jsonl + SQLite events.db). Documentation, PR bodies, and review matrices are live read-model projections rendered from the event stream.This repository is a monorepo containing:
@reactive-skills/runtime: The core TypeScript statechart engine, SQLite event store driver, guard evaluator, and stdio MCP server.@reactive-skills/axi: The Agent eXperience Interface (AXI) CLI (reactive-skills-axi) providing human- and agent-ergonomic state inspection and signal dispatch in TOON format.You can execute commands on-demand via npx (zero installation required) or install the CLI globally:
# Zero install — works immediately for any agent or shell:
npx -y @reactive-skills/axi state <skill-name>
npx -y @reactive-skills/axi emit <skill-name> <signal-name>
npx -y @reactive-skills/axi capabilities --json
npx -y @reactive-skills/axi preflight <skill-name> --json
# Optional: Install globally for instant local commands (`reactive-skills-axi` or `axi`):
npm install -g @reactive-skills/axi
npx -y @reactive-skills/axi state <skill-name>
Outputs the current state, active prompt instructions, allowed tools, and available transitions in structured format:
state:
skill_id: my-skill
current_state: STEP_ONE
prompt:
raw_prompt: "# State: Processing Step One ..."
allowed_tools: "view_file,grep_search,find_by_name,ask_question"
help[2]:
Read the state prompt above and execute the instructed tasks
Run `npx -y @reactive-skills/axi emit <skill> <signal>` to advance
npx -y @reactive-skills/axi emit <skill-name> <signal-name> [--payload '{"exit_code":0}']
The state machine evaluates transition guards, appends to the immutable event ledger, updates deliverables, and outputs the next state instructions.
Reactive skills can be driven through two primary integration paths:
npx -y @reactive-skills/axi state <skill> (or reactive-skills-axi state <skill>) and npx -y @reactive-skills/axi emit <skill> <signal>.reactive_context_prepare, reactive_state, and reactive_emit_signal tools over stdio for Claude Desktop, Antigravity, Cursor, and any MCP-compatible harness: ``bash npx -y @reactive-skills/axi mcp ``Call reactive_context_prepare with the current user message before loading full skill instructions. The tool uses Jev to select one skill and returns only its metadata, active state, or bounded instructions. If Jev is unavailable, it returns route: none and the agent can continue its directly requested runtime path.
Note: Commands below are shown using the zero-install
npx -y @reactive-skills/axiprefix. If installed globally (npm install -g @reactive-skills/axi), you can substitutereactive-skills-axioraxi.
| Command | Usage | Description | ||
|---|---|---|---|---|
state | npx -y @reactive-skills/axi state <skill> | Display current active state, unabridged prompt slice, and allowed tools | ||
emit | npx -y @reactive-skills/axi emit <skill> <signal> | Emit a signal to evaluate guards and advance to the next state; --payload is the signal payload, with context changes under contextUpdates | ||
invoke | npx -y @reactive-skills/axi invoke <skill> [--payload JSON] | Initialize and start a skill run; --payload is a flat object that becomes the initial context (a {"contextUpdates":{...}} wrapper is rejected) | ||
events | npx -y @reactive-skills/axi events [limit] <skill> | Tail recent events from the append-only event store | ||
inspect | npx -y @reactive-skills/axi inspect <skill> | Print statechart topology, substates, and guard rules | ||
validate | npx -y @reactive-skills/axi validate [path] | Validate skill manifest, prompt templates, transition targets, and bootloader | ||
vet | npx -y @reactive-skills/axi vet <path> [--fail-on <severity>] [--allowlist <file>] | Statically scan skills for risky code, prompt injection, hidden content, and download-and-execute steps before you run them; see docs/vetting.md | ||
init | npx -y @reactive-skills/axi init <name> | Scaffold a new modular reactive skill package | ||
reset | npx -y @reactive-skills/axi reset <skill> | Clear execution run state while preserving deliverables | ||
jobs | `npx -y @reactive-skills/axi jobs <skill> [list\ | switch\ | archive]` | Inspect, switch, and archive isolated execution runs and deliverables |
view | npx -y @reactive-skills/axi view <skill> | Launch real-time telemetry server and live visual statechart viewer | ||
dashboard | npx -y @reactive-skills/axi dashboard [--host <host>] [--port <port>] | Launch one read-only broker for multi-job telemetry | ||
sync | npx -y @reactive-skills/axi sync [skill] | Copy ordered sources into a physical central directory, then update linked or physical satellites | ||
capabilities | npx -y @reactive-skills/axi capabilities --json | Report runtime version and capabilities for INIT negotiation | ||
bootloader | npx -y @reactive-skills/axi bootloader <skill> --json | Retrieve the versioned authoritative runtime bootloader | ||
preflight | npx -y @reactive-skills/axi preflight <skill> | Check skill runtime requirements without creating a job |
Avoid manually hand-authoring reactive skill packages from scratch. A reactive skill binds together statechart topology (skill.yaml), individual state prompt templates (states/*.md), deterministic guards (guards/), projection deliverables (templates/*.hbs), and strict execution bootloaders. Hand-authoring these files easily introduces syntax drift, broken transitions, or missing guards.
skill-manager SkillThe canonical method to scaffold, modify, and migrate reactive skills is the skill-manager agent skill.
Instead of writing YAML manifests manually, ask your AI agent:
"Use skill-manager to create a reactive skill named my-feature-workflow"
The skill-manager skill validates schemas, coordinates state prompts with strict execution invariants, and handles lifecycle operations (CREATE, UPDATE, MIGRATE_LEGACY, MIGRATE_REACTIVE).
STATECHART.mdskill-manager supports and generates a STATECHART.md alongside skill.yaml. This document contains a Mermaid stateDiagram-v2 visualization of the entire state machine:
stateDiagram-v2
[*] --> INIT
INIT --> START : RUNTIME_READY
INIT --> SETUP_MCP : SETUP_REQUIRED
SETUP_MCP --> START : SETUP_COMPLETE [exit_code == 0]
SETUP_MCP --> ERROR : SETUP_FAILED [exit_code != 0]
START --> DONE : DONE
DONE --> [*]
stateDiagram-v2 block in STATECHART.md, and skill-manager will parse it into the corresponding skill.yaml and state templates.skill-manager updates both skill.yaml and STATECHART.md to keep documentation and runtime contracts identical.init)For quick command-line scaffolding, you can also use the AXI CLI:
npx -y @reactive-skills/axi init <skill-name>
This scaffolds the modular file layout in skills/<skill-name>/:
skills/<skill-name>/
├── skill.yaml # Statechart manifest (states, transitions, guards)
├── STATECHART.md # Visual Mermaid statechart diagram
├── SKILL.md # Skill entry point with reactive bootloader
├── states/ # State-specific markdown prompt templates
│ ├── init.md
│ ├── setup_mcp.md
│ ├── start.md
│ ├── done.md
│ └── bypass_detected.md
└── skill-release.json # Release and schema metadata
The synchronizer copies skills from ordered local source directories into a physical central directory (default ~/.agents/skills). The first source containing a skill name wins. Agent satellite directories then link to the central copy. Changes in a source repository reach the central directory on the next sync; linked satellites see those central changes immediately. Satellites that cannot read links receive physical copies, refreshed on each sync when their content changes.
Only immediate child folders containing SKILL.md, skill.md, or skill.yaml are distributed. Unrelated folders at a source root are ignored. Sync reads local files, or committed files when a ref is set; it never fetches Git updates. Within a selected skill, sync preserves every file and directory, including scripts, tests, hidden files and empty directories. Nested symbolic links remain links; sync does not traverse their targets. Repository-folder exclusions apply only when discovering skills at the source root.
Configure the layout in ~/.agents/sync.json:
{
"sources": ["~/work/public-skills", "~/work/private-skills"],
"central": "~/.agents/skills",
"satellites": ["~/.claude/skills", "~/.codex/skills", "~/.gemini/config/skills"],
"physicalSatellites": ["~/.gemini/config/skills"]
}
Change the persistent central directory by updating the central value in this file. Use --central <dir> to override it for one invocation, and use --show-config to inspect the resolved paths. When prior sync state records the old central path, the next sync can use that directory as a migration fallback while populating the new central directory. The old central directory remains on disk.
Sources are optional. Without them, sync distributes valid skills already in the central directory. Without a config file, it uses the default central path and existing agent directories as satellites; the physical satellite list is empty. If a directory appears in both satellite lists, the physical copy takes precedence. If the central path appears in either satellite list, sync omits it. When sync.json is absent, existing sources from ~/.agents/sources.json remain a fallback. If creating sync.json for the first time, copy any needed source paths into its sources array because the legacy source fallback applies only when sync.json is absent.
By default, sync copies each skill from the source folder's working tree as it is at that moment. That means checking out an older branch in a source repository changes what the next sync installs. Three safeguards keep that from replacing newer installed skills silently.
ref (branch, tag, or commit). Sync then reads the committed files at that ref with git ls-tree and git cat-file, so the source can have any branch checked out, dirty or not. Nothing is checked out and the source repository is never modified. The --ref <ref> flag does the same for every source in one run and overrides configured refs. A ref on a source that is not a git repository, or one that does not resolve, stops the sync with an error.version in each skill.yaml using SemVer. A source version lower than the installed one is refused: the installed copy stays unchanged, the report names the skill and both versions, and the command exits with status 1. Other skills still sync. Pass --allow-downgrade to replace it anyway; the usual backup is still made. A same-version content change and a skill whose version is missing or not SemVer (for example 1.0 or a legacy SKILL.md skill) produce a warning and are replaced. The check applies to --dry-run and to sources that are not git repositories.origin/HEAD branch, else main or master) or is on a detached HEAD, and warns if the selected skills have uncommitted or untracked changes. Each warning names the source, the branch, and the skills involved. Warnings do not stop the sync.{
"sources": [
"~/work/private-skills",
{ "path": "~/work/public-skills", "ref": "main" }
]
}
Sync records where each installed skill came from under skills in ~/.agents/sync-state.json: the source path, the ref or checked-out branch, the commit SHA, the skill.yaml version, and dirty: true when the working-tree copy had uncommitted changes. For a working-tree sync the SHA is the source's HEAD, so check dirty as well. Sources that are not git repositories record only the source path and version. The record is merged on each run, so syncing one skill keeps the others, and a skill that is refused keeps its previous record. sync --show-config prints the configured refs and the recorded provenance, and a sync prints the provenance of the skills it copied. Empty directories are not part of a commit, so they are not copied when a ref is used.
# Show effective paths and source precedence:
npx -y @reactive-skills/axi sync --show-config
# Preview a one-run central directory override:
npx -y @reactive-skills/axi sync --central ~/work/skill-registry --dry-run
# Preview central copies, satellite links/copies, and source collisions:
npx -y @reactive-skills/axi sync --dry-run
# Synchronize all valid skills:
npx -y @reactive-skills/axi sync
# Synchronize a specific skill only:
npx -y @reactive-skills/axi sync <skill-name>
# Synchronize several selected skills together:
npx -y @reactive-skills/axi sync --skill skill-one,skill-two
# Repeated --skill flags remain supported:
npx -y @reactive-skills/axi sync --skill skill-one --skill skill-two
# Override configured sources with comma-separated paths:
npx -y @reactive-skills/axi sync --source ~/work/public-skills,~/work/private-skills --dry-run
# Add explicit sources before configured sources:
npx -y @reactive-skills/axi sync --source ~/work/public-skills --all-sources --dry-run
# Choose linked and physical satellite directories:
npx -y @reactive-skills/axi sync --target ~/.codex/skills,~/.claude/skills --physical-target ~/.gemini/config/skills,~/.copilot/skills --dry-run
# Install the committed skills of main, whatever the sources have checked out:
npx -y @reactive-skills/axi sync --ref main
# Replace an installed skill with an older source version:
npx -y @reactive-skills/axi sync <skill-name> --allow-downgrade
# Preview changes without modifying files:
npx -y @reactive-skills/axi sync <skill-name> --dry-run
# Use physical copies for all selected satellites for this run:
npx -y @reactive-skills/axi sync <skill-name> --copy
Each --source, --target, and --physical-target value can contain comma-separated paths, and each option can be repeated. Surrounding whitespace is ignored, and empty path entries are rejected.
Agents running in GUI environments can call the native reactive_sync tool:
{
"skill": "my-skill",
"link": true,
"dryRun": false
}
The synchronizer validates path overlaps before writing. It stages and verifies central copies before replacing existing skill folders. It backs up physical directories before replacement under each target's .sync-backups/ directory. Valid skills already in the central directory remain there when no configured source supplies them. A state file at ~/.agents/sync-state.json tracks links created by sync, allowing it to remove obsolete managed links when satellite configuration changes while preserving unrelated files.
RSA provides multi-run isolation, allowing teams and autonomous agents to execute multiple independent runs of the same skill without event log pollution or deliverable overwrites.
.reactive/skills/<skill>/jobs/<job-id>/ with independent events.jsonl and SQLite events.db stores..reactive/skills/<skill>/active_job (defaults to default). All CLI commands and MCP operations target the active run unless explicitly overridden..docs/<skill>/jobs/<job-id>/ for permanent archival, and automatically mirror to the canonical .docs/ path for the active job.jobId directly inside ProjectionContext, enabling deliverables to reference their run ID.# Start a fresh execution run (auto-generates sortable job ID)
npx -y @reactive-skills/axi invoke <skill> [--payload JSON]
# Start an isolated named run (does not change the global active pointer)
npx -y @reactive-skills/axi invoke <skill> --job <job-id>
# Inspect or resume the active execution run (auto-rotates if prior job is terminal)
npx -y @reactive-skills/axi state <skill>
# Run with environment-scoped isolation (parallel subagents)
REACTIVE_JOB_ID=subagent-1 npx -y @reactive-skills/axi state <skill>
# List all execution runs for a skill (active job highlighted)
npx -y @reactive-skills/axi jobs <skill> list
# (or bidirectional syntax: npx -y @reactive-skills/axi jobs list <skill>)
# Switch the active execution run
npx -y @reactive-skills/axi jobs <skill> switch <job-id>
# Archive a completed run
npx -y @reactive-skills/axi jobs <skill> archive <job-id>
# Target a specific job explicitly without switching active pointer
npx -y @reactive-skills/axi state <skill> --job <job-id>
npx -y @reactive-skills/axi emit <skill> <signal> --job <job-id>
When interacting with agents over MCP, job operations are exposed as native tools:
reactive_bootloader: Retrieves the versioned authoritative bootloader for a reactive skill.reactive_list_jobs: Lists all runs for a skill with state and active indicators.reactive_switch_job: Switches the active job pointer.reactive_archive_job: Marks a job run as archived.RSA provides real-time performance instrumentation and token economy tracking with zero runtime latency tax.
Every state transition records execution telemetry inside .reactive/skills/<skill>/events.jsonl:
{
"type": "STATE_TRANSITION",
"payload": {
"from": "RED_SPEC",
"to": "GREEN_CODE",
"metrics": {
"transition_duration_ms": 1.151,
"slice_duration_ms": 0.922,
"slice_tokens_est": 282
}
}
}
Tail latest transition metrics from your terminal:
# PowerShell
Get-Content .reactive/skills/<skill>/events.jsonl | ConvertFrom-Json | Where-Object { $_.type -eq "STATE_TRANSITION" } | Select-Object -ExpandProperty payload | Select-Object from, to, metrics
# SQLite
sqlite3 .reactive/skills/<skill>/events.db "SELECT seq, json_extract(payload, '$.metrics') FROM events WHERE type = 'STATE_TRANSITION';"
Launch the existing single-job viewer and SSE event stream:
npx -y @reactive-skills/axi view <skill-name> # prefer 4242, then try the bounded fallback range
npx -y @reactive-skills/axi view <skill-name> --port 5000 # bind only to 5000
npx -y @reactive-skills/axi view <skill-name> --port 0 # ask the OS for an ephemeral port
npx -y @reactive-skills/axi view <skill-name> --job sprint-1 # follow one job without changing the active pointer
Launch the read-only multi-job broker:
npx -y @reactive-skills/axi dashboard
npx -y @reactive-skills/axi dashboard --port 0
npx -y @reactive-skills/axi dashboard --host 0.0.0.0 --port 4500
The command reports the actual listener URL and bound port.
Open the site /telemetry route and enter that broker URL in the connection field.
The broker catalog discovers skills and jobs from the current workspace's .reactive/skills directory and returns skill names, job IDs, status, current HSM state, local latest sequence, update time, and active-job metadata.
The dashboard uses GET /catalog, job-scoped GET /state?skillId=<skill-id>&jobId=<job-id>, and one filtered GET /events SSE connection for all tracked targets.
Add two jobs from the catalog, such as jsm-workflow/review-slice and jsm-workflow/test-slice, to monitor them simultaneously.
Every card verifies both skill ID and job ID before accepting an event, so a signal written to one job cannot update another card.
The broker tails each job's SQLite event store and refreshes the catalog on a bounded interval, so new jobs and cross-process events appear without restarting it.
The broker is read-only telemetry.
It does not dispatch signals, write events, change the active-job pointer, or scan arbitrary browser localhost ports.
The existing view <skill> --job <job-id> command remains single-job scoped for explicit isolation and backward compatibility.
The broker keeps th
hooks/register.tsx 275 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Edge, Flow, Step } from '../types'
5
6const PANE = 'bq-flow'
7const TERMINAL = new Set(['COMPLETE', 'ERROR'])
8const flowAtom = atom({ plugin: 'bq-vitals', key: 'flow' } as const, null)
9
10type Ev = {
11 type: string
12 state: string
13 timestamp: string
14 run_id: string
15 skill_id: string
16 payload: Record<string, unknown>
17}
18
19export function fmtTokens(n: number): string {
20 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
21 if (n >= 1000) return `${Math.round(n / 1000)}k`
22 return String(n)
23}
24
25export function fmtMs(ms: number): string {
26 if (ms < 1000) return `${Math.round(ms)}ms`
27 const s = ms / 1000
28 if (s < 60) return `${s.toFixed(1)}s`
29 return `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`
30}
31
32// `A --> B: SIG [guards]` or `A --> B: SIG1 / SIG2` lines of a STATECHART.md.
33export function parseChart(text: string): Edge[] {
34 const edges: Edge[] = []
35 for (const line of text.split('\n')) {
36 const m = line.match(/^\s*([A-Z][A-Z_]*)\s*-->\s*([A-Z][A-Z_]*)(?:\s*:\s*(.*))?$/)
37 if (!m) continue
38 const signals = (m[3] ?? '')
39 .replace(/\[.*$/, '')
40 .split('/')
41 .map(s => s.trim())
42 .filter(Boolean)
43 edges.push({ from: m[1], to: m[2], signals })
44 }
45 return edges
46}
47
48function stateOrder(edges: Edge[], steps: Step[], current: string): string[] {
49 const order: string[] = []
50 const add = (s: string) => {
51 if (!order.includes(s)) order.push(s)
52 }
53 for (const e of edges) {
54 add(e.from)
55 add(e.to)
56 }
57 for (const s of steps) {
58 add(s.from)
59 add(s.to)
60 }
61 add(current)
62 return order
63}
64
65// Turns one run's event log into the path it took, like an EXPLAIN of the run.
66export function buildFlow(lines: string[], edges: Edge[], now: number): Flow | null {
67 const all: Ev[] = []
68 for (const line of lines) {
69 try {
70 all.push(JSON.parse(line) as Ev)
71 } catch {
72 // a half-written last line
73 }
74 }
75 const last = all[all.length - 1]
76 if (!last) return null
77 const evs = all.filter(e => e.run_id === last.run_id)
78 const steps: Step[] = []
79 let current = 'INIT'
80 let enteredAt = Date.parse(evs[0].timestamp)
81 const startedAt = enteredAt
82 let signalAt = enteredAt
83 let denied = 0
84 let guard: Step['guard'] = 'none'
85 for (const e of evs) {
86 const t = Date.parse(e.timestamp)
87 if (e.type === 'SIGNAL_EMITTED') {
88 signalAt = t
89 denied = 0
90 guard = 'none'
91 } else if (e.type === 'GUARD_EVALUATED') {
92 if (e.payload.passed === false) denied += 1
93 else guard = e.payload.fallbackTriggered === true ? 'fallback' : 'pass'
94 } else if (e.type === 'STATE_TRANSITION') {
95 const from = String(e.payload.from)
96 const to = String(e.payload.to)
97 const signal = String(e.payload.signal)
98 const taken = edges.filter(x => x.from === from).flatMap(x => x.signals)
99 steps.push({
100 from,
101 to,
102 signal,
103 guard,
104 denied,
105 dwellMs: Math.max(0, signalAt - enteredAt),
106 notTaken: [...new Set(taken.filter(s => s !== signal))],
107 })
108 current = to
109 enteredAt = t
110 }
111 }
112 const updatedAt = Date.parse(last.timestamp)
113 const isTerminal = TERMINAL.has(current)
114 return {
115 skill: last.skill_id,
116 runId: last.run_id,
117 current,
118 isTerminal,
119 startedAt,
120 updatedAt,
121 liveMs: isTerminal ? 0 : Math.max(0, now - enteredAt),
122 order: stateOrder(edges, steps, current),
123 edges,
124 steps,
125 }
126}
127
128// Plain-text plan: the path taken, per hop the signal, guard, time and roads not taken.
129export function planText(f: Flow): string {
130 const total = f.steps.reduce((a, s) => a + s.dwellMs, 0) + f.liveMs
131 const slowest = Math.max(1, ...f.steps.map(s => s.dwellMs), f.liveMs)
132 const bar = (ms: number) => '█'.repeat(Math.max(1, Math.round((ms / slowest) * 10)))
133 const out = [
134 `${f.skill} run ${f.runId.slice(0, 8)} ${f.isTerminal ? `finished at ${f.current}` : `running at ${f.current}`} work ${fmtMs(total)}`,
135 ]
136 f.steps.forEach((s, i) => {
137 const mark = s.guard === 'fallback' ? '↯' : '✔'
138 const denied = s.denied > 0 ? ` guard denied ${s.denied}x first` : ''
139 out.push(`${String(i + 1).padStart(2)}. ${s.from} ─${s.signal}→ ${s.to} ${mark} ${bar(s.dwellMs)} ${fmtMs(s.dwellMs)}${denied}`)
140 if (s.notTaken.length > 0) out.push(` not taken: ${s.notTaken.join(', ')}`)
141 })
142 if (!f.isTerminal) out.push(` ▶ ${f.current} ${bar(f.liveMs)} ${fmtMs(f.liveMs)} so far`)
143 return out.join('\n')
144}
145
146function statusLine(f: Flow | null, now: number): string | undefined {
147 if (f === null) return undefined
148 const idle = now - f.updatedAt
149 if (idle > 15 * 60_000 || (f.isTerminal && idle > 2 * 60_000)) return undefined
150 return `${f.skill} ▸ ${f.current}`
151}
152
153const FIND = [
154 'files=$(stat -c "%Y %n" "$PWD"/.reactive/skills/*/events.jsonl',
155 '"$HOME"/.claude/skills/*/.reactive/skills/*/events.jsonl',
156 '"$HOME"/.agents/skills/*/.reactive/skills/*/events.jsonl',
157 '/tmp/.reactive/skills/*/events.jsonl 2>/dev/null | sort -rn | head -1 | cut -d" " -f2-);',
158 '[ -n "$files" ] && printf "%s" "$files"',
159].join(' ')
160
161type Engine = EngineInterface
162type Mem = { home: string | null; isPaneOpen: boolean; usageText: string; edgeCache: Map<string, Edge[]> }
163
164async function loadFlow($: Engine, mem: Mem): Promise<Flow | null> {
165 try {
166 const found = await $.process.run(['sh', '-c', FIND], { timeoutMs: 5000 })
167 const path = found.stdout.trim()
168 if (path === '') return null
169 const skill = path.split('/').slice(-2)[0]
170 if (!mem.edgeCache.has(skill)) {
171 mem.home ??= (await $.process.run(['sh', '-c', 'printf %s "$HOME"'])).stdout
172 let edges: Edge[] = []
173 for (const root of [`${mem.home}/.claude/skills`, `${mem.home}/.agents/skills`]) {
174 try {
175 edges = parseChart(await $.fs.read(`${root}/${skill}/STATECHART.md`))
176 break
177 } catch {
178 // try the next root
179 }
180 }
181 mem.edgeCache.set(skill, edges)
182 }
183 const text = await $.fs.read(path)
184 return buildFlow(text.split('\n').filter(Boolean), mem.edgeCache.get(skill) ?? [], await $.clock.now())
185 } catch {
186 return null
187 }
188}
189
190async function refresh($: Engine, mem: Mem) {
191 try {
192 const u = await $.session.usage()
193 const ctx = u.context
194 const parts = [
195 ctx.tokens === undefined
196 ? `ctx ${fmtTokens(ctx.window)}`
197 : `ctx ${fmtTokens(ctx.tokens)}/${fmtTokens(ctx.window)} ${Math.round(ctx.percent ?? 0)}%`,
198 ]
199 if (u.cost) parts.push(`$${u.cost.usd.toFixed(2)}`)
200 for (const r of u.rateLimits) {
201 parts.push(`${r.kind === 'five_hour' ? '5h' : r.kind === 'seven_day' ? '7d' : r.kind} ${Math.round(r.percentUsed)}%`)
202 }
203 mem.usageText = parts.join(' · ')
204 } catch {
205 // keep the last reading
206 }
207 const flow = await loadFlow($, mem)
208 const flowText = statusLine(flow, await $.clock.now())
209 $.ui.status([mem.usageText, flowText].filter(Boolean).join(' | ') || undefined)
210 if (mem.isPaneOpen) await update($, flowAtom, () => flow)
211}
212
213export const register: Register = on => {
214 const mem: Mem = { home: null, isPaneOpen: false, usageText: '', edgeCache: new Map() }
215
216 on('session.start', async ($, e, next) => {
217 await $.command.register({ name: 'flow', description: 'Live map and query-plan style trace of the latest reactive skill run' })
218 await $.command.register({ name: 'flow-plan', description: 'Print the path the latest reactive skill run took, as text' })
219 $.clock.every(3000, () => void refresh($, mem))
220 void refresh($, mem)
221 return next(e)
222 })
223
224 on('turn.complete', async ($, e, next) => {
225 await refresh($, mem)
226 return next(e)
227 })
228
229 on('command.run', { command: 'flow' }, async $ => {
230 mem.isPaneOpen = true
231 await update($, flowAtom, () => null)
232 await $.ui.open({ id: PANE, title: 'Reactive run' })
233 void refresh($, mem)
234 return { text: 'Reactive run pane opened.' }
235 })
236
237 on('command.run', { command: 'flow-plan' }, async $ => {
238 const flow = await loadFlow($, mem)
239 return { text: flow === null ? 'No reactive skill run found.' : planText(flow) }
240 })
241
242 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
243 const { Box, Text } = $.ui.resolve(e)
244 const flow = await read($, flowAtom)
245 if (flow === null) return <Text dimColor>No reactive skill run found yet.</Text>
246
247 const visited = new Set(flow.steps.flatMap(s => [s.from, s.to]))
248 const path = flow.steps.map(s => s.from)
249 return (
250 <Box flexDirection="column">
251 <Text bold>{flow.skill} · run {flow.runId.slice(0, 8)}</Text>
252 <Text dimColor>{' '}</Text>
253 {flow.order.map(state => {
254 const isNow = state === flow.current
255 const isDone = path.includes(state) && !isNow
256 const mark = isNow ? '▶' : isDone ? '✔' : visited.has(state) ? '·' : '○'
257 return (
258 <Text bold={isNow} dimColor={!isNow && !isDone}>
259 {mark} {state}
260 </Text>
261 )
262 })}
263 <Text dimColor>{' '}</Text>
264 <Text bold>Plan</Text>
265 {planText(flow)
266 .split('\n')
267 .slice(1)
268 .map(line => (
269 <Text dimColor={line.includes('not taken')}>{line}</Text>
270 ))}
271 </Box>
272 )
273 })
274}
275types/index.d.ts 33 lines1export type Edge = { from: string; to: string; signals: string[] }
2
3// One hop through the state machine, read from the run's event log.
4export type Step = {
5 from: string
6 to: string
7 signal: string
8 // pass: the guard allowed it; fallback: the guard failed and a fallback ran.
9 guard: 'pass' | 'fallback' | 'none'
10 denied: number
11 dwellMs: number
12 notTaken: string[]
13}
14
15export type Flow = {
16 skill: string
17 runId: string
18 current: string
19 isTerminal: boolean
20 startedAt: number
21 updatedAt: number
22 liveMs: number
23 order: string[]
24 edges: Edge[]
25 steps: Step[]
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'bq-vitals': { flow: Flow | null }
31 }
32}
33