OmniMemo agent cards, context usage reporting and opt-in fresh-context rollover for Claude Code.

A Claude Code mod (function hooks, Claude Code 2.1.302+) for OmniMemo. It runs the local om CLI and needs no server or token.
/om-agents opens a pane of agent cards: client-model · STATUS, current task, verified-milestone progress (indeterminate without milestones) and reported context usage. Offline sessions and sessions with no recent signal (stale) are left out; the pane counts the stale ones it hides.● codex · gpt-6.1-sol · working · <task> · 12s. This session is not listed. Claude Code's own tasks list below the prompt is not open to mods, so the band is the closest place.OmniMemo · 3 agents · 2 working · 1 blocked · ctx 42%.$.session.usage(), the same numbers as Claude's status line) to OmniMemo.rollover (/config):suggest (default): saves a checkpoint at prepareAt (70 %), and at rolloverAt (80 %) suggests /om-rollover.auto: at rolloverAt, it runs the handoff below by itself, at most 3 times an hour.off: reports usage only./om-rollover runs the handoff now.om rollover prepare saves the capsule as a checkpoint first: tasks, unverified milestones, git branch/commit and changed files.om rollover begin reserves the only child of this session. A session that already handed off gets no second one./clear. The conversation ends and Claude Code continues under a new session id with an empty context; nothing of the old conversation is carried over.om rollover link records parent → child.ACK <digest>.om rollover report --child moves the task to the new session and retires the parent (rollover.completed).rollover.failed) and stays resumable with claude --resume <parent id>./clear is refused, the session is unchanged and the checkpoint stays saved.Installed: bash install.sh --global --with-hooks packages the plugin beside the binary and runs claude plugin install omniverse-bridge@omnimemo (user scope); it replaces the OmniMemo settings hooks, so do not also keep this folder in CLAUDE_CODE_PLUGIN_DIRS (that loads it twice). While developing, one session: claude --plugin-dir plugins/omniverse-bridge-mod. Set options in /config, or in settings under pluginConfigs["omniverse-bridge@omnimemo"].options, the installed plugin's id (for example { "rollover": "auto" }).
The mod runs with your permissions (it calls om through $.process); review it before loading. Checks:
claude plugin validate plugins/omniverse-bridge-mod
claude plugin test plugins/omniverse-bridge-mod
Delegation to other CLIs: the plugin adds two Claude subagent types, omniverse-bridge:codex and omniverse-bridge:opencode. Ask for one by name ("run it with the codex subagent"); it writes a task brief, runs om codex run / om opencode run (read-only unless you asked for edits, about ten minutes at most) and reports the answer. The delegated run's card is linked to your session (--spawned-by), so it shows in Claude's own subagent list and not again in the band.
hooks/register.tsx 564 lines1// Omniverse bridge mod: OmniMemo presence, agent cards, reported context usage and opt-in rollover.
2// Everything goes through the local `om` CLI (no server, no token). Telemetry is the host's
3// own numbers; tool arguments are never sent. See docs/RUNTIME_EVENTS.md.
4// Lifecycle (session, prompt, tool, turn) comes from the plugin's own engine events, so no
5// Claude Code settings hooks are needed for OmniMemo.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, TurnUsage } from 'claude-code'
8
9import type { AgentCard, AgentRecord } from '../types'
10
11const PANE = 'omniverse-agent-cards'
12const SESSION_PANE = 'omniverse-agent-session'
13const agents = atom({ plugin: 'omniverse-bridge', key: 'agents' } as const, [] as AgentCard[])
14const report = atom({ plugin: 'omniverse-bridge', key: 'report' } as const, 'No bridge reading yet')
15const self = atom({ plugin: 'omniverse-bridge', key: 'self' } as const, '')
16const focus = atom({ plugin: 'omniverse-bridge', key: 'focus' } as const, '')
17const records = atom({ plugin: 'omniverse-bridge', key: 'records' } as const, [] as AgentRecord[])
18
19/** Offline sessions and sessions with no recent signal (stale, model unavailable) are hidden; the count stays visible. */
20export function present(list: AgentCard[]) {
21 return list.filter(a => a.status !== 'offline' && a.status !== 'stale')
22}
23
24/** Other agents for the band: present, not this session, busiest first. Claude subagents are
25 * left out (each Claude lists its own); a Codex or OpenCode run delegated from here stays in,
26 * since Claude's list shows only the subagent waiting on it, not the run's progress. */
27export function others(list: AgentCard[], session: string) {
28 const order = ['blocked', 'working', 'waiting']
29 return present(list)
30 .filter(a => a.session_id !== session && !(a.spawned_by && (a.client ?? a.display_slug.split('-')[0]) === 'claude'))
31 .sort((a, b) => order.indexOf(a.status) - order.indexOf(b.status))
32}
33
34/** `codex · gpt-6.1-sol`: the client and its reported model, or "model unknown". */
35export function agentLabel(a: AgentCard) {
36 const client = a.client ?? a.display_slug.split('-')[0]
37 const model = a.model && !a.model.includes('unavailable') ? a.model : 'model unknown'
38 return `${client} · ${model}`
39}
40
41const clip = (text: string, n = 160) => (text.length > n ? text.slice(0, n - 1) + '…' : text)
42
43/** Readable lines from a saved record: JSON checkpoints become `key: value` and bullet lines; text stays text. */
44export function recordLines(content: string): string[] {
45 let value: unknown
46 try {
47 value = JSON.parse(content)
48 } catch {
49 return content.split('\n').filter(l => l.trim()).slice(0, 6).map(l => clip(l))
50 }
51 if (!value || typeof value !== 'object' || Array.isArray(value)) return [clip(content)]
52 const out: string[] = []
53 for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
54 if (typeof v === 'string' || typeof v === 'number') out.push(clip(`${key}: ${v}`))
55 else if (Array.isArray(v) && v.length) {
56 out.push(`${key}:`)
57 for (const item of v.slice(0, 6))
58 out.push(clip(' • ' + (item && typeof item === 'object' ? Object.values(item).join(' · ') : String(item))))
59 }
60 }
61 return out.slice(0, 24)
62}
63
64/** The OmniMemo records this agent saved, newest first (the agent label is provenance, not identity). */
65export function recordsOf(list: { agent?: string; id: string; title: string; kind: string; state: string; created_at: string; content?: string }[], client: string) {
66 return list
67 .filter(m => m.agent === client)
68 .slice(0, 5)
69 .map(m => ({ id: m.id, title: m.title, kind: m.kind, state: m.state, created_at: m.created_at, lines: recordLines(m.content ?? '') }))
70}
71
72const mark = (status: string) => (status === 'working' ? '●' : status === 'blocked' ? '◆' : '○')
73const tone = (status: string) => (status === 'working' ? 'success' : status === 'blocked' ? 'warning' : undefined)
74
75/** What is known about an agent, in order; unknown values are left out rather than printed as "unknown". */
76export function facts(a: AgentCard, now: number) {
77 const out = [a.agent_type === 'delegated' ? `delegated · ${a.status}` : a.status]
78 if (a.task) out.push(a.task.title)
79 else if (a.tool_category) out.push(a.tool_category.replace('_', ' '))
80 const p = a.progress
81 if (p.kind === 'determinate' && typeof p.percent === 'number')
82 out.push(`${p.percent}%` + (p.total ? ` (${p.completed}/${p.total})` : ''))
83 if (a.context.used_percent !== undefined) out.push(`ctx ${Math.round(a.context.used_percent)}%`)
84 if (a.last_seen) out.push(ago(a.last_seen, now))
85 return out
86}
87
88export function ago(iso: string | undefined, now: number) {
89 if (!iso) return ''
90 const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000))
91 return s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.round(s / 3600)}h`
92}
93
94/** A handoff in flight: the parent's checkpoint saved, then the fresh child awaiting its ACK. */
95type Pending = { parent: string; child?: string; digest: string; capsule: string; lastRequest?: string; startedAt: number }
96
97/** A child that never finishes its first turn must not block every later rollover. */
98const pendingLimitMs = 15 * 60_000
99
100// Options are read in register on each load; the rest starts over on a reload.
101const config = { om: 'om', mode: 'suggest', rolloverAt: 80, prepareAt: 70, windows: new Map<string, number>() }
102
103/** `claude-sonnet-5-5=1000000, claude-haiku-5-5=200000`: windows the person states for models
104 * other than the session's, which the host does not report. Bad entries are ignored. */
105export function parseWindows(text: string) {
106 const windows = new Map<string, number>()
107 for (const entry of text.split(',')) {
108 const [model, size] = entry.split('=').map(s => s.trim())
109 const tokens = Number(size)
110 if (model && Number.isInteger(tokens) && tokens > 0) windows.set(model, tokens)
111 }
112 return windows
113}
114let pending: Pending | undefined
115let lastLevel = 'unknown'
116const rollovers: number[] = []
117
118export function levelOf(percent: number, prepareAt: number, rolloverAt: number) {
119 return percent >= 90 ? 'critical' : percent >= rolloverAt ? 'handoff' : percent >= prepareAt ? 'prepare' : 'normal'
120}
121
122/** Parses `om rollover prepare` output: the capsule, then `Handoff: <id> · digest <16 hex> (...)`. */
123export function parsePrepared(stdout: string): { capsule: string; digest: string } | undefined {
124 const lines = stdout.trimEnd().split('\n')
125 const digest = lines.at(-1)?.match(/digest ([0-9a-f]{16})/)?.[1]
126 return digest ? { capsule: lines.slice(0, -1).join('\n'), digest } : undefined
127}
128
129/** True when the reply's first line is exactly the acknowledgement token. */
130export function acknowledged(answer: string, digest: string) {
131 const first = answer.trimStart().split('\n')[0]?.trim().replace(/^`+|`+$/g, '')
132 return first === 'ACK ' + digest
133}
134
135/** The child's first reply text after the capsule prompt. A turn's `answer` is its final text,
136 * which follows any tool work, so the ACK that opens the turn is read from the transcript. */
137export function firstReply(messages: readonly { role: string; text: string }[], digest: string) {
138 const asked = messages.findLastIndex(m => m.role === 'user' && m.text.includes('ACK ' + digest))
139 if (asked < 0) return undefined
140 return messages.slice(asked + 1).find(m => m.role === 'assistant' && m.text.trim())?.text
141}
142
143function run($: EngineInterface, args: string[], stdin?: string, timeoutMs = 30_000) {
144 return $.process.run([config.om, ...args], { stdin, timeoutMs })
145}
146
147/**
148 * One lifecycle signal as `om hook claude` reads it, the payload a settings hook would have sent:
149 * the event name, session id, working directory and model, plus the tool name or prompt where
150 * that event carries one. Never tool arguments. Failures are ignored: presence never blocks Claude.
151 */
152async function lifecycle($: EngineInterface, event: string, extra: Record<string, unknown> = {}, timeoutMs = 5_000) {
153 try {
154 const payload = { hook_event_name: event, session_id: await $.session.id(), cwd: await $.session.cwd(), model: await $.session.model(), ...extra }
155 const r = await run($, ['hook', 'claude'], JSON.stringify(payload), timeoutMs)
156 return r.exitCode === 0 ? r.stdout.trim() : ''
157 } catch {
158 return ''
159 }
160}
161
162async function refresh($: EngineInterface, context?: number) {
163 const ctx = context === undefined ? '' : ` · ctx ${Math.round(context)}%`
164 try {
165 const r = await run($, ['runtime', 'sessions'])
166 if (r.exitCode !== 0) throw new Error('om exited ' + r.exitCode)
167 const all = JSON.parse(r.stdout) as AgentCard[]
168 const live = present(all).slice(0, 32)
169 await update($, agents, () => live)
170 const session = await $.session.id()
171 await update($, self, () => session)
172 const count = (s: string) => live.filter(a => a.status === s).length
173 const quiet = all.filter(a => a.status === 'stale').length
174 const hidden = quiet ? ` · ${quiet} with no recent signal hidden` : ''
175 await update($, report, () => (live.length ? `${live.length} agent(s) present${hidden}` : `No agents present${hidden}`))
176 $.ui.status(`OmniMemo · ${live.length} ${live.length === 1 ? 'agent' : 'agents'} · ${count('working')} working · ${count('blocked')} blocked${ctx}`)
177 } catch {
178 await update($, agents, () => [])
179 await update($, report, () => 'Bridge unavailable')
180 $.ui.status('OmniMemo unavailable · run om doctor' + ctx)
181 }
182}
183
184/** Checkpoint, reserve the only child, /clear into a fresh session, deliver the capsule once. */
185async function rollOver($: EngineInterface, parent: string, why: string) {
186 await expirePending($)
187 if (pending) return 'A rollover is already in progress.'
188 const prepared = await run($, ['--agent', 'claude', 'rollover', 'prepare', '--session', parent, '--provider', 'claude'])
189 const parsed = prepared.exitCode === 0 ? parsePrepared(prepared.stdout) : undefined
190 if (!parsed) return 'Rollover not started: om rollover prepare failed; nothing changed.'
191 const reserved = await run($, ['rollover', 'begin', '--session', parent, '--provider', 'claude'])
192 if (reserved.exitCode !== 0) return 'Rollover not started: this session already handed off once (one child per session).'
193 const lastUser = (await $.session.messages()).filter(m => m.role === 'user' && m.text.trim()).at(-1)
194 pending = { parent, ...parsed, lastRequest: lastUser?.text.slice(0, 1200), startedAt: Date.now() }
195 const fail = async (reason: string) => {
196 await run($, ['rollover', 'report', '--session', parent, '--digest', parsed.digest, '--outcome', 'failed', '--reason', reason])
197 pending = undefined
198 return `Rollover not applied (${reason}). This session is unchanged; the checkpoint is saved.`
199 }
200 // /clear ends this conversation; the process goes on under a new session id with an empty context.
201 const cleared = await $.command.run({ command: 'clear' }).then(() => true, () => false)
202 if (!cleared) return fail('/clear did not run')
203 const child = await $.session.id()
204 if (child === parent) return fail('no new session after /clear')
205 const linked = await run($, ['rollover', 'link', '--session', parent, '--child', child, '--provider', 'claude'])
206 if (linked.exitCode !== 0) return fail('the fresh session could not be linked')
207 pending = { ...pending, child }
208 rollovers.push(Date.now())
209 const request = pending.lastRequest
210 ? `\nLast request from the person (verbatim, cut to 1200 characters):\n${pending.lastRequest}\n`
211 : ''
212 void $.prompt.submit({
213 text: `${parsed.capsule}${request}\nOmniMemo rollover (${why}) from session ${parent}. First line of your reply must be exactly \`ACK ${parsed.digest}\`; then resume your own task from the records above, verifying them against the repository.`,
214 })
215 return `Fresh session ${child} started from the verified capsule (digest ${parsed.digest}); waiting for the acknowledgement.`
216}
217
218/** Records an unanswered handoff as failed once it is older than the limit. */
219async function expirePending($: EngineInterface) {
220 if (!pending || Date.now() - pending.startedAt < pendingLimitMs) return
221 const child = pending.child ? ['--child', pending.child] : []
222 await run($, ['rollover', 'report', '--session', pending.parent, ...child, '--digest', pending.digest, '--outcome', 'failed', '--reason', 'no acknowledgement in time'])
223 pending = undefined
224}
225
226/** After a main turn: settle an ACK, report usage, then prepare or hand off on a rising level. */
227async function afterTurn($: EngineInterface, answer: string) {
228 await expirePending($)
229 const session = await $.session.id()
230 if (pending?.child && pending.child === session) {
231 const { digest } = pending
232 const reply = await $.session.messages().then(m => firstReply(m, digest), () => undefined)
233 const ok = acknowledged(reply ?? answer, pending.digest)
234 await run($, ['rollover', 'report', '--session', pending.parent, '--child', session, '--digest', pending.digest, '--outcome', ok ? 'completed' : 'failed', '--reason', ok ? 'acknowledged' : 'first line was not the ACK'])
235 $.ui.toast(
236 ok
237 ? 'OmniMemo rollover acknowledged: the task moved to this session.'
238 : `OmniMemo rollover not acknowledged; the parent keeps the task (claude --resume ${pending.parent}).`,
239 )
240 pending = undefined
241 }
242 const { context } = await $.session.usage()
243 const percent = context.percent
244 if (percent !== undefined) {
245 await run($, ['hook', 'claude'], JSON.stringify({
246 hook_event_name: 'context.usage', session_id: session, cwd: await $.session.cwd(),
247 model: await $.session.model(), context_window: { used_percentage: percent, context_window_size: context.window },
248 }))
249 }
250 await refresh($, percent)
251 if (percent === undefined || config.mode === 'off' || pending) return
252 const level = levelOf(percent, config.prepareAt, config.rolloverAt)
253 const rising = level !== lastLevel
254 lastLevel = level
255 if (!rising) return
256 if (level === 'prepare') {
257 await run($, ['--agent', 'claude', 'rollover', 'prepare', '--session', session, '--provider', 'claude'])
258 $.ui.toast(`Context ${Math.round(percent)}%: OmniMemo checkpoint saved.`)
259 } else if (level === 'handoff' || level === 'critical') {
260 const recent = rollovers.filter(t => Date.now() - t < 3_600_000)
261 if (config.mode === 'auto' && recent.length < 3) {
262 $.ui.toast(await rollOver($, session, `context ${Math.round(percent)}%`))
263 } else {
264 $.ui.toast(`Context ${Math.round(percent)}%: run /om-rollover for a fresh context from the OmniMemo capsule.`)
265 }
266 }
267}
268
269/** Opens one agent's OmniMemo record: its card plus what it saved. Never its private conversation. */
270async function openAgent($: EngineInterface, card: AgentCard) {
271 const client = card.client ?? card.display_slug.split('-')[0] ?? ''
272 await update($, focus, () => card.session_id ?? card.display_slug)
273 const r = await run($, ['memory', 'list', '--limit', '60'])
274 const list = r.exitCode === 0 ? recordsOf(JSON.parse(r.stdout), client) : []
275 await update($, records, () => list)
276 await $.ui.open({ id: SESSION_PANE, title: agentLabel(card), focus: true })
277}
278
279/** Subagents seen in this session: who spawned each, and which have ended. */
280const parents = new Map<string, string>()
281const ended = new Set<string>()
282
283/** A subagent's event names it and its parent, so it is linked whichever event arrives first. */
284async function subagent($: EngineInterface, agentId: string) {
285 return { session_id: agentId, model: undefined, spawned_by: parents.get(agentId) ?? (await $.session.id()) }
286}
287
288/** Other CLIs as Claude subagent types: each hands one task to its CLI through `om` and reports
289 * back. Read-only by default; offered only for a request that names that CLI. */
290const WORKERS = [
291 { name: 'codex', cli: 'Codex (OpenAI)', run: 'codex run', write: '--sandbox workspace-write' },
292 { name: 'opencode', cli: 'OpenCode', run: 'opencode run', write: '--write' },
293]
294
295function workerPrompt(w: (typeof WORKERS)[number]) {
296 return `You hand one task to ${w.cli}, another coding agent CLI, through OmniMemo, then report back.
2971. Write the task as a self-contained brief to a new file in $TMPDIR (use Write): the goal, the files or area in scope, what done means, and constraints. Include only what the task needs: never secrets, credentials, or this conversation.
2982. Run it with Bash, Bash timeout 600000: \`${config.om} --agent claude ${w.run} --prompt-file <file> --timeout 570 --spawned-by "$CLAUDE_CODE_SESSION_ID"\`
299 A model named for the task goes in \`--model <id>\`, not in the brief. It is read-only by default. Add \`${w.write}\` only when the task you were given explicitly asks for file edits. A task too big for ten minutes: say so and suggest how to split it.
3003. Reply with what it reported: its answer, the files it changed (\`git status --short\`), and any failure exactly as printed. Do not redo its work yourself. Never commit, push, or change git settings.
301If the command is missing or fails to start, say so and stop.`
302}
303
304async function registerWorkers($: EngineInterface) {
305 for (const w of WORKERS) {
306 await $.agent.register({
307 name: w.name,
308 description: `Hands one self-contained task to the ${w.cli} CLI through OmniMemo and returns its answer. Use only when the user asks for ${w.name} (for example "run it with ${w.name}" or "${w.name} subagent"). Read-only unless the user asked for edits.`,
309 prompt: workerPrompt(w),
310 tools: ['Bash', 'Write', 'Read'],
311 model: 'haiku',
312 }).catch(() => undefined) // a host without agent types keeps the rest of the bridge
313 }
314}
315
316/** Last context sample (tokens) sent per subagent, so a step that barely moved it spawns no `om`. */
317const sampled = new Map<string, number>()
318
319/** A subagent's context: its last request's tokens. Over the session's window when it runs the
320 * session's model; on any other model the window is unknown, so tokens only, never a guessed percent. */
321async function subagentContext($: EngineInterface, agentId: string, usage: TurnUsage) {
322 try {
323 const { context } = await $.session.usage()
324 const base = (m: string) => m.replace(/\[.*\]$/, '')
325 const window =
326 (context.window && base(usage.model) === base(await $.session.model()) ? context.window : undefined)
327 ?? config.windows.get(base(usage.model))
328 const tokens = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.output_tokens
329 if (Math.abs((sampled.get(agentId) ?? -Infinity) - tokens) < (window ? window / 100 : 5_000)) return
330 sampled.set(agentId, tokens)
331 const sample = window
332 ? { used_percentage: Math.min(100, Math.round((tokens / window) * 100)), context_window_size: window }
333 : { used_tokens: tokens }
334 await run($, ['hook', 'claude'], JSON.stringify({
335 hook_event_name: 'context.usage', session_id: agentId, cwd: await $.session.cwd(), model: usage.model,
336 context_window: sample,
337 }))
338 } catch {
339 // Context is best effort: a failed sample leaves the card's context unknown.
340 }
341}
342
343export const register: Register = (on, options) => {
344 config.om = String(options.omPath || 'om')
345 config.mode = String(options.rollover || 'suggest')
346 config.rolloverAt = Number(options.rolloverAt ?? 80)
347 config.prepareAt = Math.min(Number(options.prepareAt ?? 70), config.rolloverAt)
348 config.windows = parseWindows(String(options.contextWindows ?? ''))
349
350 on('session.start', async ($, e, next) => {
351 void lifecycle($, 'SessionStart')
352 await $.command.register({ name: 'om-agents', description: 'Show the other agents OmniMemo sees (Codex, OpenCode, other Claude sessions)' })
353 await $.command.register({ name: 'om-agent', description: "Show another agent's OmniMemo card and saved records (not its chat): /om-agent codex" })
354 await $.command.register({ name: 'om-rollover', description: 'Save a checkpoint and continue this work in a fresh, empty conversation' })
355 $.clock.after(300, () => void refresh($))
356 $.clock.every(15_000, () => void refresh($))
357 const result = await next(e)
358 void registerWorkers($) // agent types are accepted once the session has bound
359 return result
360 })
361
362 on('command.run', { command: 'om-agents' }, async $ => {
363 await refresh($)
364 await $.ui.open({ id: PANE, title: 'Omniverse · Agents', focus: true })
365 return { text: 'Omniverse agent cards opened.' }
366 })
367
368 on('command.run', { command: 'om-agent' }, async ($, e) => {
369 const want = e.args.trim()
370 const list = others(await read($, agents), await read($, self))
371 const card = want
372 ? list.find(a => a.session_id === want || a.client === want || agentLabel(a).startsWith(want))
373 : list[0]
374 if (!card) {
375 if (!list.length) return { text: 'No other agent is present in OmniMemo right now.' }
376 return { text: `No present agent matches "${want}". Present: ${list.map(agentLabel).join(', ')}.` }
377 }
378 await openAgent($, card)
379 return { text: `Opened ${agentLabel(card)} from OmniMemo.` }
380 })
381
382 // /clear cannot run under the command's own turn, so the handoff starts just after it.
383 on('command.run', { command: 'om-rollover' }, async $ => {
384 const session = await $.session.id()
385 $.clock.after(100, () => void rollOver($, session, 'requested with /om-rollover').then(text => $.ui.toast(text)))
386 return { text: 'OmniMemo rollover starting: checkpoint, then a fresh session from the capsule.' }
387 })
388
389 // The project capsule rides with the prompt as context the model reads and the person never sees;
390 // `om` returns it only when it changed since this session last received it.
391 on('prompt.submit', async ($, e, next) => {
392 if (e.command || e.isMeta) return next(e)
393 const capsule = await lifecycle($, 'UserPromptSubmit', { prompt: e.text })
394 return next(capsule ? { ...e, context: [...(e.context ?? []), capsule] } : e)
395 }).catch(($, e, next) => next(e)) // fail open: the prompt always goes through
396
397 // A subagent is its own OmniMemo session (its loop's agentId), linked to the loop that spawned it.
398 // Its spawn can settle after its tool calls, or after it ended: then it only fills in the type.
399 on('agent.spawn', async ($, e, next) => {
400 const result = await next(e)
401 if (result.agentId) {
402 const parent = e.parentAgentId ?? (await $.session.id())
403 parents.set(result.agentId, parent)
404 void lifecycle($, ended.has(result.agentId) ? 'SubagentStop' : 'SubagentStart', {
405 session_id: result.agentId, model: result.model, spawned_by: parent, agent_type: e.subagentType,
406 // The call's few-word task label; `om` keeps it only where session titles are opted in.
407 title: e.description,
408 })
409 }
410 return result
411 }).catch(($, e, next) => next(e)) // fail open: never refuses a spawn
412
413 // Tool activity by category: `om` maps the tool name and reads nothing else of the call.
414 // A subagent's call counts for the subagent, not for the main session.
415 on('tool.call', async ($, e, next) => {
416 const who = e.agentId ? await subagent($, e.agentId) : {}
417 void lifecycle($, 'PreToolUse', { tool_name: e.tool, ...who })
418 const result = await next(e)
419 const failed = result.deny !== undefined || result.isError === true
420 void lifecycle($, failed ? 'PostToolUseFailure' : 'PostToolUse', { tool_name: e.tool, ...who })
421 return result
422 }).catch(($, e, next) => next(e)) // fail open: never refuses or repeats a tool call
423
424 // A subagent's requests carry its context use; the main session's comes from session.usage.
425 on('turn.step', async function* ($, e, next) {
426 const result = yield* next(e)
427 if (e.agentId && result.usage) void subagentContext($, e.agentId, result.usage)
428 return result
429 })
430
431 on('session.end', async ($, e, next) => {
432 // The engine allows about 1.5 s for every session.end hook together.
433 await lifecycle($, 'SessionEnd', { session_id: e.sessionId, reason: e.reason }, 1_200)
434 return next(e)
435 })
436
437 on('turn.complete', async ($, e, next) => {
438 const result = await next(e)
439 if (e.agentId === undefined) void lifecycle($, 'Stop')
440 else {
441 ended.add(e.agentId)
442 void lifecycle($, 'SubagentStop', await subagent($, e.agentId))
443 }
444 // After the turn, so /clear and prompts run with the session idle.
445 if (e.agentId === undefined) $.clock.after(200, () => void afterTurn($, e.answer))
446 return result
447 })
448
449 // Other terminal agents (Codex, OpenCode, Gemini, other Claude sessions) as rows by the
450 // prompt, the way Claude's own subagents list: client · model, state, task, last signal.
451 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
452 if (e.props.hasSurvey) return next(e)
453 const rows = others(await read($, agents), await read($, self))
454 if (!rows.length) return next(e)
455 const { Box, Button, Text } = $.ui.resolve(e)
456 const now = await $.clock.now()
457 return (
458 <Box flexDirection="column">
459 {rows.slice(0, 6).map(a => (
460 <Button key={a.session_id ?? a.display_slug} onPress={() => openAgent($, a)}>
461 <Text wrap="truncate-end">
462 <Text color={tone(a.status)}>{mark(a.status)} </Text>
463 <Text bold>{agentLabel(a)}</Text>
464 <Text dimColor>{' · ' + facts(a, now).join(' · ')}</Text>
465 </Text>
466 </Button>
467 ))}
468 {rows.length > 6 ? <Text dimColor>{rows.length - 6} more · /om-agents</Text> : null}
469 </Box>
470 )
471 })
472
473 on('ui.render', { component: 'Pane', requestId: SESSION_PANE }, async ($, e) => {
474 const { Box, Text } = $.ui.resolve(e)
475 const id = await read($, focus)
476 const card = (await read($, agents)).find(a => (a.session_id ?? a.display_slug) === id)
477 const saved = await read($, records)
478 const now = await $.clock.now()
479 return (
480 <Box flexDirection="column">
481 {card ? (
482 <Box flexDirection="column" marginBottom={1}>
483 <Text bold>
484 {agentLabel(card)} · {card.status.toUpperCase()}
485 </Text>
486 <Text>{card.task ? `Task: ${card.task.title}` : 'No active task'}</Text>
487 <Text dimColor>
488 {card.tool_category ? `last tool: ${card.tool_category} · ` : ''}
489 {card.last_seen ? `last signal ${ago(card.last_seen, now)} ago` : 'last signal unknown'}
490 {card.context.used_percent === undefined ? ' · context unknown' : ` · context ${Math.round(card.context.used_percent)}%`}
491 </Text>
492 </Box>
493 ) : (
494 <Text dimColor>This session is no longer present (offline or no recent signal).</Text>
495 )}
496 <Text dimColor>Saved to OmniMemo by this agent{saved.length ? '' : ': nothing yet'}</Text>
497 {saved.map(m => (
498 <Box key={m.id} flexDirection="column" marginTop={1}>
499 <Text bold>
500 {m.title} <Text dimColor>· {m.kind} · {m.state} · {ago(m.created_at, now)} ago</Text>
501 </Text>
502 {m.lines.map((line, i) => (
503 <Text key={String(i)} wrap="wrap">
504 {line}
505 </Text>
506 ))}
507 </Box>
508 ))}
509 <Text dimColor>
510 From OmniMemo: status, task and saved records only. Its private conversation stays in its own client.
511 </Text>
512 </Box>
513 )
514 })
515
516 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
517 const { Box, Button, Text } = $.ui.resolve(e)
518 const list = await read($, agents)
519 const line = await read($, report)
520 const me = await read($, self)
521 const now = await $.clock.now()
522 return (
523 <Box flexDirection="column">
524 <Text dimColor>{line}</Text>
525 {list.map(a => {
526 const isMe = a.session_id === me
527 const p = a.progress
528 const bar =
529 p.kind === 'determinate' && typeof p.percent === 'number'
530 ? '█'.repeat(Math.round(p.percent / 10)) + '░'.repeat(10 - Math.round(p.percent / 10))
531 : undefined
532 const row = (
533 <Box flexDirection="column" marginTop={1}>
534 <Text wrap="truncate-end">
535 <Text color={tone(a.status)}>{mark(a.status)} </Text>
536 <Text bold>{agentLabel(a)}</Text>
537 <Text dimColor>{isMe ? ' · this session' : ''}</Text>
538 </Text>
539 <Text wrap="truncate-end">
540 {' '}
541 {a.task?.title ?? (a.tool_category ? `tool: ${a.tool_category.replace('_', ' ')}` : 'no active task')}
542 </Text>
543 <Text dimColor wrap="truncate-end">
544 {' '}
545 {[a.status, bar ? `${bar} ${p.percent}%` : '', a.context.used_percent !== undefined ? `ctx ${Math.round(a.context.used_percent)}% ${a.context.level}` : '', a.last_seen ? ago(a.last_seen, now) + ' ago' : '']
546 .filter(Boolean)
547 .join(' · ')}
548 </Text>
549 </Box>
550 )
551 return isMe ? (
552 <Box key={a.session_id ?? a.display_slug}>{row}</Box>
553 ) : (
554 <Button key={a.session_id ?? a.display_slug} onPress={() => openAgent($, a)}>
555 {row}
556 </Button>
557 )
558 })}
559 {list.length > 1 ? <Text dimColor>{'\n'}Select another agent to open its OmniMemo record.</Text> : null}
560 </Box>
561 )
562 })
563}
564types/index.d.ts 26 lines1/** One agent card as `om runtime sessions` prints it (fields this mod reads). */
2export type AgentCard = {
3 display_slug: string
4 status: string
5 client?: string
6 model?: string
7 session_id?: string
8 last_seen?: string
9 tool_category?: string
10 task?: { id: string; title: string }
11 progress: { kind: string; percent?: number; completed?: number; total?: number }
12 context: { measurement: string; level: string; used_percent?: number }
13 /** Set on a subagent: the session that spawned it, and its type. */
14 spawned_by?: string
15 agent_type?: string
16}
17
18/** One OmniMemo record an agent saved (`om memory list`), cut down to what the pane prints. */
19export type AgentRecord = { id: string; title: string; kind: string; state: string; created_at: string; lines: string[] }
20
21declare module 'claude-code' {
22 interface PluginState {
23 'omniverse-bridge': { agents: AgentCard[]; report: string; self: string; focus: string; records: AgentRecord[] }
24 }
25}
26