SLOPSHOPPER

omniverse-bridge

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

panebandguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · omniverse-bridge
│ ┃ Omniverse · Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Bridge unavailable │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /om-agents │ ⎿ omniverse-bridge: Omniverse agent cards opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ omniverse-bridge: OmniMemo unavailable · run om doctor

Draws

Pane · Omniverse · Agents
Bridge unavailable
Pane · omniverse-agent-session
This session is no longer present (offline or no recent signal). Saved to OmniMemo by this agent: nothing yet From OmniMemo: status, task and saved records only. Its private conversation stays in its own client.
README

Omniverse bridge — Claude Code mod

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.
  • The other terminal agents present in the project (Codex, OpenCode, Gemini CLI, other Claude sessions) show as rows in the band directly above the prompt, like Claude's own subagents: ● 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.
  • The status line reads OmniMemo · 3 agents · 2 working · 1 blocked · ctx 42%.
  • After each main turn it reports the session's context usage ($.session.usage(), the same numbers as Claude's status line) to OmniMemo.
  • Context rollover, set with 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.

The handoff: a fresh session

  1. om rollover prepare saves the capsule as a checkpoint first: tasks, unverified milestones, git branch/commit and changed files.
  2. om rollover begin reserves the only child of this session. A session that already handed off gets no second one.
  3. The mod runs /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.
  4. om rollover link records parent → child.
  5. One prompt delivers the capsule into the new session, with the person's last request (up to 1200 characters, never stored). Its first line must be ACK <digest>.
  6. With the acknowledgement, om rollover report --child moves the task to the new session and retires the parent (rollover.completed).
  7. Without it, the parent keeps the task (rollover.failed) and stays resumable with claude --resume <parent id>.
  8. If /clear is refused, the session is unchanged and the checkpoint stays saved.

Load it

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.

Source 2 files
hooks/register.tsx 564 lines
1// 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}
564
types/index.d.ts 26 lines
1/** 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