Live mesh7 decisions, pending approvals and emergency stops in a pane

Claude Code mods: plugins of function hooks that run inside one session (panes, bands, status line, tool-call hooks). Fleet-wide views stay in deck7; what lives here sees a single session, from the inside, as events happen.
avatar7 is the stage; the other mods are utilities that also give it something to play. Each one does its own job alone (a pane, a toast, a status line), and when avatar7 is loaded, what it noticed becomes a face and a spoken line.
No mod imports another. They meet only in the session's state ($.state): each writes under its own name, and whoever cares listens to the write.
| Key | Written by | Read by | Meaning |
|---|---|---|---|
<mod>.announce | atelier-bell, usage-bell | avatar7 | voice my toasts, in this mood, from this event |
<mod>.say | mesh7-pane, jukebox7 | avatar7 | say this now, no toast needed |
avatar7.avatar, color, station | avatar7 | jukebox7 | the persona on duty, its color, its music |
avatar7.isVoicing | avatar7 | jukebox7 | a line is being heard: lower the music |
The mod gives the fact; the persona gives the voice. avatar7 picks the face from the mood, and Haiku writes the line from the event in the persona's manner, so a new mod writes no dialogue. Every line, from a tool call, a toast, a say or a poke, goes through one queue ordered by urgency: a refusal is heard before calm news.
Unplug any mod, avatar7 included, and the others keep working; only the voice goes. The same rule as mesh7, at the scale of a terminal: no part commands another, and what happens is made visible.
flowchart LR
you([you])
claude[Claude's tool calls]
mesh7[(mesh7)]
studio[(flux7-studio)]
engine[(Claude Code)]
voice[[Haiku writes the line<br/>Piper speaks it]]
subgraph mods [flux7-mods]
pane[mesh7-pane]
abell[atelier-bell]
ubell[usage-bell]
juke[jukebox7]
avatar[avatar7]
end
claude -- tool.check, tool.call --> avatar
mesh7 -- traces, approvals, health --> pane
pane -- "say: deny, hold, release, down, back" --> avatar
studio -- renders --> abell
abell -- "announce + toast" --> avatar
engine -- "session.measure" --> ubell
ubell -- "announce + toast" --> avatar
you -- "put on some ambient" --> juke
juke -- "say: a song starts" --> avatar
avatar -- "avatar, color, station" --> juke
avatar -- isVoicing --> juke
you -- "ask, answer, poke, /clear, /compact" --> avatar
avatar -. "visits, duos" .-> avatar
avatar -- "event + persona" --> voice
voice -- "face, line, voice" --> you
Four loops close through it:
a, which plays the persona's station; each song that starts is handed back to avatar7, which introduces it while jukebox7 lowers the music under the voice (isVoicing), then brings it back.To give a new mod a voice, see avatar7/README.md, Giving a mod a voice.
| Mod | What it does |
|---|---|
mesh7-pane | mesh7 decisions live, polled from localhost:9090 every 1.5 s: ALLOW / DENY / HUMAN per call with rule and parameters, approvals waiting for a human, emergency stop banner, status line counts, a toast on each new deny or approval request. In "this session" scope it shows the session's built-in calls and its MCP calls, whose traces carry the MCP connection's id, learned by matching a call to its trace. With avatar7 loaded it says refusals, holds and the human's decisions, read from the traces. Read-only. /mesh7 opens the pane, /mesh7 all widens to every agent and session, /mesh7 session narrows back. |
avatar7 | A machine face that watches tool calls and comments on them in the voice of a chosen avatar (SHODAN, HAL, GLaDOS, Ada, duck7, Pod 042, Kaneda, the Commis, Fox, the Adjutant, Morte, the PDA, Lain, a Tachikoma, Nova), framed like a comm window over a backdrop of its world; it waits with you while a human decides, answers a poke, gives its opinion on the session when asked (/avatar-ask), reacts to the slash commands that change the session (/clear, /compact, /rewind...), announces the toasts of any mod that publishes an announce (atelier-bell, usage-bell), and speaks the say of any mod (mesh7-pane, jukebox7). See avatar7/README.md: how it works, and how to make an avatar (portrait, bake, personality). |
atelier-bell | Tells you when an atelier finishes: flux7-studio renders as toasts and a status line (studio: rendering, studio: last …), /bell for the state. See atelier-bell/README.md. |
usage-bell | Rings when the session nears a limit: context fill (70/85/95 %), the 5 h and 7 d rate-limit windows (80/95 %), the auto-memory index the engine cuts at 200 lines or 25 kB. A status line (ctx 62% · 5h 41% · 7d 12% · mem 145/200), /usage7 for the detail. See usage-bell/README.md. |
jukebox7 | Music on demand, in plain words ("put on some ambient"): YouTube audio through a hidden VLC, no browser, no focus taken. A pane with genre buttons, each a radio of artists, and the pick of the avatar on duty. See jukebox7/README.md. |
One session, one or several mods:
claude --plugin-dir ~/flux7-mods/avatar7 --plugin-dir ~/flux7-mods/mesh7-pane --plugin-dir ~/flux7-mods/atelier-bell --plugin-dir ~/flux7-mods/usage-bell --plugin-dir ~/flux7-mods/jukebox7
Every interactive session: add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (colon-separated absolute paths). The folder is watched: saving a file reloads the mod.
When several mods open a pane at session start, the engine shows the one opened last; the tab order follows the first opening, and reopening a pane that is already open does not bring it forward. A mod that wants to be in front opens its pane after await next(e) in session.start, once the others have started, and so takes the last tab. Opening early for the first tab and again later for the front does not work: the early open wins.
claude plugin validate ~/flux7-mods/avatar7 claude plugin test ~/flux7-mods/avatar7 npx -y -p typescript@5 tsc -p ~/flux7-mods/avatar7 --noEmit
The plugin tests mock every process; the relay's shell scripts run for real in their own test, against a spool of their own:
node --experimental-strip-types --test ~/flux7-mods/avatar7/tools/test_relay_shell.ts
MIT, see LICENSE. Character names referenced by avatar7 belong to their owners; see the credits in avatar7/README.md.
hooks/register.tsx 282 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import type { Decision, Health, Pending, Say, Verdict } from '../types'
5
6const PANE = 'mesh7-pane'
7const MESH = 'http://localhost:9090'
8const POLL_MS = 1500
9const KEEP = 60
10
11const decisions = atom({ plugin: 'mesh7-pane', key: 'decisions' } as const, [] as Decision[])
12const pending = atom({ plugin: 'mesh7-pane', key: 'pending' } as const, [] as Pending[])
13const health = atom({ plugin: 'mesh7-pane', key: 'health' } as const, { isUp: true, version: '', halt: '' } as Health)
14const isAll = atom({ plugin: 'mesh7-pane', key: 'isAll' } as const, false)
15
16const LOOK: Record<Verdict, { label: string; color: string }> = {
17 allow: { label: 'ALLOW', color: '#3ddc84' },
18 deny: { label: 'DENY ', color: '#ff2a6d' },
19 human_approval: { label: 'HUMAN', color: '#ffb000' },
20}
21
22type Trace = {
23 trace_id: string
24 session_id?: string
25 agent_id: string
26 tool: string
27 params?: Record<string, unknown>
28 policy: string
29 policy_rule: string
30 timestamp: string
31 approval_status?: string
32}
33
34type Approval = { id: string; agent_id: string; tool: string; policy_rule: string; status: string; created_at: string }
35
36type Halt = { scope: string; target?: string; reason?: string; resumed_at?: string }
37
38// What a call was about, in one short line.
39const hintOf = (params: Record<string, unknown> | undefined): string => {
40 if (params === undefined) return ''
41 const v = params.command ?? params.file_path ?? params.path ?? params.url ?? params.pattern ?? params.key ?? params.query
42 return String(v ?? JSON.stringify(params)).replace(/\s+/g, ' ').slice(0, 120)
43}
44
45const clock = (iso: string): string => {
46 const d = new Date(iso)
47 return Number.isNaN(d.getTime()) ? '--:--:--' : d.toTimeString().slice(0, 8)
48}
49
50const ago = (iso: string, now: number): string => {
51 const s = Math.max(0, Math.round((now - new Date(iso).getTime()) / 1000))
52 return s < 120 ? `${s}s` : `${Math.round(s / 60)}m`
53}
54
55// What changed in mesh7's health between two polls, as a line for avatar7,
56// or undefined when nothing did.
57export const meshShift = (before: Health, after: Health): Omit<Say, 'at'> | undefined => {
58 if (after.halt !== '' && after.halt !== before.halt) return { mood: 'deny', event: `mesh7 EMERGENCY STOP: ${after.halt}` }
59 if (after.halt === '' && before.halt !== '') return { mood: 'watch', event: 'mesh7 emergency stop lifted, tools run again' }
60 if (!after.isUp && before.isUp) return { mood: 'error', event: 'mesh7 went down, every tool call now fails closed' }
61 if (after.isUp && !before.isUp) return { mood: 'watch', event: 'mesh7 is back up' }
62 return undefined
63}
64
65// The health of a poll kept, and a shift since the one before told to
66// avatar7 under this mod's `say` key; none before the first, so a load is silent.
67async function report($: Engine, before: Health | null, now: Health): Promise<Health> {
68 const shift = before === null ? undefined : meshShift(before, now)
69 await update($, health, () => now)
70 if (shift !== undefined) await $.state.set({ plugin: 'mesh7-pane', key: 'say' }, { ...shift, at: Date.now() })
71 return now
72}
73
74// A trace's tool as Claude Code names it: MCP calls reach mesh7 as
75// `server.tool` and Claude Code calls them `mcp__mesh7__server_tool`; the
76// hook's built-in tools (Bash, Read...) keep their name.
77export const claudeName = (tool: string): string =>
78 tool.includes('.') ? `mcp__mesh7__${tool.replace('.', '_')}` : tool
79
80// What a fresh decision of this session tells avatar7, or undefined: a
81// refusal, or an MCP call held for a human (a held Bash call is Claude
82// Code's own permission prompt, which avatar7 already sees).
83export const verdictSay = (d: { tool: string; verdict: Verdict; rule: string; hint: string }): Omit<Say, 'at'> | undefined => {
84 const tool = claudeName(d.tool)
85 const what = d.hint === '' ? d.tool : `${d.tool} ${d.hint.slice(0, 60)}`
86 if (d.verdict === 'deny') return { mood: 'deny', event: `mesh7 DENIED ${what}, by rule ${d.rule}`, tool }
87 if (d.verdict === 'human_approval' && d.tool.includes('.')) {
88 return { mood: 'wait', event: `mesh7 holds ${what} for a human, by rule ${d.rule}`, tool, hold: true }
89 }
90 return undefined
91}
92
93// An approval of this agent leaving pending: the human's decision, which
94// releases the waiting face.
95export const decisionSay = (a: { tool: string; status: string }): Omit<Say, 'at'> =>
96 ({ mood: a.status === 'approved' ? 'watch' : 'deny', event: `the human ${a.status} ${a.tool}`, tool: claudeName(a.tool), release: true })
97
98export const register: Register = on => {
99 let seen = ''
100 let isFirst = true
101 let was: Health | null = null
102 // MCP calls reach mesh7 under the MCP connection's session, not Claude
103 // Code's: learned by matching a call this session just made to its trace.
104 let mcpSession = ''
105 const recentCalls: { tool: string; at: number }[] = []
106 // Each approval's last status seen; null until the first poll seeds it.
107 let statuses: Map<string, string> | null = null
108
109 on('session.start', async ($, e, next) => {
110 await $.command.register({
111 name: 'mesh7',
112 description: 'Open the mesh7 decisions pane; /mesh7 all|session switches the scope',
113 })
114 const session = await $.session.id()
115
116 $.clock.every(POLL_MS, async () => {
117 const wide = await read($, isAll)
118 try {
119 const [tr, ap, ha, he] = await Promise.all([
120 $.http.fetch(`${MESH}/traces?limit=300`),
121 $.http.fetch(`${MESH}/approvals`),
122 $.http.fetch(`${MESH}/halts`),
123 $.http.fetch(`${MESH}/health`),
124 ])
125 const all = JSON.parse(tr.text) as Trace[]
126 if (mcpSession === '') {
127 const match = all.find(
128 t =>
129 t.tool.includes('.') &&
130 t.agent_id === 'claude' &&
131 t.session_id !== undefined &&
132 recentCalls.some(c => c.tool === claudeName(t.tool) && Math.abs(new Date(t.timestamp).getTime() - c.at) < 15_000),
133 )
134 if (match?.session_id !== undefined) mcpSession = match.session_id
135 }
136 const mine = (t: Trace): boolean => t.session_id === session || (mcpSession !== '' && t.session_id === mcpSession)
137 const traces = all.filter(t => wide || mine(t))
138 const mineIds = new Set(traces.filter(mine).map(t => t.trace_id))
139 const approvals = ap.ok ? (JSON.parse(ap.text) as Approval[]) : []
140 const halts = ha.ok ? (JSON.parse(ha.text) as Halt[]).filter(h => !h.resumed_at) : []
141 const version = he.ok ? String((JSON.parse(he.text) as { version?: string }).version ?? '') : ''
142
143 const list: Decision[] = traces.slice(0, KEEP).map(t => ({
144 id: t.trace_id,
145 at: t.timestamp,
146 agent: t.agent_id,
147 tool: t.tool,
148 verdict: (t.policy in LOOK ? t.policy : 'deny') as Verdict,
149 rule: t.policy_rule,
150 hint: hintOf(t.params),
151 approval: t.approval_status ?? '',
152 }))
153 const waiting: Pending[] = approvals
154 .filter(a => a.status === 'pending' && (wide || a.agent_id === 'claude'))
155 .map(a => ({ id: a.id, agent: a.agent_id, tool: a.tool, rule: a.policy_rule, since: a.created_at }))
156 const halt = halts.map(h => (h.target ? `${h.scope} ${h.target}` : h.scope) + (h.reason ? `: ${h.reason}` : '')).join(' · ')
157
158 // Tell about what is new since the last poll, never about the backlog.
159 const fresh = isFirst ? [] : list.slice(0, Math.max(0, list.findIndex(d => d.id === seen)))
160 isFirst = false
161 seen = list[0]?.id ?? seen
162 for (const d of fresh) {
163 if (d.verdict === 'deny') $.ui.toast(`mesh7 DENY ${d.tool} (${d.rule})`)
164 if (d.verdict === 'human_approval') $.ui.toast(`mesh7 waits for a human: ${d.tool}`)
165 const said = mineIds.has(d.id) ? verdictSay(d) : undefined
166 if (said !== undefined) await $.state.set({ plugin: 'mesh7-pane', key: 'say' }, { ...said, at: Date.now() })
167 }
168 // The human's decisions on this agent's held calls.
169 const now = new Map(approvals.filter(a => a.agent_id === 'claude').map(a => [a.id, a.status]))
170 if (statuses !== null) {
171 for (const a of approvals) {
172 if (a.agent_id === 'claude' && statuses.get(a.id) === 'pending' && a.status !== 'pending') {
173 await $.state.set({ plugin: 'mesh7-pane', key: 'say' }, { ...decisionSay(a), at: Date.now() })
174 }
175 }
176 }
177 statuses = now
178
179 await update($, decisions, () => list)
180 await update($, pending, () => waiting)
181 was = await report($, was, { isUp: true, version, halt })
182
183 const allow = list.filter(d => d.verdict === 'allow').length
184 const deny = list.filter(d => d.verdict === 'deny').length
185 $.ui.status(
186 halt !== ''
187 ? `mesh7 HALTED (${halt})`
188 : `mesh7 ${allow} allow · ${deny} deny · ${waiting.length} pending`,
189 )
190 } catch {
191 was = await report($, was, { ...(await read($, health)), isUp: false })
192 $.ui.status('mesh7 DOWN: tools fail closed')
193 }
194 })
195
196 void $.ui.open({ id: PANE, title: 'mesh7' })
197 return next(e)
198 })
199
200 // The MCP calls this session makes, by name and time, to find their traces.
201 on('tool.call', async ($, e, next) => {
202 if (e.tool.startsWith('mcp__mesh7__') && mcpSession === '') {
203 recentCalls.push({ tool: e.tool, at: Date.now() })
204 if (recentCalls.length > 20) recentCalls.shift()
205 }
206 return next(e)
207 })
208
209 on('command.run', { command: 'mesh7' }, async ($, e) => {
210 const arg = e.args.trim().toLowerCase()
211 if (arg === 'all' || arg === 'session') {
212 await update($, isAll, () => arg === 'all')
213 isFirst = true
214 }
215 const opened = await $.ui.open({ id: PANE, title: 'mesh7' })
216 const scope = (await read($, isAll)) ? 'all agents and sessions' : 'this session'
217 return { text: opened.isPlaced ? `mesh7 pane: ${scope}.` : 'The pane needs a wider terminal.' }
218 })
219
220 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
221 const { Box, Text } = $.ui.resolve(e)
222 const list = await read($, decisions)
223 const waiting = await read($, pending)
224 const state = await read($, health)
225 const wide = await read($, isAll)
226 const now = Date.now()
227 const room = Math.max(3, (e.viewport?.rows ?? 30) - 8 - waiting.length)
228
229 return (
230 <Box flexDirection="column" flexGrow={1} width="100%" height={e.viewport?.rows ?? 30} backgroundColor="#000000">
231 <Text backgroundColor="#000000" wrap="truncate-end">
232 <Text bold color="#00e5ff">mesh7</Text>
233 <Text dimColor> {state.version} · {wide ? 'all sessions' : 'this session'} · </Text>
234 {state.isUp ? <Text color="#3ddc84">up</Text> : <Text bold color="#ff2a6d">DOWN, tools fail closed</Text>}
235 </Text>
236
237 {state.halt !== '' && (
238 <Text bold color="#000000" backgroundColor="#ff2a6d" wrap="truncate-end">
239 {` EMERGENCY STOP ${state.halt} `}
240 </Text>
241 )}
242
243 {waiting.length > 0 && (
244 <Box flexDirection="column" marginTop={1}>
245 <Text bold color="#ffb000">Waiting for a human ({waiting.length})</Text>
246 {waiting.map(p => (
247 <Text wrap="truncate-end">
248 <Text color="#ffb000">{p.id.slice(0, 8)} </Text>
249 <Text>{p.tool} </Text>
250 <Text dimColor>
251 {wide ? `${p.agent} · ` : ''}
252 {p.rule} · {ago(p.since, now)}
253 </Text>
254 </Text>
255 ))}
256 <Text dimColor wrap="truncate-end">resolve: mesh approve|deny {'<id>'}, or /mesh-approve</Text>
257 </Box>
258 )}
259
260 <Box flexDirection="column" marginTop={1}>
261 {list.length === 0 && <Text dimColor>No decision yet in this scope.</Text>}
262 {list.slice(0, room).map(d => (
263 <Text wrap="truncate-end">
264 <Text dimColor>{clock(d.at)} </Text>
265 <Text bold={d.verdict !== 'allow'} color={LOOK[d.verdict].color}>
266 {LOOK[d.verdict].label}
267 </Text>
268 <Text> {d.tool} </Text>
269 {d.approval !== '' && <Text color="#ffb000">[{d.approval}] </Text>}
270 {wide && <Text color="#7f8fa6">{d.agent} </Text>}
271 <Text dimColor>
272 {d.verdict === 'allow' ? '' : `${d.rule} · `}
273 {d.hint}
274 </Text>
275 </Text>
276 ))}
277 </Box>
278 </Box>
279 )
280 })
281}
282types/index.d.ts 42 lines1export type Verdict = 'allow' | 'deny' | 'human_approval'
2
3export type Decision = {
4 id: string
5 at: string
6 agent: string
7 tool: string
8 verdict: Verdict
9 rule: string
10 hint: string
11 approval: string
12}
13
14export type Pending = { id: string; agent: string; tool: string; rule: string; since: string }
15
16export type Health = { isUp: boolean; version: string; halt: string }
17
18// What this mod asks avatar7 to say, if avatar7 is loaded: mesh7 falling,
19// halting, coming back. avatar7 hears the write; nothing here depends on it.
20// tool: the call it is about, as Claude Code names it, so avatar7 drops its
21// own line for it; hold and release: a call held for a human, then decided.
22export type Say = {
23 mood: 'watch' | 'error' | 'deny' | 'wait'
24 event: string
25 at: number
26 tool?: string
27 hold?: boolean
28 release?: boolean
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'mesh7-pane': {
34 decisions: Decision[]
35 pending: Pending[]
36 health: Health
37 isAll: boolean
38 say: Say
39 }
40 }
41}
42