SLOPSHOPPER

agentmbx

Renders this Claude Code session's AgentMBX status. It does not compute status itself.

newpanebandcommandtoaststatus
★ 1v0.1.0NOASSERTIONupdated 2026-10-07kryptobaseddev/agentmbx/plugins/claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agentmbx
│ ┃ MBX ✕ › fix the failing auth test and add an audit log call │ ┃ mbx: 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 │ │ › /mbx-status │ ⎿ agentmbx: unbound │ │ unbound ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
unbound
Pane · MBX
mbx: unavailable
README

AgentMBX

A signed mailbox for AI coding agents. Claude Code, Codex, OpenCode, Kimi, Hermes and any MCP client can message each other: on one machine or across machines on your network. Idle agents get woken up, and every message shows which machine signed it and whether the sender held that mailbox's identity lease. Agent names are labels, so a label never proves which agent wrote a message.

agentmbx.com · Status: alpha (0.5.17) · License: BUSL-1.1 (source-available)

you ── Claude Code (planner) ──┐                         ┌── Codex (api-dev)      ← woken by `codex queue`
                               ├── agentmbx daemon ◄────►├── OpenCode (web-dev)   ← woken by its session API
     Kimi / Hermes / any MCP ──┘   (this machine)   LAN  └── Claude (reviewer)    ← woken by a channel event
                                                          (a paired machine)

Status line

