See Session Orchestrator status, warnings and session beacons in Claude Code (read-only, $.so contract v1).

A native, read-only Session Orchestrator display for Claude Code (package A, #1508): a status line for the running session, a warning band when something needs attention, and one beacon file per session that other sessions can read through $.so.beacons(). The status line shows how many peer sessions are running (from the session registry).
soRoot: the mod reads all state through its scripts/so-hud.mjs, which earlier releases do not have./plugin marketplace add Kanevry/session-orchestrator
/plugin install so-hud@kanevry
The mod finds the Session Orchestrator installation in this order: the soRoot setting (an absolute path), then the path cached from an earlier lookup, then a root registered with node scripts/so-hud.mjs --register-root inside that installation, then claude plugin list --json. The first hit is cached, so after a move only soRoot replaces it. None of these install actions is run by the development tests. For a local checkout you can also load this directory directly with claude --plugin-dir ./mods/so-hud.
Settings: strictness is locker (relaxed), ausgewogen (balanced, the default) or streng (strict); it is shown in the status line and returned by $.so.profile(), and does not change the warnings yet. soRoot is optional.
Session Orchestrator state comes only from scripts/so-hud.mjs, called as --json --repo-root <cwd> every 15 seconds with a 3-second timeout. STATE.md, locks, dispatch and events are never changed. The mod writes only its own beacon file and its install-path cache.
$.so.version() is 1; snapshot() and beacons() are asynchronous engine calls (await); profile() returns locker, ausgewogen or streng. A cold snapshot is null; an old one keeps its measurement time and carries age_ms; after a failed refresh it is kept and marked degraded.
Language: the status texts are German. An English text table is prepared but not selectable yet, because JSON v1 has no field for the owner's language.
Beacons: the $.fs API of this Claude Code build offers no rename, chmod or unlink, so hooks/beacon-io.mjs is called through $.process.run with argv and JSON on stdin: temporary file plus rename, mode 0600, beacon directory 0700, at most 4 KB. The helper's parent PID stands for the starting engine process; that mapping still has to be confirmed in a real engine host. Foreign beacons are validated; one older than 120 seconds whose PID cannot be shown to be alive becomes state unknown. tool.check: ask means handing the decision on (also in auto mode), not proof that a dialog was shown. The mod never changes a tool decision.
events:tail-only. There is no complete history.npx vitest --run tests/so-hud in the Session Orchestrator repo (CI job test). The CI job mods:plugin runs claude plugin validate --strict --json and claude plugin test for every mod. Engine integration separately with claude plugin test mods/so-hud; under Claude Code 2.1.296 the kit tests pass. tsconfig.json follows the header of the type file; the engine creates .claude-plugin/types when it loads the mod. No rollout, no gates, no commands from later packages and no second Session Orchestrator state.
hooks/register.tsx 220 lines1import type { EngineInterface, Register } from 'claude-code'
2import type { SoBeacon, SoProfile, SoSnapshot } from '../types/index'
3import { ageBeacon, completeState, findings, statusText, validId, validateBeacon } from './logic'
4
5// Only these closures cross helper boundaries; the engine table never does.
6type IO = {
7 run: EngineInterface['process']['run']
8 read: (path: string) => Promise<string>
9 list: EngineInterface['fs']['list']
10 now: EngineInterface['clock']['now']
11 env: () => ReturnType<EngineInterface['env']['get']>
12 storeGet: EngineInterface['store']['get']
13 storeSet: EngineInterface['store']['set']
14 sessionId: EngineInterface['session']['id']
15 cwd: EngineInterface['session']['cwd']
16 model: EngineInterface['session']['model']
17 usage: EngineInterface['session']['usage']
18 pluginRoot: () => string
19}
20
21export const register: Register = (on, options) => {
22 let io: IO
23 let current: SoSnapshot | null = null
24 let own: SoBeacon | null = null
25 let interactive = false
26 let refreshing = false
27 let ending = false
28 let cancel: (() => void) | undefined
29 let beaconWrites: Promise<unknown> = Promise.resolve()
30 const problems = new Set<string>()
31 const profile: SoProfile = options.strictness === 'locker' || options.strictness === 'streng' ? options.strictness : 'ausgewogen'
32 const helper = async (io: IO, action: string, id = '', input: unknown = null) => {
33 const result = await io.run(['node', `${io.pluginRoot()}/hooks/beacon-io.mjs`, action, id], { stdin: JSON.stringify(input), timeoutMs: 3000 })
34 if (result.exitCode !== 0) throw new Error('beacon-io')
35 return JSON.parse(result.stdout) as unknown
36 }
37 const snapshot = async (io: IO): Promise<SoSnapshot | null> => current ? {
38 ...current, age_ms: Math.max(0, await io.now() - Date.parse(current.measured_at)),
39 degraded: [...new Set([...current.degraded, ...problems])],
40 } : null
41 const root = async (io: IO): Promise<string | null> => {
42 if (typeof options.soRoot === 'string' && options.soRoot.startsWith('/')) {
43 await io.storeSet('so-root', options.soRoot)
44 return options.soRoot
45 }
46 const cached = await io.storeGet('so-root')
47 if (typeof cached === 'string' && cached.startsWith('/')) return cached
48 const home = await io.env()
49 let found: string | null = null
50 if (home) {
51 try { const p = (await io.read(`${home}/.config/session-orchestrator/hud/so-root`)).trim(); if (p.startsWith('/')) found = p } catch { /* try installed plugins */ }
52 }
53 if (!found) {
54 const result = await io.run(['claude', 'plugin', 'list', '--json'], { timeoutMs: 3000 })
55 if (result.exitCode === 0) {
56 const data = JSON.parse(result.stdout)
57 const rows: unknown[] = Array.isArray(data) ? data : Array.isArray(data.plugins) ? data.plugins : []
58 for (const row of rows) {
59 const p = row as { name?: string; id?: string; installPath?: string }
60 if ((p.name ?? p.id ?? '').split('@')[0] === 'session-orchestrator' && p.installPath?.startsWith('/')) { found = p.installPath; break }
61 }
62 }
63 }
64 if (found) await io.storeSet('so-root', found)
65 return found
66 }
67 const write = async (io: IO, change: Partial<SoBeacon> = {}) => {
68 if (ending || !own) return
69 const now = new Date(await io.now()).toISOString()
70 if (ending || !own) return
71 own = { ...own, ...change, seit: change.zustand && change.zustand !== own.zustand ? now : own.seit, aktualisiert_at: now }
72 const value = { ...own }
73 beaconWrites = beaconWrites.catch(() => undefined).then(() => helper(io, 'write', value.session_id, value))
74 await beaconWrites.catch(() => { problems.add('beacon-write-failed') })
75 }
76 const readBeacons = async (io: IO): Promise<SoBeacon[]> => {
77 const home = await io.env()
78 if (!home) return []
79 const dir = `${home}/.config/session-orchestrator/hud/beacons`
80 const entries = await io.list(dir).catch(() => [])
81 const beacons: SoBeacon[] = []
82 for (const f of entries) {
83 if (f.kind !== 'file' || f.isLink || f.size > 4096 || !f.name.endsWith('.json')) continue
84 try { const b: unknown = JSON.parse(await io.read(`${dir}/${f.name}`)); if (validateBeacon(b) && f.name === `${b.session_id}.json`) beacons.push(b) } catch { /* skip unreadable */ }
85 }
86 const alive = await helper(io, 'alive', '', beacons.map(b => b.pid)).catch(() => []) as number[]
87 const now = await io.now()
88 return beacons.map(b => ageBeacon(b, now, b.pid !== null && alive.includes(b.pid)))
89 }
90 const refresh = async (io: IO) => {
91 if (refreshing || ending) return
92 refreshing = true
93 try {
94 const soRoot = await root(io)
95 if (!soRoot) throw new Error('not-configured')
96 const result = await io.run(['node', `${soRoot}/scripts/so-hud.mjs`, '--json', '--repo-root', await io.cwd()], { timeoutMs: 3000 })
97 if (result.exitCode !== 0) throw new Error('collector-failed')
98 const value = JSON.parse(result.stdout) as SoSnapshot
99 if (value.schema_version !== 1 || !Number.isFinite(Date.parse(value.measured_at)) || !value.wave || !value.state || !value.gate || !value.vault || !Array.isArray(value.peers) || !Array.isArray(value.degraded)) throw new Error('invalid-snapshot')
100 current = value
101 for (const reason of ['not-configured', 'refresh-failed', 'collector-failed', 'invalid-snapshot']) problems.delete(reason)
102 } catch (error) {
103 for (const reason of ['not-configured', 'refresh-failed', 'collector-failed', 'invalid-snapshot']) problems.delete(reason)
104 const reason = error instanceof Error ? error.message : ''
105 problems.add(['not-configured', 'collector-failed', 'invalid-snapshot'].includes(reason) ? reason : 'refresh-failed')
106 }
107 finally { refreshing = false }
108 try {
109 const usage = await io.usage()
110 await write(io, { kontext_prozent: usage.context.percent ?? null, welle: current?.wave.number ?? null })
111 } catch { problems.add('usage-unavailable') }
112 }
113
114 const initialize = async (io: IO, cwd: string) => {
115 ending = false
116 const id = await io.sessionId()
117 if (own?.session_id !== id) current = null
118 if (validId(id)) {
119 const now = new Date(await io.now()).toISOString()
120 const identity = await helper(io, 'identity').catch(() => null) as { pid: number } | null
121 own = { schema_version: 1, session_id: id, name: null, repo_name: cwd.split('/').at(-1) ?? null, branch: null,
122 pid: identity?.pid ?? null, zustand: 'unbekannt', seit: now, warte_grund: null, heuristisch: false,
123 kontext_prozent: null, letzter_request_at: null, modell: await io.model().catch(() => null), welle: null, aktualisiert_at: now }
124 }
125 await refresh(io)
126 }
127
128 // Register the whole chain before any engine call. engine.create is post-order, never caught.
129 on('engine.create', async (_$, e, next) => {
130 const beneath = await next(e)
131 io = {
132 run: (argv, init) => beneath.process.run(argv, init),
133 read: (path) => beneath.fs.read(path),
134 list: (path) => beneath.fs.list(path),
135 now: () => beneath.clock.now(),
136 env: () => beneath.env.get('HOME'),
137 storeGet: (key) => beneath.store.get(key),
138 storeSet: (key, value) => beneath.store.set(key, value),
139 sessionId: () => beneath.session.id(),
140 cwd: () => beneath.session.cwd(),
141 model: () => beneath.session.model(),
142 usage: () => beneath.session.usage(),
143 pluginRoot: () => beneath.plugin.root,
144 }
145 return { ...beneath, so: { version: () => 1 as const, snapshot: () => snapshot(io), beacons: () => readBeacons(io), profile: () => profile } }
146 })
147 on('session.start', async ($, e, next) => {
148 interactive = e.isInteractive
149 await initialize(io, e.cwd)
150 const updateUI = async () => {
151 if (interactive && !ending) {
152 $.ui.status(statusText(await snapshot(io), profile, 'de', [...problems]))
153 $.ui.invalidate('ui.render')
154 }
155 }
156 await updateUI()
157 cancel?.()
158 const timer = $.clock.every(15_000, async () => {
159 try { await refresh(io); await updateUI() }
160 catch { problems.add('refresh-failed') }
161 })
162 cancel = () => timer.cancel()
163 return next(e)
164 }).catch(($, e, next) => next(e))
165 on('turn.start', async ($, e, next) => {
166 if (ending || !own || own.session_id !== await io.sessionId()) {
167 await initialize(io, await io.cwd())
168 const updateUI = async () => {
169 if (interactive && !ending) {
170 $.ui.status(statusText(await snapshot(io), profile, 'de', [...problems]))
171 $.ui.invalidate('ui.render')
172 }
173 }
174 await updateUI()
175 cancel?.()
176 const timer = $.clock.every(15_000, async () => {
177 try { await refresh(io); await updateUI() }
178 catch { problems.add('refresh-failed') }
179 })
180 cancel = () => timer.cancel()
181 }
182 await write(io, { zustand: 'arbeitet', warte_grund: null, heuristisch: false, letzter_request_at: new Date(await io.now()).toISOString() })
183 return next(e)
184 }).catch(($, e, next) => next(e))
185 on('turn.complete', async ($, e, next) => {
186 if (!e.agentId) await write(io, completeState(e.answer))
187 return next(e)
188 }).catch(($, e, next) => next(e))
189 on('tool.call', async ($, e, next) => {
190 if (e.agentId) return next(e)
191 if (e.tool === 'AskUserQuestion') await write(io, { zustand: 'wartet', warte_grund: 'dialog', heuristisch: false })
192 else if (own?.warte_grund === 'freigabe') await write(io, { zustand: 'arbeitet', warte_grund: null, heuristisch: false })
193 const result = await next(e)
194 // No telemetry error can cause this tool to execute twice.
195 await write(io, { zustand: 'arbeitet', warte_grund: null, heuristisch: false }).catch(() => { problems.add('beacon-write-failed') })
196 return result
197 }).catch(($, e, next) => next(e))
198 on('tool.check', async ($, e, next) => {
199 const result = await next(e)
200 if (result.decision === 'ask' && !e.agentId && e.tool_use_id && e.tool !== 'AskUserQuestion') await write(io, { zustand: 'wartet', warte_grund: 'freigabe', heuristisch: false }).catch(() => { problems.add('beacon-write-failed') })
201 return result
202 }).catch(($, e, next) => next(e))
203 on('session.end', async ($, e, next) => {
204 ending = true
205 cancel?.()
206 await beaconWrites.catch(() => undefined)
207 if (own) await helper(io, 'remove', own.session_id).catch(() => { problems.add('beacon-remove-failed') })
208 own = null
209 return next(e)
210 }).catch(($, e, next) => next(e))
211 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
212 const usage = await io.usage()
213 const lines = findings(await snapshot(io), usage.context, own?.letzter_request_at ?? null, await io.now(), [...problems])
214 if (!lines.length) return next(e)
215 const below = await next(e)
216 const { Box, Text } = $.ui.resolve(e)
217 return <Box flexDirection="column"><Text color="yellow">{lines.join(' · ')}</Text>{below}</Box>
218 }).catch(($, e, next) => next(e))
219}
220hooks/logic.ts 82 lines1import type { SoBeacon, SoProfile, SoSnapshot } from '../types/index'
2
3export function ownerQuestion(answer: string): boolean {
4 let fence: string | null = null
5 const plain = answer.split('\n').filter(line => {
6 const mark = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1]
7 if (mark) {
8 if (!fence) fence = mark
9 else if (mark[0] === fence[0] && mark.length >= fence.length) fence = null
10 return false
11 }
12 return fence === null
13 }).join('\n').trim()
14 return plain.endsWith('?') || /weiter mit|soll ich|welche option|freigabe für/i.test(plain.slice(-400))
15}
16export const tables = {
17 de: { red: 'rot', error: 'Fehler', off: 'aus', partial: 'teilweise gemessen', setup: 'nicht eingerichtet', measurement: 'Messung fehlgeschlagen', mirror: 'Vault-Spiegel fehlgeschlagen', peer: 'Peer im selben Working Copy', scope: 'Peer ohne bekannten Scope im selben Working Copy', context: 'Kontext', cold: 'Cache kalt' },
18 en: { red: 'red', error: 'error', off: 'off', partial: 'partially measured', setup: 'not configured', measurement: 'Measurement failed', mirror: 'Vault mirror failed', peer: 'Peer in the same working copy', scope: 'Peer with unknown scope in the same working copy', context: 'Context', cold: 'Cache cold' },
19} as const
20/** Bound each displayed field; replace terminal controls and line separators. */
21export function displayText(value: unknown, limit = 120): string {
22 const plain = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ')
23 return plain.length <= limit ? plain : `${plain.slice(0, limit - 1)}…`
24}
25const measurementProblems = new Set(['refresh-failed', 'collector-failed', 'invalid-snapshot'])
26function measurementFailure(problems: readonly string[]): string[] {
27 return problems.filter(p => measurementProblems.has(p))
28}
29export function time(at: string | null): string {
30 if (!at || !Number.isFinite(Date.parse(at))) return '?'
31 const d = new Date(at)
32 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
33}
34export function statusText(s: SoSnapshot | null, profile: SoProfile, language: keyof typeof tables = 'de', problems: readonly string[] = []): string {
35 const t = tables[language]
36 if (!s) {
37 const failed = measurementFailure(problems)
38 return displayText(`SO ${failed.length ? `${t.measurement} (${failed.join(', ')})` : t.setup} · ${profile}`)
39 }
40 const g = s.gate.last
41 const vault = s.vault
42 const bad = vault.last_run && (vault.last_run.aborted || (vault.last_run.failed ?? 0) > 0)
43 return displayText([s.state.session_type, s.wave.number !== null ? `W${s.wave.number}/${s.state.total_waves ?? '?'}` : null,
44 s.wave.role, s.wave.agents_total !== null ? `${s.wave.agents_total} Agents` : null,
45 g ? `Gate ${g.passed === true ? 'ok' : g.passed === false ? t.red : '?'} ${time(g.at)}` : null,
46 `${s.degraded.some(d => /discovery|registry/.test(d)) ? '?' : s.peers.length} Peer`, `Vault ${vault.enabled === false ? t.off : bad ? t.error : vault.enabled === true ? time(vault.last_run?.at ?? null) : '?'}`, profile,
47 s.degraded.length ? t.partial : null].filter(Boolean).map(value => displayText(value)).join(' · '), 800)
48}
49export function findings(s: SoSnapshot | null, context: { percent?: number; tokens?: number }, lastRequest: string | null, now: number, problems: readonly string[] = []): string[] {
50 const failed = measurementFailure(problems)
51 const out: string[] = failed.length ? [`${tables.de.measurement}: ${failed.join(', ')}`] : []
52 if (s?.gate.last?.passed === false) out.push(`Gate rot: ${s.gate.last.variant ?? '?'} ${s.gate.last.check_failed === true ? 'check_failed' : s.gate.last.exit_code ?? '?'}`)
53 if (s?.peers.some(p => p.gleiches_working_copy)) out.push(s.peers.some(p => p.gleiches_working_copy && p.scope_paths === null) ? tables.de.scope : tables.de.peer)
54 if (s?.vault.enabled && s.vault.last_run && (s.vault.last_run.aborted || (s.vault.last_run.failed ?? 0) > 0)) out.push(`${tables.de.mirror}: ${s.vault.last_run.aborted ?? s.vault.last_run.failed}`)
55 if ((context.percent ?? -1) >= 80) out.push(`Kontext ${context.percent} %`)
56 if (lastRequest && now - Date.parse(lastRequest) >= 3_600_000 && (context.tokens ?? 0) >= 100_000) out.push(tables.de.cold)
57 return out.map(value => displayText(value))
58}
59export const validId = (v: unknown): v is string => typeof v === 'string' && /^[a-zA-Z0-9_-]{1,200}$/.test(v)
60export function validateBeacon(v: unknown): v is SoBeacon {
61 if (!v || typeof v !== 'object') return false
62 const b = v as SoBeacon
63 const date = (s: unknown) => typeof s === 'string' && Number.isFinite(Date.parse(s))
64 const nullableString = (s: unknown) => s === null || typeof s === 'string'
65 return b.schema_version === 1 && validId(b.session_id) && ['arbeitet', 'wartet', 'bereit', 'blockiert', 'unbekannt'].includes(b.zustand)
66 && date(b.seit) && date(b.aktualisiert_at) && (b.letzter_request_at === null || date(b.letzter_request_at))
67 && [b.name, b.repo_name, b.branch, b.modell].every(nullableString)
68 && (b.pid === null || Number.isSafeInteger(b.pid) && b.pid > 0)
69 && (b.welle === null || Number.isSafeInteger(b.welle) && b.welle > 0)
70 && (b.kontext_prozent === null || Number.isFinite(b.kontext_prozent) && b.kontext_prozent >= 0 && b.kontext_prozent <= 100)
71 && typeof b.heuristisch === 'boolean' && [null, 'dialog', 'freigabe', 'frage'].includes(b.warte_grund)
72 && (b.zustand === 'wartet' ? b.warte_grund !== null : b.warte_grund === null)
73 && b.heuristisch === (b.warte_grund === 'frage')
74}
75export function ageBeacon(b: SoBeacon, now: number, alive: boolean): SoBeacon {
76 return now - Date.parse(b.aktualisiert_at) > 120_000 && !alive
77 ? { ...b, zustand: 'unbekannt', warte_grund: null, heuristisch: false } : b
78}
79export function completeState(answer: string): Pick<SoBeacon, 'zustand' | 'warte_grund' | 'heuristisch'> {
80 return ownerQuestion(answer) ? { zustand: 'wartet', warte_grund: 'frage', heuristisch: true } : { zustand: 'bereit', warte_grund: null, heuristisch: false }
81}
82types/index.d.ts 31 lines1/** Standalone public wire contract; null means unmeasured. Engine nouns are asynchronous. */
2export type SoProfile = 'locker' | 'ausgewogen' | 'streng'
3export type SoState = 'arbeitet' | 'wartet' | 'bereit' | 'blockiert' | 'unbekannt'
4export interface SoBeacon {
5 schema_version: 1; session_id: string; name: string | null; repo_name: string | null
6 branch: string | null; pid: number | null; zustand: SoState; seit: string
7 warte_grund: 'dialog' | 'freigabe' | 'frage' | null; heuristisch: boolean
8 kontext_prozent: number | null; letzter_request_at: string | null; modell: string | null
9 welle: number | null; aktualisiert_at: string
10}
11export interface SoSnapshot {
12 schema_version: 1; measured_at: string; age_ms: number
13 repo: { root: string; name: string }
14 lock: { session_id: string | null; live: boolean; last_heartbeat: string | null } | null
15 session: { session_id: string; semantic_session_id: string | null } | null
16 wave: { running: boolean | null; number: number | null; role: string | null; agents_total: number | null; source: string | null }
17 agents: { entries: { id: string; typ: string | null; modellfamilie: string | null; status: string | null; tokens: null; letzter_request_at: null; quelle: string }[]; source: string | null; at: string | null; degraded: unknown }
18 state: { status: string | null; session_type: string | null; current_wave: number | null; total_waves: number | null }
19 gate: { last: { variant: string | null; passed: boolean | null; check_failed: boolean | null; exit_code: number | null; at: string | null } | null; last_green_sha: string | null }
20 peers: { session_id: string; branch: string | null; status: string | null; current_wave: number | null; last_heartbeat: string | null; gleiches_working_copy: boolean; scope_paths: string[] | null }[]
21 vault: { enabled: boolean | null; mode: string | null; last_run: { at: string | null; created: number | null; updated: number | null; failed: number | null; aborted: string | null } | null; board: { action: string | null; at: string | null } | null }
22 degraded: string[]
23}
24export interface So {
25 version(): 1
26 snapshot(): Promise<SoSnapshot | null>
27 beacons(): Promise<SoBeacon[]>
28 profile(): SoProfile
29}
30declare module 'claude-code' { interface EngineInterface { so: So } }
31