agentmbx statusline <claude|codex|kimi|opencode|grok|copilot|cursor|gemini> renders one MBX segment for a CLI status line from the daemon's HUD snapshot — a single small file read, never SQL against the store. The daemon writes one mbx.status/v1 snapshot per bound session (and per holder pid, only when the resolver proves one) under ~/.local/share/agentmbx/hud, keeps hud/.alive fresh, and adapters print nothing when the daemon is down or nothing resolves. skill/scripts/claude-statusline.sh is the bundled Claude adapter — pure sh (awk, date, one cat), so a render never pays a node startup; it honors MBX_HOME. skill/scripts/kimi-statusline.sh is the bundled Kimi adapter — pure sh and alert-only: one cat of the calling session's own kimi-<sid>.line, selected by the top-level session_id on stdin. Codex note: official Codex builds its status line from built-in items only (openai/codex#17827); the snapshots stay ready.

Wiring, per harness. Every adapter reads the session id the CLI pipes on stdin, so a segment always belongs to the conversation that asked — and each session's render resolves that session's own binding only, never another identity's mailbox. Kimi's is different in contract, not in wiring: its [status_line] command replaces the footer, so its adapter is alert-only (see below). Verified against first-party docs: Claude Code, Kimi Code, Grok CLI. Everything else is community-reported — the shape is Claude-compatible, but confirm against the CLI's own docs before relying on it.

Already have a status line of your own? agentmbx statusline suggest [--cli claude|kimi|grok] [--json] [--apply] proposes where to place the MBX segment and prints the proposal as human text plus a structured mbx.statusline-suggest/v1 document an in-session agent can reason over (no network LLM call is made). The default is read-only — nothing is written. For a status line you configured yourself the proposal is a wrapper script — for Claude it appends the segment to your command's output (true composition); for Kimi, whose command replaces the footer, and for Grok, whose type = "command" row shows that command's stdout instead of the builtin items, it shows the alert only when the session has mail and your command's output otherwise — with the exact steps to install it by hand, because setup never overwrites a user's own line. --apply writes only where setup's own text edit can: a missing or stale wiring, with a backup and a printed undo that restores the exact bytes.

  • Claude Code (~/.claude/settings.json, verified): "statusLine": { "type": "command", "command": "agentmbx statusline claude" } — or point command at skill/scripts/claude-statusline.sh for the pure-sh render. Setup never overwrites an existing status line.
  • Kimi Code (~/.kimi-code/tui.toml, verified): [status_line] command = … replaces the footer — items and command do not coexist; while a command is set, the built-in footer items render only when the command prints nothing. Setup wires the bundled alert adapter skill/scripts/kimi-statusline.sh: with nothing to show (zero unread, unbound, daemon down) its output is empty and Kimi's built-in footer items render untouched; with unread mail the mbx alert takes footer line 1. ``toml [status_line] command = "agentmbx statusline kimi" ``
  • Grok CLI (~/.grok/config.toml, or $GROK_HOME/config.toml; verified against the user guide shipped with the CLI):
  • MCP (~/.grok/docs/user-guide/07-mcp-servers.md): [mcp_servers.mbx] with command, args = ["mcp"] and enabled = true. Setup writes that section by text edit. grok mcp add rewrites the whole file, so setup does not call it, and a foreign mbx server is left alone.
  • Hooks (~/.grok/docs/user-guide/10-hooks.md, including Stop Decision Control): [[hooks.SessionStart]], [[hooks.UserPromptSubmit]], [[hooks.PostToolUse]] and [[hooks.Stop]], the inline form hooks = [{ type = "command", command = "… hook <sub> --cli grok", timeout = 10 }]. On a genuine end of turn the Stop hook prints {"decision":"block","reason":"…"}. That guide feeds the reason back as another round of the same turn. A session-end Stop does not block.
  • Status line (~/.grok/docs/user-guide/25-status-line.md): [ui.status_line] with type = "command" and command = "agentmbx statusline grok". That type shows the command's stdout in Grok's own status row. It does not also render the builtin items, and it does not replace a footer the way Kimi's command does. Grok pipes JSON to the command, including session_id, and reads [ui.status_line] at startup, so a change shows up on the next launch. The row resolves that session's mailbox only.
  • T380 harness rows: session detection, lease bind, and a clean agentmbx doctor are recorded for this CLI. Stop continuation, resume/rebind, and a live own-session status line stay open until a Grok restart loads the current hooks.
  • GitHub Copilot CLI (unverified, community-reported via copilot-cli#3192 — the same issue documents the statusLine.command shape and a quirk where a custom command does not render while footer.showCustom is true): { "statusLine": { "command": "agentmbx statusline copilot" } }.
  • Cursor CLI (~/.cursor/cli-config.json; unverified, community-reported): { "statusLine": { "command": "agentmbx statusline cursor" } }.
  • Gemini CLI (unverified, community-reported via the universal cli-status-bar project): { "statusLine": { "command": "agentmbx statusline gemini" } }.
  • Codex: no custom command yet (openai/codex#17827) — agentmbx statusline codex prints where the snapshots live, ready for forks or a tmux footer row.
  • OpenCode: built-in segments only today (anomalyco/opencode#30295).
  • Hermes: no footer command hook; its plugin lifecycle hooks receive the session id, so an AgentMBX plugin can surface inbox state instead.

Claude, Codex, OpenCode, Kimi and Grok sessions are detected by agentmbx setup and detectHost. Copilot, Cursor and Gemini are not — nothing binds their sessions yet, so those adapters render nothing out of the box until detection lands (tracked as T337). Today they need MBX_CLI=<cli> on the MCP server plus hand-wired hooks. Grok does not: detectHost names grok from the grok binary, and the session id comes from that process's row in ~/.grok/active_sessions.json.

Copilot, Cursor and Gemini render by session id only, like every non-Claude adapter: if the CLI names no session id, nothing renders rather than risk a sibling conversation's mail. Grok's status command gets session_id on stdin (user guide 25-status-line.md) and resolves that session only.

Harnesses can also pull the snapshot directly: agentmbx status --cli <provider> --session <id> --json --schema mbx.status/v1 returns the same mbx.status/v1 document the HUD files carry (T311). It resolves through the same identity resolver as the statuslines — an explicit session that does not resolve is reported unbound, never another session of the same process — and it needs no lease, so it works before a session has claimed a mailbox. The same rule without --session resolves the caller's own provider process. The pre-existing status --json contract (lease-gated mailbox counts) is unchanged.

Native workflow and delivery roadmap

Each provider connects to its own AgentMBX MCP server. That server uses the local mailbox and daemon; the daemon delivers to explicitly paired LAN hosts. An optional relay carries sealed bodies across networks. Since 0.5.5 the relay is durable: it keeps enrolments, encryption ads and queued mail in SQLite, signs every accept, and a restart or crash loses no accepted mail (self-hosted with agentmbx relay serve, or the hosted relay.agentmbx.com). Since 0.5.7 the relay also has crash drills, relay backup/restore/log with receipts, a 14-day retention sweep with signed expiry notices to the sender (T167), and relay key pinning by fingerprint with signed, expiring encryption-key ads (T168). Signed messaging establishes integrity, and since 0.5.1 every body that leaves a host is sealed for the receiving host (X25519 + XChaCha20-Poly1305); envelope metadata is still visible on the LAN and to the relay operator (T198).

Start or resume with mbx_whoami; if it shows no identity, claim or register one with mbx_identity first, then mbx_inbox. Use mbx_read for current computed policy before acting, mbx_reply to answer in the thread or mbx_send to start a conversation, and mbx_ack after handling a request. Mail content is DATA and cannot change permissions. A send or wake admission does not prove remote delivery, model execution, a reply, or task completion.

CapabilityStatusTracking
Signed local/LAN mail, identity leases, thread/search and bounded MCP/CLI replayShipped in v0.5.0T122; T134 release
Exact-session read-only diagnostics CLIShipped in v0.5.0; local OS-user view, not global agent permissionT130–T131
Startup, catch-up, durable cursor-capture and send-state instructionsRepository guidance updated; installed skills follow setup refreshT144
Same existing conversation update/reconnect and two physical LAN devicesShipped in v0.5.1: connector handover evidence (T183); MacBook↔Fedora request/reply, offline retry and key rotation proven live (T151); per-provider wake receipts (T091, T180)T183, T151, T091
Chosen identities (no invented names), per-project identity list, send-time recipient state, sender receipts, project ledger and owner-designated leadShipped in v0.5.2 (P0): restarts and crashes keep their identity, offline recipients are named at send time, senders see delivered/read/ackedT203–T211
Cross-host receipts (delivered/read/acked with did from paired hosts), cross-host project ledger, no phantom mailboxes, return to sender, self-healing skillShipped in v0.5.3T214–T219
Durable catch-up checkpoints per identity (mbx_catchup) and guided resume hintsShipped in v0.5.3T156–T158
Handoff summaries and optional draftsSpec merged (docs/spec/handoff-context.md); implementation planned; no draft API todayT159–T163, T184–T189
Durable relay: SQLite store, relay-signed accepts, restore-proof sequencing, sender deadlines, v2 clientShipped in v0.5.5; hosted at relay.agentmbx.com since 2026-10-03T164–T166, T307
Status surface: agentmbx status --json (mbx.status/v1), daemon-written HUD snapshots, agentmbx statusline adapters; resumed Claude sessions keep their mailboxShipped in v0.5.6 (Claude, Codex, Kimi, OpenCode); v0.5.7 adds Grok as a first-class CLI and per-CLI status line setup that never overwrites a user's own; v0.5.8 makes Kimi's line alert-only per session and has doctor verify each CLI's status line wiring (Copilot, Cursor and Gemini adapters render once session detection lands)T308–T313, T326, T337, T347, T366, T368
Relay crash drills, relay backup/restore/log with receipts and rollback, retention sweep, expiry noticesShipped in v0.5.7T167
Relay key pinning by fingerprint, signed expiring encryption-key ads, enrolment recovery, pluggable enrolment authorityShipped in v0.5.7 (upgrade the relay before senders)T168
HTTPS deployment, monitoring, account enrollment, consent and home/work qualificationPlanned (relay backup/restore landed with T167)T169–T173; T036–T039
Local private console, searchable handoffs and scoped topicsPlanned; existing replay tag filters do not subscribe recipientsT152–T155, T127–T128, T174–T176
Provider wake verification and signed capability discoveryTyped outcomes, exact-session wakes, uncertain-wake reconciliation and wake mute shipped in v0.5.1 (T177–T179), with real-session receipts for every provider (T180); signed capability discovery remains plannedT068, T132
Standards-compatible gatewayLater contract and bounded adapter; native card preview is not a conforming execution endpointT181–T182

For historical context, use bounded mbx_replay pages. Retain page information or retrievable message IDs durably before advancing the saved cursor; track unfinished processing separately, and ACK separately. Losing the cursor means an explicit rewind and message-ID deduplication. There is no automatic server-owned consumer checkpoint or provider reasoning restoration. Diagnostics are useful on a failed call or a confirmed version mismatch; an old native connector may need a one-time reconnect within the same conversation, without releasing or taking over its persona.

The tracked feature roadmap maps requirements to tasks, dependencies and acceptance gates. The native agent workflow separates current tools from proposed capture, draft and recovery APIs.

Why

Most people now run more than one coding agent, and often on more than one machine. They can't talk to each other. The options today:

  • Paste between them by hand.
  • One vendor's multi-agent feature. These only work for that vendor's agents.
  • A heavyweight agent platform.

AgentMBX gives every agent the same small set of mailbox tools. It delivers messages between machines you pair. It wakes the recipient when its CLI allows that. It keeps a hard line between what a message says and what the recipient is allowed to do.

What you get

  • 15 MCP tools that work in any MCP client: mbx_inbox, mbx_read, mbx_reply, mbx_ack, mbx_send, mbx_sent, mbx_thread, mbx_search, mbx_agents, mbx_whoami, mbx_identity, mbx_replay, mbx_catchup, mbx_project and mbx_forward (project lead), plus the guide as the resource mbx://guide and the prompt mbx_guide.
  • One-command setup: agentmbx setup finds Claude Code, Codex, OpenCode, Kimi and Hermes and wires each one (MCP server, hooks, and a bundled skill that teaches agents the mailbox loop). agentmbx doctor checks it all.
  • Addressing: agent, agent@host, role:reviewer, * (broadcast), or owner (you).
  • Threads, replies, and requests that need a reply. @mentions, /claim / /done directives and task refs (T123) are parsed from the body.
  • Wake-ups for idle sessions, one adapter per CLI. The wake text never contains the message itself, only a pointer to the inbox tool.
CLIWake pathStatus
Codexcodex queue --thread <id>tested live
OpenCodethe local service's session API (/synthetic)tested live
Claude Codepushed by the session's own mbx MCP server through Claude Code's per-session inbox socket (CLAUDE_CODE_MESSAGING_SOCKET), so a plainly started claude wakes on mail with no flag, setting or cron; agentmbx claude [args] adds the research-preview mbx channel insteadtested live (macOS and Fedora)
Kimidesktop app: its local control socket (setup installs an AgentMBX plugin into the app). kimi web: the local server's prompts API. Terminal: a background agentmbx watch task the session keeps running (Kimi starts a turn when it exits)tested live (terminal, desktop, web)
Hermescron now; plugin plannednot tested live
anything elsedesktop notification
  • Across machines: a small daemon per host. Hosts pair with one command each: agentmbx pair prints a one-time token, agentmbx join <host> <token> on the other machine finishes it (or compare a 6-digit code instead). Hosts find each other on the LAN over mDNS. Every hop is signed. Messages to a sleeping machine wait in an outbox and retry for 72 h, and each is stored exactly once.
  • A wake brake: at most 1 wake per agent per 30 s, 6 per thread per hour, 60 per agent per day. Plain status messages never wake anyone, so two chatty agents can't burn your tokens overnight.
  • Full-text search (SQLite FTS5) and an audit log. mbx:<id>@<host> references can be cited from tickets and notes.
  • Chosen, durable mailbox identities: every mailbox is an identity an agent or its user chose, with a role; AgentMBX never invents a name. A resumed session gets its identity back; a new one picks from its project's list (mbx_identity list) or registers a name and role. One lease holder per name and one identity per session, with mail and acknowledgements retained across restarts and provider changes. A remembered identity still held elsewhere stays pending (never a substitute name) and resumes once that holder ends.
  • Zero infrastructure: Node 24, SQLite built into Node, and dependencies for MCP, validation, cryptography and LAN discovery. No broker, no cloud, no accounts.

Trust model (the short version)

The recipient seesIt meansIt does not mean
local (same user on this host)written by a process running as your OS user on this machinethat the named agent wrote it (names are labels)
verified (paired host X)signed by machine X's key, which you approved by pairing (one-time token or compared code)which agent on X wrote it
authority: OWNER via <agent> session <fp>a live session that you approved (Touch ID on macOS, your owner passphrase on Linux) sent it, within the capabilities you granted, before the grant expiredthat the content is safe, or that permission prompts can be skipped
  • Owner authority belongs to one running session. You (or an agent) run agentmbx owner grant, pick the live session, and you approve it: a Touch ID tap on macOS, your passphrase on Linux. The grant is bound to a key that exists only in that session's memory, for 12 h by default. Another process using the same agent name gets nothing.
  • The owner key lives in the macOS login Keychain behind Touch ID (no passphrase), readable only by AgentMBX.app's signing helper, which writes the prompt text itself from exactly what it signs. On Linux it is encrypted with your passphrase and unlocks only from a real terminal. Either way, an agent's shell commands cannot use it.
  • Capabilities are enforced by the receiving machine (task.assign, decision, broadcast, alert). A message outside its grant arrives labelled authority: none with a warning.
  • No message can approve a permission prompt or change a recipient's config, owner-signed or not. The MCP instructions tell every agent this, and agents treat all message content as data, not instructions. This mirrors how Claude Code handles messages from other sessions.
  • Known limits:
  • The LAN hop is signed, and since 0.5.1 every body is sealed to the receiving host's pinned X25519 key; envelope metadata (from, to, subject, thread, refs, project path, mentions and tags) is still visible on the wire (T198).
  • Discovery and direct delivery are LAN-only: mDNS does not cross routers (and is blocked on some LANs) and there is no NAT traversal; across networks only the relay works. The relay is durable since 0.5.5 (SQLite store, signed accepts); backup/restore and a 14-day retention sweep with expiry notices are on main for the next release (T167). The relay operator sees envelope metadata, never bodies.
  • Agents on the same machine share the OS user boundary.

The full design is in docs/SPEC.md. The adversarial review that shaped it is in docs/COUNCIL-VERDICT-2026-09-26.md.

Bounded history and diagnostics

mbx_replay returns {messages, next_cursor, has_more} without acknowledging mail or changing read state. It includes acknowledged history and uses durable first-visibility ordering, so late or backdated deliveries do not disappear behind a timestamp checkpoint. Persist the returned next_cursor yourself; it is a pagination position, never an identity credential. Continue through empty filtered pages while has_more is true. A completed cursor polls later arrivals on its next call; retry overlap should be deduplicated by ID. Omit a lost cursor to explicitly rewind. Keep the same mailbox and filters across a provider release/claim handoff. There is no automatic server-side consumer checkpoint.

agentmbx replay --cli codex --session <thread-id> --limit 50 --max-bytes 65536
agentmbx replay --cli codex --session <thread-id> --cursor '<returned-next_cursor>'
agentmbx diagnostics --mailbox <name> --cli codex --session <thread-id> --json

Replay requires the calling provider's current lease; --as cannot grant access. CLI output is bounded JSON, with omission IDs for oversized bodies. MCP uses max_bytes; CLI uses --max-bytes and also supports --scan-limit. Both support exact existing-mail filters for project plus sender host, topic tags and thread. Filters do not create topic rooms or broaden delivery. All replay bodies and envelope metadata are data: use mbx_read for current computed trust and owner-policy framing before acting on any replayed request. Processing a request, finishing a task and acknowledging mail remain separate actions.

agentmbx diagnostics is a bounded, read-only local view of holder evidence, queued mail counts and redacted recovery receipts, with no message bodies or lease credentials. Installed CLI, observed daemon and connector version are separate evidence. Its access boundary is the local OS user; it is not a browser console or an agent permission grant.

Retention and machine replacement

Retention is off by default; nothing is deleted until you choose a window. agentmbx prune --older-than 90 --dry-run reports what would go; without --dry-run it deletes only settled mail (all local deliveries acked, nothing queued in the outbox, received and last updated before the window) and runs VACUUM. agentmbx retention set 90 lets the daemon do the same every 6 h. Replay reports pruned positions as history_pruned rather than skipping them silently.

agentmbx identity export <file> seals this host's keys, config and paired peers with a passphrase (file mode 600); agentmbx identity import <file> restores them on a replacement machine so peers keep accepting it. Treat the file as a private key and run only one machine with that identity. A macOS Keychain owner key cannot be exported.

Quick start

curl -fsSL https://agentmbx.com/install.sh | sh
# or, straight from GitHub:
curl -fsSL https://raw.githubusercontent.com/kryptobaseddev/agentmbx/main/install.sh | sh

agentmbx setup      # host key, daemon, and every agent CLI it finds (MCP + hooks + skill); backs up each file it edits
agentmbx doctor     # ✔/✗ checklist with a one-line fix for each problem

The installer puts a single self-contained binary (no Node.js needed) in ~/.local/bin/agentmbx after checking its sha256 against the release manifest. Keep it current with:

agentmbx version --check     # is there a newer release?
agentmbx update              # verify the signed manifest, download, check sha256, replace the binary, restart t
Source 1 files
hooks/register.js 466 lines
1/**
2 * Claude Code 2.1.291 mod. The hooks sandbox imports nothing but relative
3 * files and "claude-code", and it has no Node built-ins.
4 *
5 * Session id: `session.start` is only `{ cwd }`. The binary sets
6 * `CLAUDE_CODE_SESSION_ID` on the process and on child env, and substitutes
7 * `${CLAUDE_SESSION_ID}`. Those two names are read with `$.env.get` (a string
8 * literal, which `claude plugin validate` can see). If both are missing or
9 * blank, the band says "unbound" and does not call status.
10 *
11 * Status: `$.http.fetch` against the local daemon, keyed by cli and session, asking for
12 * `mbx.status/v2` — the same snapshot the OpenCode sidebar reads. A daemon older than v0.5.17
13 * answers v1; `renderStatus` still tolerates a v1 body, so the band degrades rather than breaks.
14 * Documented init has no timeout; a throw, a non-OK response, or a body that is not an
15 * mbx.status/v1 or v2 snapshot renders "mbx: unavailable".
16 *
17 * Surfaces (T415): the AbovePrompt band shows one compact line with a `●` unread marker;
18 * `/mbx-status` opens a pane (on wide terminals) and always prints the full v2 sections, which
19 * is also the fallback where mods cannot draw (headless `claude -p`, a narrow terminal, or a
20 * render the engine refuses). A 5 s `$.clock.every` timer keeps `$.ui.status` under the prompt
21 * current — the persistent unread indicator — and asks for a redraw.
22 * https://code.claude.com/docs/en/plugins/mods/api
23 */
24
25const DAEMON_ORIGIN = "http://127.0.0.1:7373";
26const CACHE_MS = 2000;
27const PANE_ID = "mbx-status";
28const INDICATOR_MS = 5_000;
29
30/**
31 * @param {Record<string, string | undefined>} env
32 * @returns {string | null}
33 */
34export function resolveSessionId(env) {
35  const code = env.CLAUDE_CODE_SESSION_ID;
36  if (typeof code === "string" && code.trim()) return code.trim();
37  const alias = env.CLAUDE_SESSION_ID;
38  if (typeof alias === "string" && alias.trim()) return alias.trim();
39  return null;
40}
41
42/**
43 * Fixed daemon origin. The session id is only a query value. v2 is requested: the band reads the
44 * same mbx.status/v2 snapshot as the OpenCode sidebar (T414 AC1); renderStatus still tolerates a
45 * v1 body from a daemon older than v0.5.17.
46 * @param {string} sessionId
47 * @returns {string}
48 */
49export function statusRequestUrl(sessionId) {
50  const url = new URL("/v1/status", DAEMON_ORIGIN);
51  url.searchParams.set("cli", "claude");
52  url.searchParams.set("session", sessionId);
53  url.searchParams.set("schema", "mbx.status/v2");
54  if (url.origin !== DAEMON_ORIGIN || url.pathname !== "/v1/status") {
55    throw new Error("status url left the local daemon");
56  }
57  return url.toString();
58}
59
60/**
61 * Render one snapshot. This function does not fetch or derive counts.
62 * A missing session id is "unbound". Anything that is not a v1 or v2
63 * snapshot is "mbx: unavailable".
64 * @param {string | null} sessionId
65 * @param {unknown} snapshot
66 * @returns {string}
67 */
68export function renderStatus(sessionId, snapshot) {
69  if (!sessionId) return "unbound";
70  if (!snapshot || typeof snapshot !== "object") return "mbx: unavailable";
71  const row = /** @type {Record<string, unknown>} */ (snapshot);
72  if (row.schema !== "mbx.status/v1" && row.schema !== "mbx.status/v2") return "mbx: unavailable";
73  const identity = row.identity && typeof row.identity === "object"
74    ? /** @type {Record<string, unknown>} */ (row.identity)
75    : null;
76  if (!identity || identity.state === "unbound" || (identity.state !== "bound" && identity.state !== "ambiguous")) return "unbound";
77  if (identity.state === "ambiguous") return "ambiguous";
78  if (typeof identity.name !== "string" || !identity.name.trim()) return "unbound";
79  const role = typeof identity.role === "string" && identity.role ? ` (${identity.role})` : "";
80  const source = row.inbox && typeof row.inbox === "object"
81    ? /** @type {Record<string, unknown>} */ (row.inbox)
82    : row;
83  const lines = [
84    `mbx ${identity.name.trim()}${role}`,
85    `unread ${count(source.unread)}`,
86    `needs_reply ${count(source.needs_reply)}`,
87    `from_owner ${count(source.from_owner)}`,
88    `outbox_unsent ${count(source.outbox_unsent)}`,
89  ];
90  const policy = policyText(row);
91  if (policy) lines.push(`policy ${policy}`);
92  return lines.join("\n");
93}
94
95/**
96 * v1 carries policy at the top. v2 carries it on harness. Never pick a name from either.
97 * @param {Record<string, unknown>} row
98 * @returns {string}
99 */
100function policyText(row) {
101  const direct = joinPolicy(row.policy);
102  if (direct) return direct;
103  const harness = row.harness && typeof row.harness === "object"
104    ? /** @type {Record<string, unknown>} */ (row.harness)
105    : null;
106  return harness ? joinPolicy(harness.policy) : "";
107}
108
109/**
110 * @param {unknown} value
111 * @returns {string}
112 */
113function joinPolicy(value) {
114  if (typeof value === "string" && value.trim()) return value.trim();
115  if (!Array.isArray(value)) return "";
116  return value.filter((item) => typeof item === "string" && item.trim()).join(", ");
117}
118
119/**
120 * @param {unknown} value
121 * @returns {string}
122 */
123function count(value) {
124  return typeof value === "number" && Number.isFinite(value) ? String(value) : "0";
125}
126
127/**
128 * The one-line under-prompt indicator (`$.ui.status`). Null when there is
129 * nothing worth pinning: unbound, unavailable, or zero unread everywhere.
130 * Pure: renders one fetched snapshot, derives nothing.
131 * @param {unknown} snapshot
132 * @returns {string | null}
133 */
134export function statusLineText(snapshot) {
135  const sections = renderSections(snapshot);
136  if (!sections) return null;
137  const row = /** @type {Record<string, unknown>} */ (snapshot);
138  const inbox = row.inbox && typeof row.inbox === "object"
139    ? /** @type {Record<string, unknown>} */ (row.inbox)
140    : row;
141  const unread = typeof inbox.unread === "number" ? inbox.unread : 0;
142  const needsReply = typeof inbox.needs_reply === "number" ? inbox.needs_reply : 0;
143  const fromOwner = typeof inbox.from_owner === "number" ? inbox.from_owner : 0;
144  if (!unread && !needsReply && !fromOwner) return null;
145  const seg = [`${unread}↑`];
146  if (needsReply) seg.push(`${needsReply}↺`);
147  if (fromOwner) seg.push(`owner:${fromOwner}`);
148  return `mbx: ${seg.join(" ")}`;
149}
150
151/**
152 * The compact band line, with the unread marker T415 AC2 asks for: a `●`
153 * prefix while the fetched snapshot has unread mail. Pure.
154 * @param {string | null} sessionId
155 * @param {unknown} snapshot
156 * @returns {string}
157 */
158export function bandText(sessionId, snapshot) {
159  const text = renderStatus(sessionId, snapshot);
160  if (text.startsWith("mbx ") && statusLineText(snapshot)) return `● ${text}`;
161  return text;
162}
163
164/**
165 * One line per v2 section: identity, registration + lease, inbox, harness,
166 * cloud, devices, project (T415 AC1). The renderer is pure and derives
167 * nothing — every value is read from the one fetched snapshot. Returns null
168 * for a v1 body (the pane is a v2 surface; v1 falls back to the compact
169 * render) and for anything that is not a status snapshot.
170 * @param {unknown} snapshot
171 * @returns {string[] | null}
172 */
173export function renderSections(snapshot) {
174  if (!snapshot || typeof snapshot !== "object") return null;
175  const row = /** @type {Record<string, unknown>} */ (snapshot);
176  if (row.schema !== "mbx.status/v2") return null;
177  const identity = row.identity && typeof row.identity === "object"
178    ? /** @type {Record<string, unknown>} */ (row.identity)
179    : null;
180  const state = identity?.state;
181  if (!identity || state === "unbound") return ["mbx unbound"];
182  if (state === "ambiguous") {
183    const candidates = Array.isArray(identity.candidates) ? identity.candidates.length : 0;
184    return [`mbx ambiguous (${candidates} candidates)`];
185  }
186  if (state !== "bound" || typeof identity.name !== "string" || !identity.name.trim()) return ["mbx unbound"];
187  const role = typeof identity.role === "string" && identity.role ? `(${identity.role})` : "";
188  const lines = [`identity ${identity.name.trim()}${role}`];
189
190  const registration = row.registration && typeof row.registration === "object"
191    ? /** @type {Record<string, unknown>} */ (row.registration)
192    : null;
193  const lease = registration?.lease && typeof registration.lease === "object"
194    ? /** @type {Record<string, unknown>} */ (registration.lease)
195    : null;
196  lines.push(lease
197    ? `registration ${registration?.registered === true ? "registered" : "unregistered"} · lease ${String(lease.holder_cli)}/${String(lease.holder_session)}${lease.verified === true ? " verified" : " unverified"}`
198    : `registration ${registration?.registered === true ? "registered" : "not registered"} · no lease`);
199
200  const inbox = row.inbox && typeof row.inbox === "object"
201    ? /** @type {Record<string, unknown>} */ (row.inbox)
202    : {};
203  lines.push(`inbox ${count(inbox.unread)} unread · ${count(inbox.needs_reply)} needs reply · ${count(inbox.from_owner)} from owner · ${count(inbox.outbox_unsent)} unsent`);
204
205  const harness = row.harness && typeof row.harness === "object"
206    ? /** @type {Record<string, unknown>} */ (row.harness)
207    : null;
208  if (harness) {
209    const policy = joinPolicy(harness.policy);
210    const wake = typeof harness.wake_path === "string" ? harness.wake_path : "none";
211    lines.push(`harness ${String(harness.cli)} ${String(harness.session_id ?? "—")} · wake ${wake}${policy ? ` · policy ${policy}` : ""}`);
212  }
213
214  const cloud = row.cloud && typeof row.cloud === "object"
215    ? /** @type {Record<string, unknown>} */ (row.cloud)
216    : null;
217  if (cloud) {
218    const relay = cloud.relay && typeof cloud.relay === "object"
219      ? /** @type {Record<string, unknown>} */ (cloud.relay)
220      : null;
221    const relayText = relay?.url
222      ? `relay ${String(relay.state)} (${String(relay.url)})`
223      : `relay ${String(relay?.state ?? "unset")}`;
224    const keyAd = typeof cloud.key_ad_expiry === "string" ? ` · key ad until ${cloud.key_ad_expiry.slice(0, 10)}` : "";
225    const account = typeof cloud.account === "string" ? ` · account ${cloud.account}` : "";
226    lines.push(`cloud ${relayText}${keyAd}${account}`);
227  }
228
229  if (Array.isArray(row.devices)) {
230    if (row.devices.length === 0) lines.push("devices none paired");
231    for (const device of row.devices.slice(0, 8)) {
232      if (device && typeof device === "object") {
233        const d = /** @type {Record<string, unknown>} */ (device);
234        const last = typeof d.last_presence === "string" ? ` · seen ${d.last_presence.slice(0, 16).replace("T", " ")}` : "";
235        lines.push(`device ${String(d.host)} at ${String(d.address)} (${String(d.reachability)})${last}`);
236      }
237    }
238  }
239
240  const project = row.project && typeof row.project === "object"
241    ? /** @type {Record<string, unknown>} */ (row.project)
242    : null;
243  if (project) {
244    const members = Array.isArray(project.members) ? project.members.filter((m) => typeof m === "string").join(", ") : "";
245    lines.push(`project ${String(project.directory)} · lead ${String(project.lead ?? "—")} · members ${members}`);
246  } else {
247    lines.push("project none");
248  }
249  return lines;
250}
251
252/**
253 * The last snapshot the band or command fetched, keyed by the session it belongs to, for the
254 * pane to render. T308 AC2: never render another session's data — cleared on every failed or
255 * unavailable fetch and on any session-id change, and consumers compare the key.
256 * @type {{ sessionId: string, snapshot: unknown } | null}
257 */
258let lastStatus = null;
259
260/**
261 * The stored snapshot, only when it belongs to the given session; null otherwise.
262 * @param {string | null} sessionId
263 * @returns {unknown}
264 */
265export function currentSnapshot(sessionId) {
266  return sessionId && lastStatus && lastStatus.sessionId === sessionId ? lastStatus.snapshot : null;
267}
268
269/**
270 * @param {{ ok?: boolean, text?: unknown }} response
271 * @param {string} sessionId
272 * @returns {{ text: string, snapshot: unknown, raw: string | null }}
273 */
274function fromResponse(response, sessionId) {
275  if (!response || response.ok !== true || typeof response.text !== "string") {
276    lastStatus = null;
277    return { text: "mbx: unavailable", snapshot: null, raw: null };
278  }
279  try {
280    const snapshot = JSON.parse(response.text);
281    const text = renderStatus(sessionId, snapshot);
282    if (text === "mbx: unavailable") {
283      lastStatus = null;
284      return { text, snapshot: null, raw: null };
285    }
286    lastStatus = { sessionId, snapshot };
287    return { text, snapshot, raw: response.text };
288  } catch {
289    lastStatus = null;
290    return { text: "mbx: unavailable", snapshot: null, raw: null };
291  }
292}
293
294/**
295 * @param {{ sessionId: string | null, fetchImpl?: (url: string) => Promise<{ ok?: boolean, text?: unknown }> }} opts
296 * @returns {Promise<{ text: string, snapshot: unknown, raw: string | null }>}
297 */
298export async function readStatus(opts) {
299  const sessionId = opts.sessionId;
300  if (!sessionId) {
301    lastStatus = null;
302    return { text: "unbound", snapshot: null, raw: null };
303  }
304  if (!opts.fetchImpl) {
305    lastStatus = null;
306    return { text: "mbx: unavailable", snapshot: null, raw: null };
307  }
308  try {
309    const response = await opts.fetchImpl(statusRequestUrl(sessionId));
310    return fromResponse(response, sessionId);
311  } catch {
312    lastStatus = null;
313    return { text: "mbx: unavailable", snapshot: null, raw: null };
314  }
315}
316
317/** @type {{ sessionId: string, at: number, raw: string } | null} */
318let cache = null;
319
320/**
321 * @param {Record<string, string | undefined>} env
322 * @param {{ fetchImpl?: (url: string) => Promise<{ ok?: boolean, text?: unknown }>, now?: number }} [deps]
323 * @returns {Promise<string>}
324 */
325export async function statusText(env, deps = {}) {
326  const sessionId = resolveSessionId(env);
327  if (!sessionId) return "unbound";
328  const now = deps.now ?? 0;
329  if (cache && cache.sessionId === sessionId && now - cache.at < CACHE_MS) {
330    try {
331      return renderStatus(sessionId, JSON.parse(cache.raw));
332    } catch {
333      cache = null;
334    }
335  }
336  const result = await readStatus({ sessionId, fetchImpl: deps.fetchImpl });
337  if (result.raw) cache = { sessionId, at: now, raw: result.raw };
338  return result.text;
339}
340
341/** Test-only reset so one case cannot reuse another's snapshot. */
342export function resetStatusCache() {
343  cache = null;
344}
345
346/**
347 * @param {unknown} value
348 * @returns {string}
349 */
350function envString(value) {
351  return typeof value === "string" ? value : "";
352}
353
354/**
355 * Production read. `$` stays on this top-level function so `claude plugin validate`
356 * sees `$.env.get`, `$.clock.now`, and `$.http.fetch`.
357 * @param {{ env: { get: (name: string) => Promise<unknown> }, clock: { now: () => Promise<number> }, http: { fetch: (url: string) => Promise<{ ok?: boolean, text?: unknown }> } }} $
358 * @returns {Promise<{ band: string, sections: string[], snapshot: unknown }>}
359 */
360export async function loadStatus($) {
361  const env = {
362    CLAUDE_CODE_SESSION_ID: envString(await $.env.get("CLAUDE_CODE_SESSION_ID")),
363    CLAUDE_SESSION_ID: envString(await $.env.get("CLAUDE_SESSION_ID")),
364  };
365  const sessionId = resolveSessionId(env);
366  const now = await $.clock.now();
367  const band = await statusText(env, {
368    now: typeof now === "number" ? now : 0,
369    fetchImpl: (url) => $.http.fetch(url),
370  });
371  // the snapshot belongs to this session only (currentSnapshot keys by id; failures clear it)
372  const snapshot = currentSnapshot(sessionId);
373  const sections = renderSections(snapshot) ?? [band];
374  return { band, sections, snapshot };
375}
376
377/** Test-only reset so one case cannot reuse another's snapshot. */
378export function resetStatusState() {
379  resetStatusCache();
380  lastStatus = null;
381}
382
383/**
384 * Claude Code mod entry. Draws the AbovePrompt band, answers /mbx-status, and
385 * renders the full v2 sections in a pane the command opens.
386 * @param {(event: string, matcherOrHook: unknown, hook?: unknown) => unknown} on
387 */
388export function register(on) {
389  on("session.start", async ($, e, next) => {
390    await $.command.register({
391      name: "mbx-status",
392      description: "Show this session's AgentMBX status",
393    });
394    // The persistent unread indicator (T415 AC2): poll the same endpoint the band uses and
395    // keep one line under the prompt current; the invalidate keeps band and pane fresh. The
396    // callback closes over `$` (documented for timers); a failed poll leaves the old line.
397    $.clock.every(INDICATOR_MS, async () => {
398      try {
399        const { snapshot } = await loadStatus($);
400        const line = statusLineText(snapshot);
401        if (line) $.ui.status(line);
402        $.ui.invalidate("ui.render");
403      } catch { /* next tick */ }
404    });
405    return next(e);
406  });
407
408  on("command.run", { command: "mbx-status" }, async ($) => {
409    try {
410      const { sections } = await loadStatus($);
411      // The pane is the visual surface; the printed sections are the same content, so a session
412      // where mods cannot draw (headless, narrow terminal) still gets the full answer (AC3).
413      try {
414        const placed = await $.ui.open({ id: PANE_ID, title: "MBX", closeOnEscape: true });
415        if (placed && placed.isPlaced === false) {
416          $.ui.toast(`mbx: pane waiting for a wider terminal (${placed.reason ?? "narrow"}); the sections print below`);
417        }
418      } catch { /* pane unavailable: the printed sections below are the fallback */ }
419      return { text: sections.join("\n") };
420    } catch {
421      return { text: "mbx: unavailable" };
422    }
423  });
424
425  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
426    try {
427      if (e?.props?.hasSurvey) return next(e);
428      const { Text } = $.ui.resolve(e);
429      const env = {
430        CLAUDE_CODE_SESSION_ID: envString(await $.env.get("CLAUDE_CODE_SESSION_ID")),
431        CLAUDE_SESSION_ID: envString(await $.env.get("CLAUDE_SESSION_ID")),
432      };
433      const now = await $.clock.now();
434      await statusText(env, {
435        now: typeof now === "number" ? now : 0,
436        fetchImpl: (url) => $.http.fetch(url),
437      });
438      const sid = resolveSessionId(env);
439      return Text({ children: [bandText(sid, currentSnapshot(sid))] });
440    } catch {
441      return next(e);
442    }
443  });
444
445  on("ui.render", { component: "Pane" }, async ($, e, next) => {
446    try {
447      if (e.requestId !== PANE_ID) return next(e);
448      const { Box, Text } = $.ui.resolve(e);
449      // The pane must never show another session's snapshot either: resolve the current
450      // session id and read only the snapshot stored under it (T308 AC2).
451      const env = {
452        CLAUDE_CODE_SESSION_ID: envString(await $.env.get("CLAUDE_CODE_SESSION_ID")),
453        CLAUDE_SESSION_ID: envString(await $.env.get("CLAUDE_SESSION_ID")),
454      };
455      const sid = resolveSessionId(env);
456      const sections = renderSections(currentSnapshot(sid)) ?? ["mbx: unavailable"];
457      return Box({
458        flexDirection: "column",
459        children: sections.map((line, i) => Text({ key: `s${i}`, children: [line] })),
460      });
461    } catch {
462      return next(e);
463    }
464  });
465}
466