SLOPSHOPPER

high-command

See the messages your teammates, named subagents, and other Claude Code sessions send you, grouped by peer with unread counts: a pane, a band above the prompt…

newpanebandguardcommandtoast
v1.1.1MITupdated 2026-10-12dot-agi/high-command
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · high-command
│ ┃ high-command ✕ › fix the failing auth test and add an audit log call │ ┃ Capture window: 08:53 · current conversation │ ┃ only. Earlier history is not included. ⏺ Read(src/auth.ts) │ ┃ Order: observation time. Counts cover ⎿ Read 6 lines │ ┃ retained messages. ⏺ Update(src/auth.ts) │ ┃ High Command · 0 unread (retained) · 0 peers ⎿ Added 2 lines, removed 1 line │ ┃ No messages from teammates or other sessions ⏺ Bash(bun test) │ ┃ in this capture window. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /highcommand │ ⎿ high-command: Capture window: 08:53 · current conversation only. │ ⎿ high-command: No messages from teammates or other sessions in th │ ⎿ high-command: Order: observation time; read marks and retention │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · high-command
Capture window: 08:53 · current conversation only. Earlier history is not included. Order: observation time. Counts cover retained messages. High Command · 0 unread (retained) · 0 peers No messages from teammates or other sessions in this capture window.
README

<img src="https://img.shields.io/badge/Claude_Code-2.1.288%2B-D97757" alt="Claude Code 2.1.288+"> <img src="https://img.shields.io/badge/type-mod-555555" alt="mod"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-555555" alt="License: MIT"></a> <img src="https://img.shields.io/badge/version-1.1.1-555555" alt="Version: 1.1.1">

Terminal with a High Command band: 5 unread from scout, tester, and helper, with Open, Read all, and Hide buttons, and a status line.

The band above the prompt and the status line under it: 5 unread from scout, tester, and helper.

Why

When your session works with other agents, their messages arrive in the transcript one at a time, between your own turns. It is hard to see who wrote what, what you have not read yet, and what each of your sends reported. Users report teammate text that looked like their own turns (#40166, #28627) and ask for a message inbox (#100724). High Command groups these messages by peer, says how far Claude Code vouches for each sender, and shows the reported result of each plain-text send.

Features

  • One inbox by peer. Messages from agent-team teammates, from subagents that message your session by name, and from your other Claude Code sessions, grouped by peer with unread counts. Read them in a pane, or print a summary with /highcommand.
  • Band and status line. While unread messages wait, a row above the prompt shows the count and up to three peers, with Open, Read all, and Hide. Hide puts the band away until a newer message arrives and marks nothing read. The status line under the prompt, high-command: 5 unread (retained), is the mod's own line, so another mod's band cannot hide it. It clears at zero. Both count the main inbox. The agentLanesInBand option adds the unread messages of agent lanes.
  • Pane with threads. A roster of the peers you received messages from or tried to message: peers with unread messages first, then the most recent. Each row shows the unread count, the sender label, any suspicious mark, the time of the latest message, the peer's last known state, and the start of that message. Open a peer to read its thread, newest first: its messages to you and Claude's sends to it, each send with its reported result. Messages that were unread when you opened the thread, and messages that arrive while it is open, stay marked new until you go back to the roster.
  • Sender labels. Each message to you gets a label from Claude Code's own record of where it came from. The sender name is a claim. The label says how far Claude Code vouches for it:
  • verified sender: a teammate whose message the team harness wrote under that name.
  • local sender: another session or agent whose message Claude Code delivered. Claude Code does not report the sender's host, so a remote session's message can have this label too.
  • unverified sender: everything else, such as a message that a mod in the sender's session wrote outside a verified team mailbox, or a delivery that Claude Code could not place. Teammates in their own split panes (tmux or iTerm2) write through a file that Claude Code cannot check, so their messages always get this label.

The message text can lower a label but never raise one: a message that names a different teammate than the one that sent it is unverified sender. A peer's row and /highcommand show the weakest label among its unread messages, so a newer message cannot hide a warning.

  • Suspicious marks. A message that matches a short fixed list of text patterns is marked suspicious in its thread, and on its peer's row while it is unread. The list covers some wordings that tell an agent to ignore its earlier instructions, to change the permission mode, to run a download in a shell, or to read a credential file, and long base64 blocks. The check ignores these invisible characters: Unicode format characters (category Cf, such as a zero-width space or a soft hyphen), the combining grapheme joiner U+034F, and the variation selectors U+FE00 to U+FE0F and U+E0100 to U+E01EF. It tests the text once with each of them read as a space and once with each removed, and marks the message when either test matches. Other invisible characters, look-alike letters from another alphabet, and one wording with these characters both inside a word and between two words can still avoid the mark. Other wording with the same aim gets no mark, so a message without the mark can still try to steer an agent. The mark is for you alone: it hides no text and changes nothing that Claude reads.
  • Idle notices and peer states. A teammate's idle notice with no content only updates its state. A notice that carries the teammate's final answer or failure shows as a message. When Claude runs ListAgents, High Command notes the state of each listed peer, such as busy, idle, or offline, and shows it on that peer's row once the peer has messages.
  • Read-only. High Command never starts a model turn and never changes a message, a send, or a tool result.

High Command pane roster with three peers: scout 2 new and tester 1 new as verified sender, helper 2 new as local sender.

The roster: three peers with unread counts and sender labels.

High Command thread for helper, newest first: its finished notice as verified sender, then its message build finished, 3 warnings as local sender, both new.

The helper thread, newest first, shows its message and finished notice.

How it works

flowchart LR
  IN["Message to your session<br>session.receive"] --> CC["Other mods and Claude Code<br>handle it as usual"]
  SUB["Named subagent's message<br>to your session<br>prompt.submit"] --> CC
  OUT["Plain-text SendMessage<br>by Claude<br>session.send"] --> CC
  CC -->|"queued text or reported result"| MEM[("Session memory: peer, sender label,<br>suspicious mark, send result")]
  LIST["ListAgents results and<br>the local agent list"] -->|"states"| MEM
  MEM --> BAND["Band and status line"]
  MEM --> PANE["Pane: roster and threads"]
  MEM --> CMD["/highcommand summary"]
  • Messages in: High Command records each message to your main conversation after Claude Code and the other mods have handled it, so it shows the text that Claude reads. In Claude Code 2.1.296, a named subagent's message does not pass session.receive. Claude Code submits it as a prompt from a peer, so High Command reads it in prompt.submit, both while the session is idle and during a turn. Task notifications, scheduled prompts, and your own Remote Control prompts are not recorded. A message that another mod consumes, or that you decline in an approval dialog, never reaches Claude, so High Command does not record it.
  • Messages out: each plain-text SendMessage that Claude makes in your main conversation, with its reported result. A message to an agent that has no name, and a message that another mod in your session sends, are not recorded. Known limits names the other sends that High Command cannot see.
  • Agent lanes: local agents' messages go to one lane per agent. The pane opens on the main inbox. Its agent selector switches lanes, and the pane stays on the lane you pick, also when you view an agent's transcript.
  • Unread: a message is unread until you open its peer's thread, press Mark read in that thread, or press Read all. Messages that Claude sends are never unread.

Install

Works with Claude Code 2.1.288 and later. Tested with Claude Code 2.1.296.

Agent teams are optional. Turn them on with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1.

Inside Claude Code:

/plugin marketplace add dot-agi/high-command
/plugin install high-command@high-command

From a shell:

claude plugin marketplace add dot-agi/high-command
claude plugin install high-command@high-command

For one session from a clone:

claude --plugin-dir ./high-command

Agents and scripts

Install from a shell with Claude Code 2.1.292 or later. The second part turns on auto-update. It runs only when the install succeeds and the marketplace entry exists:

claude plugin install high-command --marketplace dot-agi/high-command --scope user --json \
  && f="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" \
  && jq -e --arg name high-command '.extraKnownMarketplaces[$name].source' "$f" > /dev/null \
  && tmp=$(mktemp "$f.XXXXXX") \
  && { jq --arg name high-command '.extraKnownMarketplaces[$name].autoUpdate = true' "$f" > "$tmp" && mv "$tmp" "$f" || { rm -f "$tmp"; false; }; }

Claude Code 2.1.288 to 2.1.291 has no --marketplace option. Add the marketplace first, then run the auto-update part above, from f= on:

claude plugin marketplace add dot-agi/high-command --scope user --json \
  && claude plugin install high-command@high-command --scope user --json

Auto-update runs only in interactive sessions. A headless job updates at its start, before the session that uses the mod:

claude plugin update high-command@high-command --json
  • New code loads in the next session, or after /reload-plugins.
  • Running install again does not update. Use update.
  • On a host with no SSH key for GitHub, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 in the environment of every claude command and session.
  • The auto-update part replaces settings.json with a new file with mode 600. If settings.json is a symbolic link, edit its target instead.

Commands

CommandWhat it does
/highcommandPrints the summary: one row per peer with its unread count, the time of its latest message, its state, and the kind of its latest message. Runs at once, even during a turn.
/highcommand openOpens the pane on the first page of the main inbox roster. In claude -p, it explains that no pane can draw.

Keys

KeyWhereAction
Ctrl+X, then TabAnywhereMove the keyboard to the band or the pane. This is Claude Code's own focus chord.
oBandOpen the pane
rBand or pane rosterMark every message read
hBandHide the band until a newer message arrives
1-9Pane rosterOpen the thread of that peer
bPane threadBack to the roster
rPane threadMark that peer read, only while Mark read shows
EscBand or paneGive the keyboard back to the prompt

Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next tab. In Emacs terminals, Ctrl+X goes to Emacs: click the band, run /highcommand open, or rebind abovePrompt:focus with /keybindings.

Letter and digit keys work only while the band or the pane has the keyboard. Open in the band opens the pane but leaves the keyboard on the band, and focus passes each band button before it reaches the pane. Press Ctrl+X, then Tab, until the pane border shows focus, or click the pane, before you press a digit. Each letter and digit key also has a button you can click. The full guide is docs/usage.md.

Options

Set options in /config, or under pluginConfigs["high-command@high-command"].options in ~/.claude/settings.json. For a --plugin-dir load, the key is high-command@inline.

OptionDefaultEffect
agentLanesInBandfalseAdds the unread count of all agent lanes to the band and the status line, as in high-command: 3 unread (retained) · 2 unread in agent lanes. Read all in the band then also marks every agent lane read. Hide then also ends when a newer message arrives in an agent lane. Open still shows the main inbox.

A change takes effect at once: Claude Code reloads the mod with the new values.

How it compares

Claude Code and other mods also show peer messages. Each row says what the tool does and what High Command adds.

Tool or featureWhat it doesWhat High Command adds
Built-in Message from rows in the transcript (cross-session messaging)One dim preview line per message with the sender name. Ctrl+O expands it.Messages grouped by peer, unread counts, sender labels, and the reported result of each plain-text send.
Built-in /list-agents (/peers) (cross-session messaging)Lists subagents, teammates, and local and remote sessions, with offline status.The messages of each peer. When Claude runs ListAgents, the reported state shows on that peer's roster row.
Built-in agent panel (agent teams)Lists teammates and subagents. Enter opens one teammate's transcript.One inbox across teammates, named subagents, and other sessions.
session-orchestraA read-only mod with session cards (working or idle state, model, effort, context bar), subagent cards, and a message list with a full-message view and a copy button. It reads transcripts and runs in a terminal.Unread counts, sender labels, suspicious marks, and the reported result of each plain-text send. High Command reads no files, and its pane also draws in the desktop app, the VS Code panel, and the mobile app.
claude-crosstalkA mod with a per-peer inbox for cross-session messages, unread counts, a status line, and a reply field that sends through SendMessage. It keeps history across restarts and --resume, rebuilt from the session journal.Teammates and named subagents in the same inbox, sender labels, suspicious marks, and the reported result of each plain-text send. High Command sends nothing and keeps no history past the conversation.

Known limits

What it catches

  • Messages to you: from agent-team teammates, named subagents, and your other sessions, by peer, with a sender label.
  • Claude's sends: each plain-text SendMessage in your main conversation, with its reported result.
  • Local agents: the messages each local agent sends and receives, in a lane of its own.

What it does not catch

  • Earlier history: messages from before this session, or from before /clear, /resume, or /branch.
  • Some sends: structured sends such as shutdown_request, and sends by other mods or by agents that a mod started.
  • Split-pane traffic: messages between teammates that run in their own tmux or iTerm2 panes.
  • Proof of reading: a queued send does not show that the peer read it, and a peer state can be out of date.
  • Every steering attempt: the suspicious mark covers a short list of wordings, and other wordings get no mark.

The list below holds the limits you can hit in daily use. docs/usage.md has every limit, also the rare ones: size caps, unusual names and frames, changes by other mods, and display gaps.

  • Agent lanes: messages that local agents send and receive go to a separate lane for each agent. Each lane has its own counts and read marks. The pane shows each lane's unread count on that agent's button, and /highcommand lists the counts apart from the main inbox. By default, the band, the status line, and Hide cover the main inbox only, so an unread message in an agent lane does not show there. The agentLanesInBand option adds the unread count of all agent lanes to the band and the status line. A message between your main conversation and a local agent can show twice, once in the main inbox and once in that agent's lane. The lane copy of a message from your main conversation never counts as unread. High Command does not see messages between teammates that run in their own split panes.
  • Local agent status: the status under each agent button in the pane, and in /highcommand, names its source. A roster snapshot status comes from Claude Code's local agent list. High Command reads that list when you open the pane or run /highcommand, when an agent starts, and when an agent ends a turn. Between reads, the status can be old. When two reads overlap, the older one can bring back an agent that the newer one removed. Open the pane or run /highcommand again after agent activity settles. A lifecycle observation says how an agent's last turn ended, such as turn answered or turn error. When the list does not hold an agent that ended a turn, an open pane shows that observation as the agent's status until the next read of the list. It describes a finished turn, so it does not prove that the agent still runs. A teammate in its own split pane can keep its last status after it crashes. /highcommand lists up to 100 local agents. The pane pages through all of them, 10 at a time.
  • Peer states: a peer's roster row shows only states from teammates' notices and ListAgents results, never the local agent status. A state is the last one reported, with its time, so it does not prove that the peer is in that state now. When two rows of one ListAgents result have the same name, High Command cannot tell whose state is whose. When the result lists its rows as data, High Command takes no state for that name, so a state recorded earlier, such as offline, stays on that peer's row. When the result holds only the printed listing, that name gets the state of the last such row that has one.
  • Send results: a send shows the recipient and text that High Command observed, and the result reported back to High Command. Send details shows what Claude Code itself received, which another mod may have changed, and whether Claude Code queued the message. When a mod answers the send itself, Send details cannot show this. When a mod sends the message more than once, Send details covers only one of those sends. A refused send shows the first 120 characters of the reason, on one line, in its header. Send details keeps up to 2,000 characters of the reason, with its line breaks, and says when it was cut. A queued message is not proof that the peer read it.
  • Structured sends: Claude Code passes only plain-text SendMessage messages to High Command. A SendMessage whose message is a shutdown_request, shutdown_response, or plan_approval_response object does not reach it. Neither its attempt nor its result appears in the main inbox, an agent lane, or /highcommand. Check the transcript for these sends.
  • No earlier history: High Command starts empty in each new session and conversation. /clear, /resume, and /branch empty it again, so a resumed conversation shows none of its earlier messages. The pane and /highcommand say when the capture window began. After these commands, they also say that earlier history was not restored.
  • Display only: High Command changes nothing that Claude reads. Its inbox leaves out idle notices with no content, but Claude still reads them, so the inbox saves no model turns or tokens.

Privacy

  • High Command makes no network, file, process, or model call of its own, and it keeps nothing on disk. It reads no credential, token, settings file, environment variable, or other file. The suspicious patterns in hooks/text.ts name credential files, such as .aws/credentials, only to mark a message that asks an agent to read one.
  • Besides the events in the table below, it reads the local agent list (each agent's ID, name, status, and the mod that started it) to file sends, label subagent messages, and show each local agent's status. It reads which surfaces show the session, so /highcommand open can say when no pane can draw. It also reads the clock and its own session state.
  • It keeps up to 200 messages in the main inbox and in each agent lane, and the states of up to 100 peers, in session memory. A message, a refusal reason, and the text in Send details keep at most 2,000 characters each, with control characters removed. /clear, /resume, /branch, and the end of the session drop them.
  • A pane that you open in the desktop app, the VS Code panel, or the mobile app is drawn there, as the transcript is.
  • /highcommand output reaches Claude as command output. It holds counts, times, states, and peer and agent names, never message text. Names show as code, so a name cannot add a link or formatting.
  • High Command never orders, highlights, or counts a message by a priority or urgency that its sender claims.

What High Command hooks

The first eight hooks below pass their event on with next(e) and return the result unchanged. The last two answer /highcommand and draw High Command's own views.

EventWhat the hook does
session.startRegisters /highcommand, starts the capture window, and sets the status line.
classic.SessionStart, on /clear, /resume, and /branchStarts a new capture window and sets the status line again. It adds no context, instructions, or settings.
session.receiveFor a message from a peer, records the text that Claude Code queues for Claude.
prompt.submit, for peer promptsRecords a named subagent's <agent-message> prompt, and reads the local agent list for its sender label. It submits no prompt.
session.send, for Claude's sendsRecords each plain-text SendMessage attempt and its reported result.
tool.call, for ListAgentsReads the peer states in the result.
agent.spawnReads the local agent list after the agent starts.
turn.completeWhen a local agent's turn ends, reads the local agent list and notes how the turn ended.
command.run, for /highcommandPrints the summary, or opens the pane.
ui.render, for its own pane and the area above the promptDraws the pane, and the band above other mods' content. During a survey, or when the band does not fit, it leaves that area as it was.

High Command has no hook on a permission check, so it never changes a permission decision. Its agent.spawn hook only reads the local agent list after the spawn. It never changes an agent's prompt, type, model, or permission mode, and it never refuses a spawn.

Troubleshooting

  • Nothing appears: run claude plugin test in an empty folder. no hooks module to load means mods can load on your machine. Then check that the mods active line in /plugin names high-command.
  • claude plugin test says hooks modules are turned off: mods cannot load. turned off here means a setting or your organization's policy blocks them. turned off in this process means installed mods are off: start claude once with network access, then run the check again. If the message stays, installed mods are turned off remotely, and no setting on your machine turns them back on.
  • **A peer shows
Source 9 files
hooks/register.tsx 562 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, CommandRunResult, EngineInterface, Register, SessionReceiveOrigin, SessionSendResult, TraceEntry } from 'claude-code'
3
4import type { HighCommandEntry, HighCommandLane, HighCommandThread } from '../types'
5import { agentSenderOf, isAgentMessage, parseDelivery, spawnerOf, UNNAMED, type NewEntry, type PeerReport } from './envelope'
6import {
7  appendEntries,
8  coreEvidence,
9  applyReports,
10  fitLanes,
11  hideLanes,
12  isNewerView,
13  keepsThread,
14  lanesSeen,
15  lanesUnread,
16  markOf,
17  markRead,
18  newestInboundSeq,
19  newestSeq,
20  newestUnreadableSeq,
21  outboundEntry,
22  recipientOf,
23  rosterOf,
24  threadOf,
25  unreadTotal,
26  type RosterRow,
27} from './inbox'
28import { parseListing, parseStructuredListing } from './listing'
29import { summaryText } from './summary'
30import { count, sanitize, peerKey, peerLabels, unreadText } from './text'
31import { bandTree, compactBandTree, fitsBounds, paneTree, type BandActions, type PaneActions } from './views'
32
33const DESCRIPTION = 'High Command: messages from teammates and other sessions (/highcommand open shows the pane)'
34const PANE = { id: 'high-command', title: 'High Command', focus: true } as const
35const USAGE = 'Usage: /highcommand shows the inbox summary. /highcommand open opens the High Command pane.'
36const NO_PANES =
37  'command.run: this session draws no panes (claude -p or an SDK host). Use /highcommand for the summary.'
38
39/** The origin kinds whose deliveries can hold messages from peers; `unclassified` counts only with a frame. */
40const MESSAGE_ORIGINS: ReadonlySet<string> = new Set(['peer', 'coordinator', 'peer-send-message', 'unclassified'])
41
42const ENTRIES = { plugin: 'high-command', key: 'entries' } as const
43const READ_THROUGH = { plugin: 'high-command', key: 'readThrough' } as const
44const STATUS_FROM = { plugin: 'high-command', key: 'statusFrom' } as const
45const LANES = { plugin: 'high-command', key: 'lanes' } as const
46
47const entries = atom(ENTRIES, [])
48const peers = atom({ plugin: 'high-command', key: 'peers' } as const, {})
49const readThrough = atom(READ_THROUGH, {})
50const openThread = atom({ plugin: 'high-command', key: 'openThread' } as const, null)
51const hiddenThrough = atom({ plugin: 'high-command', key: 'hiddenThrough' } as const, 0)
52const inboundThrough = atom({ plugin: 'high-command', key: 'inboundThrough' } as const, 0)
53
54const lanes = atom(LANES, {})
55const hiddenLanes = atom({ plugin: 'high-command', key: 'hiddenLanes' } as const, {})
56const selectedLane = atom({ plugin: 'high-command', key: 'selectedLane' } as const, null)
57const lanePage = atom({ plugin: 'high-command', key: 'lanePage' } as const, 0)
58const rosterPage = atom({ plugin: 'high-command', key: 'rosterPage' } as const, 0)
59const agents = atom({ plugin: 'high-command', key: 'agents' } as const, {})
60const capture = atom({ plugin: 'high-command', key: 'capture' } as const, null)
61
62function emptyLane(): HighCommandLane {
63  return { entries: [], peers: {}, readThrough: {}, openThread: null }
64}
65
66async function readLane($: EngineInterface, id: string): Promise<HighCommandLane> {
67  const all = await read($, lanes)
68  return Object.hasOwn(all, id) ? all[id]! : emptyLane()
69}
70
71/** Changes one agent lane; fitLanes keeps all lanes within the space of one saved value. */
72async function changeLane($: EngineInterface, id: string, change: (lane: HighCommandLane) => HighCommandLane): Promise<void> {
73  await update($, lanes, known => {
74    const next = new Map(Object.entries(known))
75    next.set(id, change(next.get(id) ?? emptyLane()))
76    return fitLanes(Object.fromEntries(next), id)
77  })
78}
79
80/** Changes every agent lane at once. */
81async function changeLanes($: EngineInterface, change: (lane: HighCommandLane) => HighCommandLane): Promise<void> {
82  await update($, lanes, known => fitLanes(Object.fromEntries(Object.entries(known).map(([id, lane]) => [id, change(lane)]))))
83}
84
85async function changeThread($: EngineInterface, id: string | undefined, change: (thread: HighCommandThread | null) => HighCommandThread | null): Promise<void> {
86  if (id === undefined) await update($, openThread, change)
87  else await changeLane($, id, lane => ({ ...lane, openThread: change(lane.openThread) }))
88}
89
90async function ensureCapture($: EngineInterface, at: number, reset = false): Promise<void> {
91  if (await read($, capture) !== null) return
92  await update($, capture, known => known ?? { at, reset })
93}
94
95/** Reads status without treating a repeated split-pane roster word as liveness evidence. */
96async function refreshAgents($: EngineInterface, event: string): Promise<void> {
97  try {
98    const at = await $.clock.now()
99    const list = await $.agent.list()
100    await update($, agents, known => {
101      const next = new Map(Object.entries(known).filter(([, value]) => value.at > at))
102      for (const agent of list) if (!next.has(agent.id)) next.set(agent.id, {
103        id: agent.id, name: agent.name === undefined ? undefined : peerKey(agent.name),
104        status: agent.status, at, source: 'roster snapshot',
105        lastEvent: Object.hasOwn(known, agent.id) ? known[agent.id]?.lastEvent : undefined,
106      })
107      return Object.fromEntries(next)
108    })
109  } catch (error) {
110    diagnose($, `high-command: ${event}: could not refresh local agents: ${causeOf(error)}`)
111  }
112}
113
114/** Transcript diagnostics already written since this module loaded. */
115const logged = new Set<string>()
116
117function causeOf(error: unknown): string {
118  return error instanceof Error ? error.message : String(error)
119}
120
121/** Writes a diagnostic to the transcript once per module load; a repeat goes to the debug log alone. */
122function diagnose($: EngineInterface, line: string): void {
123  if (logged.has(line)) {
124    $.ui.log(line.replace(/^high-command: /, ''), { to: 'debug' })
125    return
126  }
127  logged.add(line)
128  $.ui.log(line.replace(/^high-command: /, ''))
129}
130
131/**
132 * Pins the unread count as this plugin's status line, which no other mod's band can hide, or clears the
133 * line at zero. It runs after every change of the count; a failure is diagnosed under `event`. `withLanes`
134 * (agentLanesInBand) adds the unread count of all agent lanes, and the version of `lanes` to the view.
135 *
136 * Refreshes overlap, and a dispatch reads state as it stood at its own last write, so a refresh pins its
137 * count only for a view newer than the one behind the line (STATUS_FROM, written with ifVersion). The refresh
138 * after the last write of either value reads both final values; a refresh with an older view loses the
139 * ifVersion race, and its next reads show what stands. A pin can still show after a newer one when its
140 * answer comes late, so each refresh then writes its pin again with ifVersion: a miss means a newer pin
141 * landed, and the refresh shows that one. `isForced` pins the first view whatever the line shows: a reload
142 * may drop the line.
143 */
144async function syncStatus($: EngineInterface, event: string, isForced = false, withLanes = false): Promise<void> {
145  try {
146    for (let isFirst = true; ; isFirst = false) {
147      const list = await $.state.get(ENTRIES)
148      const marks = await $.state.get(READ_THROUGH)
149      const all = withLanes ? await $.state.get(LANES) : undefined
150      const pinned = await $.state.get(STATUS_FROM)
151      const view = { entries: list.version, marks: marks.version, ...(all === undefined ? {} : { lanes: all.version }) }
152      if (!isNewerView(view, isForced && isFirst ? undefined : pinned.value)) return
153      let pin: { entries: number; marks: number; unread: number; lanes?: number; laneUnread?: number } = {
154        ...view, unread: unreadTotal(rosterOf(list.value ?? [], {}, marks.value ?? {})),
155        ...(all === undefined ? {} : { laneUnread: lanesUnread(all.value ?? {}) }),
156      }
157      const written = await $.state.set(STATUS_FROM, pin, { ifVersion: pinned.version })
158      if (!written.isSet) continue
159      for (let version = written.version; ; ) {
160        $.ui.status(unreadText(pin.unread, pin.laneUnread) || undefined)
161        if ((await $.state.set(STATUS_FROM, pin, { ifVersion: version })).isSet) return
162        const newer = await $.state.get(STATUS_FROM)
163        if (newer.value === undefined) return
164        pin = newer.value
165        version = newer.version
166      }
167    }
168  } catch (error) {
169    diagnose($, `high-command: ${event}: could not update the status line: ${causeOf(error)}`)
170  }
171}
172
173/** The stored entries and the roster built from them, as `/highcommand` and the pane read them. */
174async function readRoster($: EngineInterface, laneId?: string): Promise<{ list: HighCommandEntry[]; rows: RosterRow[] }> {
175  if (laneId !== undefined) {
176    const lane = await readLane($, laneId)
177    return { list: lane.entries, rows: rosterOf(lane.entries, lane.peers, lane.readThrough) }
178  }
179  const list = await read($, entries)
180  return { list, rows: rosterOf(list, await read($, peers), await read($, readThrough)) }
181}
182
183/**
184 * Appends fresh entries. The open thread closes when this append drops its peer from the window, so the pane
185 * shows the roster and stays there when that peer writes again. The check runs inside update, so it judges
186 * the thread open now, and keepsThread keeps a thread opened after this append. `inboundThrough`, and a lane's
187 * own `inboundThrough`, keep the newest inbound seq after its entry leaves the window, so a Hide that it outdates
188 * stays ended.
189 */
190async function append($: EngineInterface, fresh: readonly NewEntry[], at: number, laneId?: string): Promise<void> {
191  if (laneId !== undefined) {
192    await changeLane($, laneId, lane => {
193      const list = appendEntries(lane.entries, fresh, at)
194      return {
195        ...lane, entries: list, inboundThrough: Math.max(lane.inboundThrough ?? 0, newestUnreadableSeq(list)),
196        openThread: lane.openThread === null || keepsThread(lane.openThread, lane.entries, list) ? lane.openThread : null,
197      }
198    })
199    return
200  }
201  let before: readonly HighCommandEntry[] = []
202  const list = await update($, entries, known => {
203    before = known
204    return appendEntries(known, fresh, at)
205  })
206  await update($, openThread, open => (open === null || keepsThread(open, before, list) ? open : null))
207  await update($, inboundThrough, seq => Math.max(seq, newestInboundSeq(list)))
208}
209
210async function saveReports($: EngineInterface, reports: readonly PeerReport[], at: number, laneId?: string): Promise<void> {
211  if (laneId === undefined) await update($, peers, known => applyReports(known, reports, at))
212  else await changeLane($, laneId, lane => ({ ...lane, peers: applyReports(lane.peers, reports, at) }))
213}
214
215/**
216 * Records the entries and peer states of one queued delivery, and says in the debug log when it names no
217 * sender. `event` is the hook that saw it: session.receive, or prompt.submit for a local agent's message.
218 * `sender`, when given, files all of the delivery under that name, whatever names its text claims.
219 */
220async function recordDelivery($: EngineInterface, event: string, text: string, origin: SessionReceiveOrigin, at: number, laneId?: string, sender?: string): Promise<void> {
221  const parsed = parseDelivery(text, origin, sender)
222  if (parsed.incomplete) diagnose($, `high-command: ${event}: incomplete frame parsing; consult the transcript for original framing`)
223  if ([...parsed.entries, ...parsed.reports].some(item => item.peer === UNNAMED)) {
224    $.ui.log(`${event}: a ${origin.kind} delivery named no sender; it shows as "${UNNAMED}"`, { to: 'debug' })
225  }
226  if (parsed.entries.length === 0 && parsed.reports.length === 0) return
227  // In an agent lane, a coordinator delivery is the main conversation's own message to that agent: never unread.
228  const fresh = laneId !== undefined && origin.kind === 'coordinator' ? parsed.entries.map(entry => ({ ...entry, fromMain: true as const })) : parsed.entries
229  if (fresh.length > 0) await append($, fresh, at, laneId)
230  if (parsed.reports.length > 0) await saveReports($, parsed.reports, at, laneId)
231}
232
233/** The local agents for a subagent message's sender label; a failure keeps the label and goes to the debug log. */
234async function agentsForLabel($: EngineInterface): Promise<readonly AgentInfo[]> {
235  try {
236    return await $.agent.list()
237  } catch (error) {
238    $.ui.log(`prompt.submit: could not read local agents: ${causeOf(error)}; the message keeps the local sender label`, { to: 'debug' })
239    return []
240  }
241}
242
243/** Records the observed send attempt and its result; an attempt to an agent with no name is left out. */
244async function recordSend($: EngineInterface, to: string, text: string, sent: SessionSendResult, trace: readonly TraceEntry<'session.send'>[], at: number, laneId?: string): Promise<void> {
245  const lane = laneId === undefined ? undefined : await readLane($, laneId)
246  const peer = recipientOf(to, lane?.peers ?? await read($, peers), await $.agent.list(), lane?.entries ?? await read($, entries))
247  if (peer === undefined) return
248  await append($, [{ ...outboundEntry(peer, text, sent), core: coreEvidence(trace) }], at, laneId)
249}
250
251/** Records the peer states a ListAgents listing names, or says in the debug log that no row gave one. */
252async function recordListing($: EngineInterface, { reports, rowCount }: ReturnType<typeof parseListing>, laneId?: string): Promise<void> {
253  reports.forEach(report => { report.source ??= 'formatted listing' })
254  if (reports.length > 0) {
255    const at = await $.clock.now()
256    await saveReports($, reports, at, laneId)
257  } else if (rowCount > 0) {
258    $.ui.log(
259      `tool.call ListAgents: no listing row gave a peer state (${count(rowCount, 'row')} read); peer states are unchanged`,
260      { to: 'debug' },
261    )
262  }
263}
264
265/** Opens the main inbox roster; answers why the pane is not drawn, or undefined when it is. */
266async function openPane($: EngineInterface): Promise<string | undefined> {
267  await refreshAgents($, 'ui.open')
268  try { await ensureCapture($, await $.clock.now()) } catch (error) { diagnose($, `high-command: ui.open: could not set capture window: ${causeOf(error)}`) }
269  await update($, selectedLane, () => '')
270  await update($, rosterPage, () => 0)
271  await update($, openThread, () => null)
272  const opened = await $.ui.open(PANE)
273  return opened.isPlaced ? undefined : `the pane waits to be drawn: ${opened.reason}`
274}
275
276/** `/highcommand open`: the pane, or the reason there is none. */
277async function openFromCommand($: EngineInterface): Promise<CommandRunResult> {
278  try {
279    if ((await $.session.surfaces()).length === 0) return { text: NO_PANES }
280    const problem = await openPane($)
281    return problem === undefined ? {} : { text: `command.run: ${problem}` }
282  } catch (error) {
283    return { text: `command.run: the pane did not open: ${causeOf(error)}` }
284  }
285}
286
287/**
288 * Runs a button's work; a failure becomes a toast that names the action. The press counts as the person's
289 * own input only while its onPress work runs, so each handler returns this promise: a `$.ui.open` after
290 * awaits is then asked, and the pane is placed at any width.
291 */
292function press($: EngineInterface, action: string, work: () => Promise<unknown>): Promise<void> {
293  return work().then(() => undefined, (error: unknown) => $.ui.toast(`ui.press: ${action}: ${causeOf(error)}`))
294}
295
296/**
297 * Marks `peer`, or every peer when none is given, read through its newest inbound entry, and refreshes the status
298 * line: for an agent lane only when the line counts agent lanes (`withLanes`).
299 */
300async function markPeersRead($: EngineInterface, peer?: string, laneId?: string, withLanes = false): Promise<void> {
301  if (laneId !== undefined) {
302    await changeLane($, laneId, lane => ({ ...lane, readThrough: markRead(lane.readThrough, lane.entries, peer) }))
303    if (withLanes) await syncStatus($, 'ui.press', false, true)
304    return
305  }
306  const list = await read($, entries)
307  await update($, readThrough, marks => markRead(marks, list, peer))
308  await syncStatus($, 'ui.press', false, withLanes)
309}
310
311/** `Read all` in the band; with agentLanesInBand (`withLanes`), it also marks every agent lane read. */
312function readAll($: EngineInterface, withLanes: boolean): Promise<void> {
313  return press($, 'read all', async () => {
314    if (withLanes) await changeLanes($, lane => ({ ...lane, readThrough: markRead(lane.readThrough, lane.entries) }))
315    await markPeersRead($, undefined, undefined, withLanes)
316  })
317}
318
319function paneActions($: EngineInterface, withLanes: boolean, laneId?: string): PaneActions {
320  /** Any change of a lane can evict lane messages (fitLanes); with agentLanesInBand, the status line then counts again. */
321  const thenSync = (work: () => Promise<unknown>) => async (): Promise<void> => {
322    await work()
323    if (laneId !== undefined && withLanes) await syncStatus($, 'ui.press', false, true)
324  }
325  return {
326    openThread: peer => press($, 'open thread', async () => {
327      const lane = laneId === undefined ? undefined : await readLane($, laneId)
328      const marks = lane?.readThrough ?? await read($, readThrough)
329      const from = newestSeq(lane?.entries ?? await read($, entries))
330      await changeThread($, laneId, () => ({ peer, since: markOf(marks, peer), from }))
331      await markPeersRead($, peer, laneId, withLanes)
332    }),
333    back: () => press($, 'back', thenSync(() => changeThread($, laneId, () => null))),
334    markRead: peer => press($, 'mark read', () => markPeersRead($, peer, laneId, withLanes)),
335    readAll: () => press($, 'read all', () => markPeersRead($, undefined, laneId, withLanes)),
336    page: page => press($, 'page', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, page }))),
337    identity: identityPage => press($, 'identity', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, identityPage }))),
338    reason: reasonSeq => press($, 'send details', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, reasonSeq: open.reasonSeq === reasonSeq ? undefined : reasonSeq }))),
339    lane: id => press($, 'select lane', () => update($, selectedLane, () => id)),
340    rosterPage: page => press($, 'peer page', thenSync(async () => {
341      if (laneId === undefined) await update($, rosterPage, () => page)
342      else await changeLane($, laneId, lane => ({ ...lane, rosterPage: page }))
343    })),
344    lanePage: page => press($, 'agent page', () => update($, lanePage, () => page)),
345  }
346}
347
348/** The band's buttons; with agentLanesInBand (`withLanes`), Read all and Hide also cover agent lanes. */
349function bandActions($: EngineInterface, withLanes: boolean): BandActions {
350  return {
351    open: () =>
352      press($, 'the pane did not open', async () => {
353        const problem = await openPane($)
354        if (problem !== undefined) $.ui.toast(`ui.press: ${problem}`)
355      }),
356    readAll: () => readAll($, withLanes),
357    hide: () =>
358      press($, 'hide', async () => {
359        const through = newestSeq(await read($, entries))
360        if (withLanes) {
361          const all = await read($, lanes)
362          await update($, hiddenLanes, hidden => hideLanes(hidden, all))
363        }
364        await update($, hiddenThrough, seq => Math.max(seq, through))
365      }),
366  }
367}
368
369export const register: Register = (on, options) => {
370  /** The `agentLanesInBand` option: the band and the status line add the unread count of all agent lanes. */
371  const withLanes = options.agentLanesInBand === true
372
373  on('session.start', async ($, e, next) => {
374    try {
375      await $.command.register({ name: 'highcommand', description: DESCRIPTION, argumentHint: '[open]', immediate: true })
376    } catch (error) {
377      diagnose($, `high-command: session.start: could not register /highcommand: ${causeOf(error)}`)
378    }
379    try { await ensureCapture($, await $.clock.now()) } catch (error) { diagnose($, `high-command: session.start: could not set capture window: ${causeOf(error)}`) }
380    await syncStatus($, 'session.start', true, withLanes)
381    return next(e)
382  })
383
384  // /clear, /resume, and /branch reset $.state but leave the status line pinned.
385  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
386    try { await ensureCapture($, await $.clock.now(), true) } catch (error) { diagnose($, `high-command: classic.SessionStart: could not set capture window: ${causeOf(error)}`) }
387    await syncStatus($, 'classic.SessionStart', false, withLanes)
388    return next(e)
389  })
390
391  on('session.receive', async ($, e, next) => {
392    if (!MESSAGE_ORIGINS.has(e.origin.kind)) return next(e)
393    let at: number | undefined
394    try { at = await $.clock.now(); await ensureCapture($, at) } catch (error) { diagnose($, `high-command: session.receive: could not observe delivery time: ${causeOf(error)}`) }
395    const queued = await next(e)
396    if (queued.consumed !== undefined) return queued
397    try {
398      if (at !== undefined) {
399        await recordDelivery($, 'session.receive', queued.text, e.origin, at, e.agentId)
400      }
401    } catch (error) {
402      diagnose($, `high-command: session.receive: could not record a delivery (${e.origin.kind}): ${causeOf(error)}`)
403    }
404    if (e.agentId === undefined || withLanes) await syncStatus($, 'session.receive', false, withLanes)
405    return queued
406  })
407
408  // In Claude Code 2.1.296 a named subagent's SendMessage to the main conversation skips session.receive:
409  // Claude Code submits its <agent-message> as a peer prompt, while the session is idle or into a running
410  // turn. A message from another session passed session.receive first, so only an agent message counts here.
411  // A mod beneath can rewrite it without the frame; the text that entered is then filed under the one sender
412  // that the frames named, or under `unnamed peer`, and a name that the rewritten text claims does not count.
413  // The prompt's origin does not say whether a mod started the sender, so the local agent list can lower its label;
414  // the list is read before the mods beneath run, while an agent that ends meanwhile is still listed.
415  on('prompt.submit', { origin: { kind: 'peer' } }, async ($, e, next) => {
416    if (!isAgentMessage(e.text)) return next(e)
417    let at: number | undefined
418    try { at = await $.clock.now(); await ensureCapture($, at) } catch (error) { diagnose($, `high-command: prompt.submit: could not observe delivery time: ${causeOf(error)}`) }
419    const named = agentSenderOf(e.text, e.origin)
420    const listed = named === undefined ? undefined : agentsForLabel($)
421    const entered = await next(e)
422    if (entered.drop !== undefined) return entered
423    try {
424      if (at !== undefined) {
425        let sender: string | undefined
426        if (!isAgentMessage(entered.text)) {
427          sender = named
428          const filing = sender === undefined ? `its frames named no single sender, so it is filed under "${UNNAMED}"` : 'it is filed under the sender that the frame named'
429          $.ui.log(`prompt.submit: a mod rewrote a subagent message without its <agent-message> frame; ${filing}`, { to: 'debug' })
430          sender ??= UNNAMED
431        }
432        const plugin = named === undefined || listed === undefined ? undefined : spawnerOf(await listed, named)
433        const origin = plugin === undefined ? e.origin : { kind: 'peer' as const, plugin }
434        await recordDelivery($, 'prompt.submit', entered.text, origin, at, undefined, sender)
435      }
436    } catch (error) {
437      diagnose($, `high-command: prompt.submit: could not record a delivery (${e.origin.kind}): ${causeOf(error)}`)
438    }
439    await syncStatus($, 'prompt.submit', false, withLanes)
440    return entered
441  })
442
443  on('session.send', async ($, e, next) => {
444    if (e.origin.kind !== 'model') return next(e)
445    let at: number | undefined
446    try { at = await $.clock.now(); await ensureCapture($, at) } catch (error) { diagnose($, `high-command: session.send: could not observe send time: ${causeOf(error)}`) }
447    const sent = await next(e)
448    try {
449      if (at !== undefined) {
450        await recordSend($, e.to, e.text, sent, next.trace, at, e.agentId)
451      }
452    } catch (error) {
453      diagnose($, `high-command: session.send: could not record a message to ${sanitize(e.to)}: ${causeOf(error)}`)
454    }
455    if (e.agentId === undefined || withLanes) await syncStatus($, 'session.send', false, withLanes)
456    return sent
457  })
458
459  on('tool.call', { tool: 'ListAgents' }, async ($, e, next) => {
460    const out = await next(e)
461    const result: unknown = out.isError === true ? undefined : out.result
462    const structured = parseStructuredListing(result)
463    const listing = typeof result === 'object' && result !== null && 'listing' in result ? result.listing : undefined
464    if (structured === undefined && typeof listing !== 'string') return out
465    try {
466      if (structured !== undefined) {
467        await recordListing($, structured, e.agentId)
468      } else if (typeof listing === 'string') await recordListing($, parseListing(listing), e.agentId)
469    } catch (error) {
470      diagnose($, `high-command: tool.call ListAgents: could not record peer states: ${causeOf(error)}`)
471    }
472    // Peer states make a lane longer, so fitLanes can evict lane messages.
473    if (e.agentId !== undefined && withLanes) await syncStatus($, 'tool.call ListAgents', false, true)
474    return out
475  })
476
477  on('agent.spawn', async ($, e, next) => {
478    const result = await next(e)
479    await refreshAgents($, 'agent.spawn')
480    return result
481  })
482
483  on('turn.complete', async ($, e, next) => {
484    const result = await next(e)
485    if (e.agentId !== undefined) {
486      await refreshAgents($, 'turn.complete')
487      try {
488        const at = await $.clock.now()
489        const id = e.agentId
490        await update($, agents, known => {
491          const observed = new Map(Object.entries(known))
492          const agent = observed.get(id)
493          const word = e.reason === 'answer' ? 'turn answered' : `turn ${e.reason}`
494          if ((agent?.lastEvent?.at ?? -Infinity) <= at) observed.set(id, {
495            ...agent, id, status: agent?.status ?? word, at: agent?.at ?? at,
496            source: agent?.source ?? 'lifecycle observation', lastEvent: { word, at },
497          })
498          return Object.fromEntries(observed)
499        })
500      } catch (error) { diagnose($, `high-command: turn.complete: could not record lifecycle observation: ${causeOf(error)}`) }
501    }
502    return result
503  })
504
505  on('command.run', { command: 'highcommand' }, async ($, e) => {
506    const argument = e.args.trim()
507    if (argument === 'open') return openFromCommand($)
508    if (argument !== '') return { text: USAGE }
509    try {
510      await refreshAgents($, 'command.run')
511      const now = await $.clock.now()
512      await ensureCapture($, now)
513      const { list, rows } = await readRoster($)
514      return { text: summaryText(rows, list.length, now, {
515        evicted: list.at(-1)?.evictedTotal, capture: await read($, capture), agents: Object.values(await read($, agents)), lanes: await read($, lanes),
516      }) }
517    } catch (error) {
518      return { text: `command.run: could not build the summary: ${causeOf(error)}` }
519    }
520  })
521
522  on('ui.render', { component: 'Pane', requestId: 'high-command' }, async ($, e) => {
523    const ui = $.ui.resolve(e)
524    try {
525      const chosen = await read($, selectedLane)
526      const laneId = chosen === '' ? undefined : chosen ?? e.props.view.agentId
527      const { list, rows } = await readRoster($, laneId)
528      const thread = threadOf(list, rows, laneId === undefined ? await read($, openThread) : (await readLane($, laneId)).openThread)
529      const now = await $.clock.now()
530      const all = await read($, lanes)
531      return paneTree(ui, { rows, now, thread, laneId, rosterPage: laneId === undefined ? await read($, rosterPage) : (await readLane($, laneId)).rosterPage, lanePage: await read($, lanePage),
532        laneIds: Object.keys(all), agents: Object.values(await read($, agents)),
533        laneUnread: new Map(Object.entries(all).map(([id, lane]) => [id, unreadTotal(rosterOf(lane.entries, {}, lane.readThrough))])),
534        capture: await read($, capture), evicted: list.at(-1)?.evictedTotal ?? 0,
535      }, paneActions($, withLanes, laneId))
536    } catch (error) {
537      const { Text } = ui
538      return <Text color="red">{`high-command: ui.render (Pane): ${causeOf(error)}`}</Text>
539    }
540  })
541
542  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
543    if (e.props.hasSurvey) return next(e)
544    const below = await next(e)
545    const list = await read($, entries)
546    // A Hide holds while the person had seen every inbound entry: those in the window and those recorded before.
547    const isHidden = (await read($, hiddenThrough)) >= Math.max(await read($, inboundThrough), newestInboundSeq(list))
548    // With agentLanesInBand, it holds only while the person had also seen each agent lane's newest unread entry.
549    const all = withLanes ? await read($, lanes) : {}
550    if (isHidden && (!withLanes || lanesSeen(all, await read($, hiddenLanes)))) return below
551    const rows = rosterOf(list, {}, await read($, readThrough))
552    const waiting = rows.filter(row => row.unread > 0)
553    const laneUnread = withLanes ? lanesUnread(all) : 0
554    if (waiting.length === 0 && laneUnread === 0) return below
555    // Other mods draw `below`; when the band would push it past the engine's bounds, it yields.
556    const band = bandTree($.ui.resolve(e), waiting, bandActions($, withLanes), below, peerLabels(rows.map(row => row.peer)), laneUnread)
557    if (fitsBounds(band)) return band
558    const compact = compactBandTree($.ui.resolve(e), waiting, bandActions($, withLanes), below, laneUnread)
559    return fitsBounds(compact) ? compact : below
560  })
561}
562
hooks/envelope.ts 263 lines
1import type { AgentInfo, SessionReceiveOrigin } from 'claude-code'
2
3import type { HighCommandEntry, HighCommandTrust } from '../types'
4import { capBody, isSuspicious, peerKey, sanitize, summaryOf } from './text'
5
6/** An entry before the inbox numbers and stamps it. */
7export type NewEntry = Omit<HighCommandEntry, 'seq' | 'at'>
8
9/** What a frame or a listing row says about a peer. */
10export type PeerReport = { peer: string; state?: string; address?: string; source?: string }
11
12/** The peer a delivery that names no sender is filed under. */
13export const UNNAMED = 'unnamed peer'
14
15/** One frame of a delivery; `name` is the tag, absent for text kept from beside the frames. */
16type Frame = { name?: string; attributes: ReadonlyMap<string, string>; body: string }
17type Protocol = { type: string } & Record<string, unknown>
18
19const FRAMING = /^\s*Another Claude session sent a message:\s*/
20/** The start of the notice that the engine writes on a line of its own after a delivery's text. */
21const NOTICE = '\nThis came from another Claude session'
22/** Decodes the XML entities attribute values use; `&amp;` last, so `&amp;lt;` stays `&lt;`. */
23function decode(value: string): string {
24  return value
25    .replaceAll('&quot;', '"')
26    .replaceAll('&apos;', "'")
27    .replaceAll('&lt;', '<')
28    .replaceAll('&gt;', '>')
29    .replaceAll('&amp;', '&')
30}
31
32/** Tokenizes once from left to right; malformed names never trigger suffix searches. */
33function attributesOf(text: string): { attributes: Map<string, string>; malformed: boolean } {
34  const attributes = new Map<string, string>()
35  let at = 0
36  let malformed = false
37  while (at < text.length) {
38    if (/\s/.test(text[at]!)) { at++; continue }
39    const start = at
40    while (at < text.length && /[\w-]/.test(text[at]!)) at++
41    const name = text.slice(start, at)
42    if (name === '' || text[at] !== '=' || text[at + 1] !== '"') {
43      malformed = true
44      if (at === start) at++
45      continue
46    }
47    at += 2
48    const valueAt = at
49    while (at < text.length && text[at] !== '"') at++
50    if (at === text.length) { malformed = true; break }
51    const value = text.slice(valueAt, at++)
52    if (value.trim() !== '') attributes.set(name, decode(value))
53  }
54  return { attributes, malformed }
55}
56
57/** Finds a frame's closing tag, ignoring literals inside a JSON object's strings. */
58function closingOf(text: string, start: number, closing: string): number {
59  let at = start
60  while (at < text.length && /\s/.test(text[at]!)) at += 1
61  if (text[at] !== '{') return text.indexOf(closing, at)
62  let depth = 0
63  let quoted = false
64  let escaped = false
65  for (; at < text.length; at += 1) {
66    const char = text[at]
67    if (quoted) {
68      // A raw line feed or tab cannot occur in a JSON string; keep malformed text as a message.
69      if (char === '\n' || char === '\t') return text.indexOf(closing, at)
70      if (escaped) escaped = false
71      else if (char === '\\') escaped = true
72      else if (char === '"') quoted = false
73    } else if (text.startsWith(closing, at)) {
74      return at
75    } else if (char === '"') {
76      quoted = true
77    } else if (char === '{') {
78      depth += 1
79    } else if (char === '}') {
80      depth -= 1
81      if (depth === 0) return text.indexOf(closing, at + 1)
82    }
83  }
84  return -1
85}
86
87/**
88 * Finds complete frames in one forward pass, skipping closing-tag literals in JSON strings.
89 * A name with no closing tag left is not searched for again, keeping the scan linear.
90 */
91function framesOf(text: string): { frames: Frame[]; incomplete: boolean } {
92  const names = ['teammate-message', 'agent-message', 'cross-session-message']
93  const unclosed = new Set<string>()
94  const frames: Frame[] = []
95  let at = 0
96  let span = 0
97  let incomplete = false
98  const preserve = (end: number, tail = false): void => {
99    const raw = text.slice(span, end)
100    const body = (tail ? unframed(raw) : raw).trim()
101    if (body !== '') {
102      frames.push({ attributes: new Map(), body })
103      incomplete = true
104    }
105  }
106  while (at < text.length) {
107    const opening = text.indexOf('<', at)
108    if (opening < 0) break
109    at = opening + 1
110    const name = names.find(value => text.startsWith(value, at) && /[\s>]/.test(text[at + value.length] ?? ''))
111    if (name === undefined || unclosed.has(name)) continue
112    const attributesAt = at + name.length
113    at = attributesAt
114    while (at < text.length && text[at] !== '>' && text[at] !== '<') at++
115    if (text[at] !== '>') { incomplete = true; continue }
116    const start = at + 1
117    const closing = `</${name}>`
118    const end = closingOf(text, start, closing)
119    if (end === -1) { incomplete = true; unclosed.add(name); continue }
120    preserve(opening)
121    const parsed = attributesOf(text.slice(attributesAt, at))
122    incomplete ||= parsed.malformed
123    frames.push({ name, attributes: parsed.attributes, body: text.slice(start, end).trim() })
124    at = end + closing.length
125    span = at
126  }
127  if (frames.length > 0) preserve(text.length, true)
128  return { frames, incomplete }
129}
130
131/** The text of a delivery that holds no frame, cut before the engine's last notice; the peer wrote only the text before it. */
132function unframed(text: string): string {
133  const at = `\n${text}`.lastIndexOf(NOTICE)
134  return (at === -1 ? text : text.slice(0, at)).trim()
135}
136
137/** The JSON object a protocol body holds, or undefined for any other body. */
138function protocolOf(body: string): Protocol | undefined {
139  if (!body.startsWith('{')) return undefined
140  try {
141    // JSON text that starts with "{" parses to an object or throws.
142    const value = JSON.parse(body) as Record<string, unknown>
143    return typeof value.type === 'string' ? (value as Protocol) : undefined
144  } catch {
145    // A body that starts with "{" and is not JSON is a plain message.
146    return undefined
147  }
148}
149
150/** A sanitized string field with a non-space character, else undefined. */
151function textField(value: unknown): string | undefined {
152  const text = typeof value === 'string' ? sanitize(value) : ''
153  return text.trim() === '' ? undefined : text
154}
155
156/**
157 * The first claim that is a name after peerKey: the frame, the team origin's name (`stamped`), the protocol
158 * body; else the origin's default. Every other claim is text of this frame, so the work stays linear in the
159 * delivery.
160 */
161function senderOf(frame: Frame, protocol: Protocol | undefined, origin: SessionReceiveOrigin, stamped: string): string {
162  const from = protocol?.from
163  const claims = [
164    peerKey(frame.attributes.get('teammate_id') ?? ''),
165    peerKey(frame.attributes.get('from-name') ?? ''),
166    peerKey(frame.attributes.get('from') ?? ''),
167    stamped,
168    typeof from === 'string' ? peerKey(from) : '',
169  ]
170  return claims.find(name => name !== '') ?? (origin.kind === 'coordinator' ? 'coordinator' : UNNAMED)
171}
172
173/**
174 * The trust level of one sender name, from origin fields alone. A team mailbox entry is verified only
175 * when the harness checked it and the frame names that same member; another session's or agent's
176 * message that Claude Code delivered is local unless a plugin composed it; the rest is unverified.
177 */
178function trustOf(origin: SessionReceiveOrigin, peer: string, stamped: string): HighCommandTrust {
179  if ('teammate' in origin) return origin.isVerified && peer === stamped ? 'verified' : 'unverified'
180  const isPluginClaim = 'plugin' in origin && origin.plugin !== undefined
181  return isPluginClaim || origin.kind === 'unclassified' ? 'unverified' : 'local'
182}
183
184/** The state an idle notification reports. */
185function idleState(protocol: Protocol): string {
186  if (textField(protocol.failureReason) !== undefined || protocol.idleReason === 'failed') return 'failed'
187  return protocol.idleReason === 'interrupted' ? 'interrupted' : 'idle'
188}
189
190/**
191 * Whether a peer prompt is a local agent's SendMessage to the main conversation: it holds an
192 * `<agent-message>` frame and no frame of another kind. Claude Code 2.1.296 submits such a message as a
193 * prompt and skips session.receive; a message from another session passes session.receive first.
194 */
195export function isAgentMessage(text: string): boolean {
196  const { frames } = framesOf(sanitize(text).replace(FRAMING, ''))
197  const names = new Set(frames.flatMap(frame => (frame.name === undefined ? [] : [frame.name])))
198  return names.size === 1 && names.has('agent-message')
199}
200
201/**
202 * The one sender that the `<agent-message>` frames of a prompt name, or undefined when they name none or
203 * several. A mod beneath can rewrite such a prompt without its frame; the text that enters keeps this name.
204 */
205export function agentSenderOf(text: string, origin: SessionReceiveOrigin): string | undefined {
206  const { frames } = framesOf(sanitize(text).replace(FRAMING, ''))
207  const names = new Set(frames.flatMap(frame =>
208    frame.name === 'agent-message' ? [senderOf(frame, protocolOf(frame.body), origin, '')] : []))
209  const [name] = names
210  return names.size === 1 && name !== UNNAMED ? name : undefined
211}
212
213/**
214 * The mod that started the local agents whose id or name is `sender`, when a mod started every one of them;
215 * undefined when none is listed or the model or the person started one. Claude Code does not pass a mod on with a
216 * subagent's peer prompt, so this can only lower the label of a message from that sender.
217 */
218export function spawnerOf(agents: readonly AgentInfo[], sender: string): string | undefined {
219  const matches = agents.filter(agent => agent.id === sender || peerKey(agent.name ?? '') === sender)
220  return matches.length > 0 && matches.every(agent => agent.spawnedBy !== undefined) ? matches[0]!.spawnedBy : undefined
221}
222
223/**
224 * Splits one queued delivery into entries and peer reports. When `sender` is given, every frame and text of
225 * the delivery is filed under it, and the names that they claim are ignored.
226 */
227export function parseDelivery(text: string, origin: SessionReceiveOrigin, sender?: string): { entries: NewEntry[]; reports: PeerReport[]; incomplete?: boolean } {
228  const clean = sanitize(text).replace(FRAMING, '')
229  const { frames, incomplete } = framesOf(clean)
230  if (frames.length === 0 && origin.kind === 'unclassified') return { entries: [], reports: [], ...(incomplete ? { incomplete: true } : {}) }
231  const entries: NewEntry[] = []
232  const reports: PeerReport[] = []
233  // The origin's name is the same for every frame, so it is keyed once.
234  const stamped = 'teammate' in origin ? peerKey(origin.teammate) : ''
235  for (const frame of frames.length > 0 ? frames : [{ attributes: new Map<string, string>(), body: unframed(clean) }]) {
236    const protocol = protocolOf(frame.body)
237    const peer = sender ?? senderOf(frame, protocol, origin, stamped)
238    const trust = trustOf(origin, peer, stamped)
239    const add = (kind: NewEntry['kind'], preferred: string | undefined, body: string): void => {
240      const mark = isSuspicious(`${preferred ?? ''}\n${body}`) ? { isSuspicious: true as const } : {}
241      entries.push({ peer, direction: 'in', kind, summary: summaryOf(preferred, body), ...capBody(body), trust, ...mark })
242    }
243    const address = frame.attributes.has('from-name') ? frame.attributes.get('from') : undefined
244    if (address !== undefined) reports.push({ peer, address })
245    if (protocol === undefined) {
246      add('message', frame.attributes.get('summary'), frame.body)
247    } else if (protocol.type === 'idle_notification') {
248      const failure = textField(protocol.failureReason)
249      const result = textField(protocol.result)
250      reports.push({ peer, state: idleState(protocol) })
251      if (failure !== undefined) add('failure', undefined, result === undefined ? failure : `${failure}\n\n${result}`)
252      else if (result !== undefined) add('result', textField(protocol.summary), result)
253    } else if (protocol.type === 'teammate_terminated') {
254      reports.push({ peer, state: 'ended' })
255    } else if (protocol.type === 'shutdown_approved') {
256      reports.push({ peer, state: 'shut down' })
257    } else {
258      add('message', `[${sanitize(protocol.type)}]`, frame.body)
259    }
260  }
261  return { entries, reports, ...(incomplete ? { incomplete: true } : {}) }
262}
263
hooks/inbox.ts 373 lines
1import type { AgentInfo, SessionSendResult, TraceEntry } from 'claude-code'
2
3import type { HighCommandEntry, HighCommandLane, HighCommandPeer, HighCommandState, HighCommandThread, HighCommandTrust } from '../types'
4import type { NewEntry, PeerReport } from './envelope'
5import { LIMITS } from './limits'
6import { capBody, lineOf, peerKey, sanitize, summaryOf } from './text'
7
8type Peers = Record<string, HighCommandPeer>
9
10/** Per peer, the highest inbound seq marked read. */
11type ReadMarks = Record<string, number>
12
13/** The trust levels, the weakest first. */
14const LEVELS: readonly HighCommandTrust[] = ['unverified', 'local', 'verified']
15
16/** The six hexadecimal characters appended to a recipient name in ListAgents output. */
17const RECIPIENT_REF = / \[[0-9a-f]{6}\]$/i
18
19/**
20 * A peer with entries: its unread count, its newest entry, its last reported state, and the trust level and
21 * suspicious mark its row shows (no trust level when it only received sends).
22 */
23export type RosterRow = {
24  peer: string
25  unread: number
26  last: HighCommandEntry
27  state?: HighCommandState
28  trust?: HighCommandTrust
29  isSuspicious: boolean
30}
31
32/** The open thread as the pane draws it: the newest entries first, the count of older ones, and the count of new ones. */
33export type Thread = { row: RosterRow; since: number; shown: HighCommandEntry[]; hidden: number; fresh: number; page: number; pages: number; identityPage?: number; reasonSeq?: number }
34
35/** The seq of the newest entry, or 0 for an empty list. The window never drops its newest entry. */
36export function newestSeq(list: readonly HighCommandEntry[]): number {
37  return list.at(-1)?.seq ?? 0
38}
39
40/** The seq of the newest inbound entry, or 0 without one. */
41export function newestInboundSeq(list: readonly HighCommandEntry[]): number {
42  return list.findLast(entry => entry.direction === 'in')?.seq ?? 0
43}
44
45/**
46 * Numbers and stamps fresh entries; keeps each peer's newest LIMITS.peerEntries entries and the newest
47 * LIMITS.entries in all, so a busy peer drops its own oldest entries first.
48 */
49export function appendEntries(list: readonly HighCommandEntry[], fresh: readonly NewEntry[], at: number): HighCommandEntry[] {
50  const newest = newestSeq(list)
51  const all = [...list, ...fresh.map((entry, index) => ({ ...entry, seq: newest + index + 1, at }))]
52  const held = new Map<string, number>()
53  const kept = all.reverse().filter(entry => {
54    const count = held.get(entry.peer) ?? 0
55    held.set(entry.peer, count + 1)
56    return count < LIMITS.peerEntries
57  })
58  const result = kept.slice(0, LIMITS.entries).reverse()
59  const last = result.at(-1)
60  if (last !== undefined) result[result.length - 1] = {
61    ...last, evictedTotal: (list.at(-1)?.evictedTotal ?? 0) + all.length - result.length,
62  }
63  return result
64}
65
66/**
67 * A record's own value under a peer name. Names come from senders, so `constructor` must not read
68 * Object.prototype; records are written through Map and Object.fromEntries, so `__proto__` is a plain key.
69 */
70function own<T>(record: Readonly<Record<string, T>>, peer: string): T | undefined {
71  return Object.hasOwn(record, peer) ? record[peer] : undefined
72}
73
74/** The peer's read mark: the highest inbound seq marked read, or 0 before any. */
75export function markOf(marks: Readonly<ReadMarks>, peer: string): number {
76  return own(marks, peer) ?? 0
77}
78
79/** String addresses can survive a hot reload; seenAt is their only saved timestamp. */
80function addressOf(peer: HighCommandPeer): HighCommandPeer['address'] {
81  return typeof peer.address === 'string' ? { value: peer.address, at: peer.seenAt } : peer.address
82}
83
84/**
85 * Applies peer reports and keeps the LIMITS.peers peers reported most recently. Deliveries can finish
86 * recording out of order, so a state or an address older than the stored one leaves it, and `seenAt` never
87 * moves back. Address observations retain their application order to break timestamp ties.
88 */
89export function applyReports(peers: Readonly<Peers>, reports: readonly PeerReport[], at: number): Peers {
90  const isCurrent = (stored: { at: number } | undefined): boolean => stored === undefined || stored.at <= at
91  const next = new Map(Object.entries(peers).map(([name, peer]) => [
92    name, typeof peer.address === 'string' ? { ...peer, address: addressOf(peer) } : peer,
93  ]))
94  let order = 0
95  for (const peer of next.values()) order = Math.max(order, peer.address?.order ?? 0)
96  for (const { peer, state, address, source } of reports) {
97    const known = next.get(peer)
98    next.set(peer, {
99      ...known,
100      seenAt: Math.max(known?.seenAt ?? at, at),
101      ...(state !== undefined && isCurrent(known?.state) ? { state: { word: state, at, source: source ?? 'peer notice' } } : {}),
102      ...(address !== undefined && isCurrent(known?.address) ? { address: { value: address, at, order: ++order } } : {}),
103    })
104  }
105  const all = [...next]
106  return Object.fromEntries(all.length <= LIMITS.peers ? all : all.sort(([, a], [, b]) => b.seenAt - a.seenAt).slice(0, LIMITS.peers))
107}
108
109/**
110 * The key a send to a listed agent files under: its name, so the send joins that agent's messages to the main
111 * conversation, or its name and ID when the name is empty after peerKey, another listed agent has that name, or a
112 * peer of that name has an address (another session).
113 */
114function agentRecipient(agent: AgentInfo, agents: readonly AgentInfo[], peers: Readonly<Peers>): string {
115  if (agent.name === undefined) return ''
116  const name = peerKey(agent.name)
117  const isShared = name === '' || own(peers, name)?.address !== undefined ||
118    agents.some(other => other.id !== agent.id && peerKey(other.name ?? '') === name)
119  return isShared ? peerKey(`${agent.name} [${agent.id}]`) : name
120}
121
122/**
123 * The peer a send goes to: the peer whose newest address report matches `to`, else this session's agent whose
124 * id is `to` (agentRecipient), else the name `to` gives; undefined when that is no name.
125 * Equal address timestamps use observation order. Known names keep their brackets; an unknown recipient
126 * can carry one trailing listing reference.
127 */
128export function recipientOf(to: string, peers: Readonly<Peers>, agents: readonly AgentInfo[], entries: readonly HighCommandEntry[] = []): string | undefined {
129  let addressed: { name: string; at: number; order: number } | undefined
130  for (const [name, peer] of Object.entries(peers)) {
131    const address = addressOf(peer)
132    if (address?.value === to && (addressed === undefined || address.at > addressed.at ||
133      (address.at === addressed.at && (address.order ?? 0) > addressed.order))) {
134      addressed = { name, at: address.at, order: address.order ?? 0 }
135    }
136  }
137  if (addressed !== undefined) return addressed.name
138  const agent = agents.find(candidate => candidate.id === to)
139  let name = agent === undefined ? peerKey(to) : agentRecipient(agent, agents, peers)
140  const isKnown = Object.hasOwn(peers, name) || entries.some(entry => entry.peer === name) ||
141    agents.some(candidate => peerKey(candidate.name ?? '') === name)
142  if (agent === undefined && !isKnown) {
143    name = name.replace(RECIPIENT_REF, '')
144  }
145  return name === '' ? undefined : name
146}
147
148/** The observed send attempt, its reported outcome, and a bounded detailed refusal. */
149export function outboundEntry(peer: string, text: string, sent: SessionSendResult): NewEntry {
150  const body = sanitize(text)
151  return {
152    peer,
153    direction: 'out',
154    kind: 'message',
155    summary: summaryOf(undefined, body),
156    ...capBody(body),
157    ...(!sent.isDelivered ? { refusal: capBody(sanitize(sent.reason)) } : {}),
158    delivery: sent.isDelivered ? { isDelivered: true } : { isDelivered: false, reason: lineOf(sanitize(sent.reason)) },
159  }
160}
161
162/** An inbound entry newer than `mark`. Outbound entries and lane copies of the main conversation's sends are never new. */
163export function isNewer(entry: HighCommandEntry, mark: number): boolean {
164  return entry.direction === 'in' && entry.fromMain !== true && entry.seq > mark
165}
166
167/** The unread count of all rows. */
168export function unreadTotal(rows: readonly RosterRow[]): number {
169  return rows.reduce((sum, row) => sum + row.unread, 0)
170}
171
172/**
173 * One row per peer with entries: peers with unread entries first, then the newest activity first. While a peer
174 * has unread entries, its row shows the weakest trust level and any suspicious mark among them, so a newer
175 * message cannot hide a warning on one the person has not read; else it shows those of its newest inbound entry.
176 */
177export function rosterOf(entries: readonly HighCommandEntry[], peers: Readonly<Peers>, marks: Readonly<ReadMarks>): RosterRow[] {
178  const rows = new Map<string, RosterRow>()
179  for (const entry of entries) {
180    const row = rows.get(entry.peer) ?? { peer: entry.peer, unread: 0, last: entry, state: own(peers, entry.peer)?.state, isSuspicious: false }
181    if (entry.at > row.last.at || (entry.at === row.last.at && entry.seq > row.last.seq)) row.last = entry
182    if (entry.direction === 'in') {
183      const isUnread = isNewer(entry, markOf(marks, entry.peer))
184      // A peer's read entries come before its unread ones, so its first unread entry starts the fold. A lane copy of
185      // the main conversation's send is never unread and can follow unread entries; it leaves their fold alone.
186      if (isUnread || row.unread === 0) {
187        const isFolded = isUnread && row.unread > 0
188        row.trust = isFolded ? LEVELS.find(level => level === row.trust || level === entry.trust) : entry.trust
189        row.isSuspicious = (isFolded && row.isSuspicious) || entry.isSuspicious === true
190      }
191      if (isUnread) row.unread += 1
192    }
193    rows.set(entry.peer, row)
194  }
195  return [...rows.values()].sort((a, b) => Number(b.unread > 0) - Number(a.unread > 0) || b.last.at - a.last.at || b.last.seq - a.last.seq)
196}
197
198/** The unread count of all agent lanes. */
199export function lanesUnread(lanes: Readonly<Record<string, HighCommandLane>>): number {
200  return Object.values(lanes).reduce((sum, lane) => sum + unreadTotal(rosterOf(lane.entries, {}, lane.readThrough)), 0)
201}
202
203/** Per lane, the newest seq a press of Hide saw in `lanes`. A mark never moves back. */
204export function hideLanes(hidden: Readonly<Record<string, number>>, lanes: Readonly<Record<string, HighCommandLane>>): Record<string, number> {
205  const next = new Map(Object.entries(hidden))
206  for (const [id, lane] of Object.entries(lanes)) next.set(id, Math.max(next.get(id) ?? 0, newestSeq(lane.entries)))
207  return Object.fromEntries(next)
208}
209
210/** The seq of the newest lane entry that can be unread (isNewer), or 0 without one. */
211export function newestUnreadableSeq(list: readonly HighCommandEntry[]): number {
212  return list.findLast(entry => isNewer(entry, 0))?.seq ?? 0
213}
214
215/**
216 * Whether a Hide saw every lane's newest entry that can be unread, also one that left its lane since: the lane's
217 * `inboundThrough` keeps its seq, so an eviction cannot bring back a Hide that the entry ended.
218 */
219export function lanesSeen(lanes: Readonly<Record<string, HighCommandLane>>, hidden: Readonly<Record<string, number>>): boolean {
220  return Object.entries(lanes).every(([id, lane]) => Math.max(lane.inboundThrough ?? 0, newestUnreadableSeq(lane.entries)) <= markOf(hidden, id))
221}
222
223/**
224 * The versions of `entries` and `readThrough` that a status line count comes from, and the version of `lanes` when
225 * the line also counts agent lanes.
226 */
227type StatusView = { entries: number; marks: number; lanes?: number }
228
229/**
230 * Whether a status refresh may pin its count: its view is older in no value than the view behind the line and
231 * newer in one, the line has no view yet, or the line counts agent lanes and the refresh does not, or the reverse.
232 */
233export function isNewerView(view: StatusView, shown: StatusView | undefined): boolean {
234  if (shown === undefined || (view.lanes === undefined) !== (shown.lanes === undefined)) return true
235  const isOlder = view.entries < shown.entries || view.marks < shown.marks || (view.lanes ?? 0) < (shown.lanes ?? 0)
236  return !isOlder && (view.entries > shown.entries || view.marks > shown.marks || (view.lanes ?? 0) > (shown.lanes ?? 0))
237}
238
239/**
240 * Whether an append keeps the open thread: the person opened it after the append, or its peer
241 * has entries before and after it. So the thread on a peer the append drops closes, and so does a thread
242 * opened from an older view on a peer already gone, when that peer writes again.
243 */
244export function keepsThread(open: HighCommandThread, before: readonly HighCommandEntry[], after: readonly HighCommandEntry[]): boolean {
245  const has = (list: readonly HighCommandEntry[]): boolean => list.some(entry => entry.peer === open.peer)
246  return open.from >= newestSeq(after) || (has(before) && has(after))
247}
248
249/** The characters of `value` as JSON text: the measure that Claude Code holds a saved value to. */
250function jsonChars(value: unknown): number {
251  return JSON.stringify(value).length
252}
253
254/**
255 * The lane without its `drop` oldest entries, where `drop` is less than its entry count. Its newest entry stays and
256 * counts them as evicted, and the open thread closes as an append that dropped them would close it.
257 */
258function dropOldest(lane: HighCommandLane, drop: number): HighCommandLane {
259  if (drop <= 0) return lane
260  const kept = lane.entries.slice(drop)
261  const last = kept.at(-1)!
262  kept[kept.length - 1] = { ...last, evictedTotal: (last.evictedTotal ?? 0) + drop }
263  const open = lane.openThread
264  return { ...lane, entries: kept, openThread: open === null || keepsThread(open, lane.entries, kept) ? open : null }
265}
266
267/** Drops the lane's oldest entries while its entries pass LIMITS.laneChars; it keeps its newest entry. */
268function capLane(lane: HighCommandLane): HighCommandLane {
269  let size = jsonChars(lane.entries)
270  if (size <= LIMITS.laneChars) return lane
271  let drop = 0
272  while (size > LIMITS.laneChars && drop < lane.entries.length - 1) {
273    size -= jsonChars(lane.entries[drop]) + 1
274    drop += 1
275  }
276  const next = dropOldest(lane, drop)
277  // The evicted count on the newest entry adds a few characters.
278  return drop > 0 && jsonChars(next.entries) > LIMITS.laneChars ? capLane(next) : next
279}
280
281/**
282 * Fits the agent lanes in the space of one saved value. The lane `id`, the one that changed, first drops its own
283 * oldest entries past LIMITS.laneChars. Then, while all lanes pass LIMITS.lanesChars, the entries observed first
284 * drop from every lane. An entry drops only with the older entries of its lane, so it ranks by the latest observation
285 * among them. Each lane keeps its newest entry, so its seq numbers and read marks stay in step.
286 */
287export function fitLanes(lanes: Readonly<Record<string, HighCommandLane>>, id?: string): Record<string, HighCommandLane> {
288  const all = new Map(Object.entries(lanes))
289  const changed = id === undefined ? undefined : all.get(id)
290  if (changed !== undefined) all.set(id!, capLane(changed))
291  let over = jsonChars(Object.fromEntries(all)) - LIMITS.lanesChars
292  while (over > 0) {
293    const ranked: { lane: string; index: number; at: number; size: number }[] = []
294    for (const [name, lane] of all) {
295      let at = -Infinity
296      lane.entries.slice(0, -1).forEach((entry, index) => {
297        at = Math.max(at, entry.at)
298        ranked.push({ lane: name, index, at, size: jsonChars(entry) + 1 })
299      })
300    }
301    if (ranked.length === 0) break
302    ranked.sort((a, b) => a.at - b.at || a.index - b.index || (a.lane < b.lane ? -1 : a.lane > b.lane ? 1 : 0))
303    const drops = new Map<string, number>()
304    for (const { lane, index, size } of ranked) {
305      if (over <= 0) break
306      drops.set(lane, index + 1)
307      over -= size
308    }
309    for (const [name, drop] of drops) all.set(name, dropOldest(all.get(name)!, drop))
310    over = jsonChars(Object.fromEntries(all)) - LIMITS.lanesChars
311  }
312  return Object.fromEntries(all)
313}
314
315/** The thread of the open peer, or undefined when the pane draws the roster. */
316export function threadOf(entries: readonly HighCommandEntry[], rows: readonly RosterRow[], open: HighCommandThread | null): Thread | undefined {
317  const row = rows.find(candidate => candidate.peer === open?.peer)
318  if (open === null || row === undefined) return undefined
319  const all = entries.filter(entry => entry.peer === open.peer).sort((a, b) => b.at - a.at || b.seq - a.seq)
320  const groups: HighCommandEntry[][] = [[]]
321  let size = 0
322  for (const entry of all) {
323    const cost = JSON.stringify([entry.body, entry.summary]).length + 2000
324    if (groups.at(-1)!.length >= LIMITS.threadEntries || size + cost > 60_000) {
325      groups.push([])
326      size = 0
327    }
328    groups.at(-1)!.push(entry)
329    size += cost
330  }
331  const pages = groups.length
332  const page = Math.max(0, Math.min(open.page ?? 0, pages - 1))
333  const shown = groups[page]!
334  const fresh = all.filter(entry => isNewer(entry, open.since)).length
335  return { row, since: open.since, shown, hidden: all.length - shown.length, fresh, page, pages, identityPage: open.identityPage, reasonSeq: open.reasonSeq }
336}
337
338/**
339 * Marks `peer`, or every peer when none is given, read through its newest inbound entry in the window `entries`.
340 * A mark never moves back: `update` can apply an entries snapshot older than marks that another press already
341 * wrote. A mark below the window's oldest entry goes, so the marks of peers that left the window do not pile up:
342 * every entry of its peer, now or later, is newer, so it marked nothing. An older snapshot only drops less.
343 */
344export function markRead(marks: Readonly<ReadMarks>, entries: readonly HighCommandEntry[], peer?: string): ReadMarks {
345  const oldest = entries[0]?.seq ?? 0
346  const next = new Map(Object.entries(marks).filter(([, seq]) => seq >= oldest))
347  for (const entry of entries) {
348    if (entry.direction === 'in' && (peer === undefined || entry.peer === peer)) {
349      next.set(entry.peer, Math.max(next.get(entry.peer) ?? 0, entry.seq))
350    }
351  }
352  return Object.fromEntries(next)
353}
354
355/** The arrow and label for an inbound message or an observed send attempt. */
356export function labelOf(entry: HighCommandEntry): { arrow: '←' | '→'; word: string } {
357  if (entry.direction === 'out') return { arrow: '→', word: entry.delivery?.isDelivered === false ? 'send attempted (not delivered)' : 'send attempted' }
358  return { arrow: '←', word: entry.kind === 'result' ? 'finished' : entry.kind === 'failure' ? 'failed' : 'message' }
359}
360
361/** A settled core call proves only that call's queue outcome. */
362export function coreEvidence(trace: readonly TraceEntry<'session.send'>[]): HighCommandEntry['core'] {
363  const core = trace.find(item => item.plugin === 'engine' && item.tier === 'core' &&
364    (item.outcome === 'returned' || item.outcome === 'passed') && item.returned !== undefined)
365  if (core?.returned === undefined) return undefined
366  return {
367    to: sanitize(core.received.to).slice(0, LIMITS.bodyChars),
368    ...capBody(sanitize(core.received.text)),
369    isDelivered: core.returned.isDelivered,
370    ...(!core.returned.isDelivered ? { reason: lineOf(sanitize(core.returned.reason)) } : {}),
371  }
372}
373
hooks/listing.ts 53 lines
1import type { PeerReport } from './envelope'
2import { LIMITS } from './limits'
3import { peerKey, sanitize } from './text'
4
5/**
6 * The start of a listing row, `  name [ref]`, where the engine's `·` fields begin. The name runs to the last
7 * ` [ref]` that a `·` follows, so a session name that holds ` [ref] · ` stays whole and cannot place a field.
8 * The fields after it are split on `·`, not matched by a repeated group, so a hostile row cannot make the
9 * match backtrack across its fields.
10 */
11const HEAD = /^\s+(\S.*) \[[0-9a-z]+\](?=\s+·)/i
12/** A state word as the engine writes one: a letter, then letters, spaces, and hyphens. */
13const STATE_WORD = new RegExp(`^[a-z][a-z -]{0,${LIMITS.stateChars - 1}}$`, 'i')
14
15/** Peer states from a ListAgents listing, and how many lines looked like rows. */
16export function parseListing(listing: string): { reports: PeerReport[]; rowCount: number } {
17  const rows = sanitize(listing)
18    .split('\n')
19    .filter(line => /^\s+\S/.test(line))
20  const reports: PeerReport[] = []
21  for (const row of rows) {
22    const head = HEAD.exec(row)
23    if (head === null) continue
24    const peer = peerKey(head[1] ?? '')
25    // The text before the first `·` is the space after the ref; the fields keep their places, so an empty one stays empty.
26    const state = row.slice(head[0].length).split('·')[2]?.trim()
27    if (peer !== '' && state !== undefined && STATE_WORD.test(state)) reports.push({ peer, state })
28  }
29  return { reports, rowCount: rows.length }
30}
31
32/** Structured rows take precedence; duplicate names cannot establish one peer's state. */
33export function parseStructuredListing(result: unknown): { reports: PeerReport[]; rowCount: number } | undefined {
34  if (typeof result !== 'object' || result === null || !('sections' in result) || !Array.isArray(result.sections)) return undefined
35  const states = new Map<string, string | undefined>()
36  let rowCount = 0
37  for (const section of result.sections) {
38    if (typeof section !== 'object' || section === null || !Array.isArray(section.rows)) continue
39    for (const row of section.rows) {
40      rowCount++
41      if (typeof row !== 'object' || row === null || typeof row.name !== 'string') continue
42      const peer = peerKey(row.name)
43      if (peer === '') continue
44      const state = typeof row.status === 'string' ? sanitize(row.status).trim() : ''
45      states.set(peer, states.has(peer) || !STATE_WORD.test(state) ? undefined : state)
46    }
47  }
48  return {
49    reports: [...states].flatMap(([peer, state]) => state === undefined ? [] : [{ peer, state, source: 'structured listing' }]),
50    rowCount,
51  }
52}
53
hooks/summary.ts 55 lines
1import type { HighCommandAgent, HighCommandCapture, HighCommandLane } from '../types'
2import { labelOf, rosterOf, unreadTotal, type RosterRow } from './inbox'
3import { captureLabel, clockLabel, count, peerLabel, peerLabels, stateLabel, trustLabel } from './text'
4
5/** Names remain literal, including backticks and link-shaped text. */
6function literal(name: string): string {
7  const longest = Math.max(0, ...Array.from(name.matchAll(/`+/g), run => run[0].length))
8  const fence = '`'.repeat(longest + 1)
9  const pad = name.startsWith('`') || name.endsWith('`') ? ' ' : ''
10  return `${fence}${pad}${name}${pad}${fence}`
11}
12
13type SummaryContext = { evicted?: number; capture?: HighCommandCapture | null; agents?: readonly HighCommandAgent[]; lanes?: Record<string, HighCommandLane> }
14
15/** Counts cover retained messages only. Message bodies never enter command output. */
16export function summaryText(rows: readonly RosterRow[], entryCount: number, now: number, context: SummaryContext = {}): string {
17  const labels = peerLabels(rows.map(row => row.peer))
18  const useList = Array.from(labels.values()).some(name => {
19    let oddBackslashes = false
20    for (const char of name) {
21      if (char === '|' && oddBackslashes) return true
22      oddBackslashes = char === '\\' && !oddBackslashes
23    }
24    return false
25  })
26  const lines = rows.map(row => {
27    const label = trustLabel(row.trust)
28    const name = literal(labels.get(row.peer)!)
29    const peer = `${name}${label === undefined ? '' : ` (${label})`}`
30    const { arrow, word } = labelOf(row.last)
31    const fields = [String(row.unread), clockLabel(row.last.at, now), stateLabel(row.state, now) ?? '—', `${arrow} ${word}`]
32    if (useList) return `- ${peer}: ${fields[0]} new; last ${fields[1]}; state ${fields[2]}; latest ${fields[3]}`
33    const escaped = peer.replace(/\|/g, '\\|')
34    return `| ${escaped} | ${fields.join(' | ')} |`
35  })
36  const header = rows.length === 0 ? 'No messages from teammates or other sessions in this capture window.' :
37    `${unreadTotal(rows)} unread · ${count(rows.length, 'peer')} · ${count(entryCount, 'retained message')}`
38  const agents = context.agents ?? []
39  const lanes = Object.entries(context.lanes ?? {})
40  return [
41    ...(context.capture ? [captureLabel(context.capture, now)] : []),
42    header,
43    'Order: observation time; read marks and retention use recording sequence.',
44    ...((context.evicted ?? 0) > 0 ? [`History trimmed: ${count(context.evicted!, 'message')} permanently evicted; totals cover retained messages.`] : []),
45    ...(rows.length === 0 ? [] : ['', ...(useList ? [] : ['| Peer | New | Last | State | Latest |', '| --- | --- | --- | --- | --- |']), ...lines]),
46    ...(lanes.length === 0 ? [] : ['', 'Agent observations (separate counts; split-pane traffic unavailable):',
47      ...lanes.map(([id, lane]) => `- ${literal(peerLabel(id))}: ${unreadTotal(rosterOf(lane.entries, lane.peers, lane.readThrough))} unread; ${count(lane.entries.length, 'retained message')}; ${lane.entries.at(-1)?.evictedTotal ?? 0} permanently evicted`),
48    ]),
49    ...(agents.length === 0 ? [] : ['', 'Local agents (separate from peer names; split-pane liveness is unconfirmed):',
50      ...agents.slice(0, 100).map(agent => `- ${literal(peerLabel(agent.name ?? agent.id))} (${literal(peerLabel(agent.id))}): ${agent.status}; ${agent.source} at ${clockLabel(agent.at, now)}${agent.lastEvent ? `; lifecycle observation: ${agent.lastEvent.word} at ${clockLabel(agent.lastEvent.at, now)}` : ''}`),
51      ...(agents.length > 100 ? [`${agents.length - 100} more local agents; open the pane to page through them.`] : []),
52    ]),
53  ].join('\n')
54}
55
hooks/text.ts 197 lines
1import type { HighCommandState, HighCommandTrust } from '../types'
2import { LIMITS } from './limits'
3
4const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
5const FORMAT = /\p{Cf}/gu
6/**
7 * The invisible characters that the suspicious-text check reads past: format characters (category Cf), the
8 * combining grapheme joiner U+034F, and the variation selectors U+FE00 to U+FE0F and U+E0100 to U+E01EF.
9 */
10const INVISIBLE = /[\p{Cf}\u034F\uFE00-\uFE0F\u{E0100}-\u{E01EF}]/gu
11const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
12
13/**
14 * The suspicious-text list, in order: an instruction to ignore earlier instructions, to
15 * change the permission mode, to run a download in a shell (piped, or through `$(...)` or `<(...)`), or to
16 * read a credential file. Every repeat is bounded or over distinct words, so a match is linear in the text.
17 * `hasBase64Block` adds the last item of the list, a long base64 block.
18 */
19const SUSPICIOUS = [
20  /\b(?:ignore|disregard|forget)\s+(?:(?:all|any|the|your|of)\s+)*(?:(?:previous|prior|earlier|above)\s+(?:instructions?|prompts?|rules?)\b|(?:instructions?|prompts?|rules?)\s+above\b)/i,
21  /\b(?:bypassPermissions|dangerously-skip-permissions)\b|\b(?:change|switch|set)\b[^.\n]{0,30}\bpermission mode\b/i,
22  /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\b(?:(?:ba|z|da|k)?sh|eval|source)\s+(?:-c\s+)?["']?(?:\$\(|<\()\s*(?:curl|wget)\b/i,
23  /\b(?:cat|read|print|send|upload|paste|share|copy|dump)\b[^\n]{0,60}(?:\.ssh\/|\bid_(?:rsa|ecdsa|ed25519)\b|\.aws\/credentials|\.netrc\b|\.git-credentials\b|\.env\b)/i,
24]
25/** Characters of one unbroken base64 run that make a long base64 block. */
26const BASE64_RUN = 200
27
28/** Removes control characters, keeping tab and line feed, so no terminal sequence reaches a drawing. */
29export function sanitize(text: string): string {
30  return text.replace(CONTROL, '')
31}
32
33/** Replaces each run of white space with one space and trims. */
34function oneLine(text: string): string {
35  return text.replace(/\s+/g, ' ').trim()
36}
37
38/** The first line that holds a non-space character, as one line; '' when there is none. */
39function firstLine(text: string): string {
40  const line = text.split('\n').find(part => part.trim() !== '')
41  return line === undefined ? '' : oneLine(line)
42}
43
44/** Keeps at most `max` UTF-16 units of a longer text, never splitting a surrogate pair. */
45function cut(text: string, max: number): string {
46  if (text.length <= max) return text
47  const kept = text.slice(0, max)
48  const last = kept.charCodeAt(max - 1)
49  return last >= 0xd800 && last <= 0xdbff ? kept.slice(0, -1) : kept
50}
51
52/** The text when it has at most `max` characters, else its start and '…'. */
53function clip(text: string, max: number): string {
54  return text.length <= max ? text : `${cut(text, max - 1)}…`
55}
56
57/** The text as one line of at most LIMITS.summaryChars characters. */
58export function lineOf(text: string): string {
59  return clip(oneLine(text), LIMITS.summaryChars)
60}
61
62/** One summary line: the preferred text, else the first line of the body, else '(empty message)'. */
63export function summaryOf(preferred: string | undefined, body: string): string {
64  return lineOf(preferred ?? '') || lineOf(firstLine(body)) || '(empty message)'
65}
66
67/** The body as stored, and its length before the cut. */
68export function capBody(text: string): { body: string; fullChars: number } {
69  return { body: cut(text, LIMITS.bodyChars), fullChars: text.length }
70}
71
72function twoDigits(value: number): string {
73  return String(value).padStart(2, '0')
74}
75
76/** `HH:MM` on the local day of `now`, else `Mon D HH:MM`. */
77export function clockLabel(at: number, now: number): string {
78  const time = new Date(at)
79  const today = new Date(now)
80  const hhmm = `${twoDigits(time.getHours())}:${twoDigits(time.getMinutes())}`
81  const isToday =
82    time.getFullYear() === today.getFullYear() &&
83    time.getMonth() === today.getMonth() &&
84    time.getDate() === today.getDate()
85  return isToday ? hhmm : `${MONTHS[time.getMonth()]} ${time.getDate()} ${hhmm}`
86}
87
88/** A state with the clock time it was reported, as in `idle (12:04)`; undefined without a state. */
89export function stateLabel(state: HighCommandState | undefined, now: number): string | undefined {
90  return state === undefined ? undefined : `${state.word} (${clockLabel(state.at, now)}${state.source ? `; ${state.source}; last reported` : ''})`
91}
92
93/**
94 * Whether the text holds BASE64_RUN or more base64 characters in a row that mix upper case, lower case,
95 * and digits, as encoded data does; a run of one letter or of hex digits is not a block. One pass.
96 */
97function hasBase64Block(text: string): boolean {
98  let run = 0
99  let kinds = 0
100  for (let index = 0; index < text.length; index += 1) {
101    const code = text.charCodeAt(index)
102    const kind = code >= 65 && code <= 90 ? 1 : code >= 97 && code <= 122 ? 2 : code >= 48 && code <= 57 ? 4 : code === 43 || code === 47 ? 8 : 0
103    run = kind === 0 ? 0 : run + 1
104    kinds = kind === 0 ? 0 : kinds | kind
105    if (run >= BASE64_RUN && (kinds & 7) === 7) return true
106  }
107  return false
108}
109
110/**
111 * Whether the text matches the suspicious-text list; the mark it gives is for display only. The list is tested on
112 * two forms of the text: each INVISIBLE character read as a space, as between two words, and each one removed,
113 * as inside a word. A match in either form marks the text. A space matches every class that an INVISIBLE
114 * character matches in the list, so the first form keeps each match of the text as it came.
115 */
116export function isSuspicious(text: string): boolean {
117  return [text.replace(INVISIBLE, ' '), text.replace(INVISIBLE, '')].some(
118    form => SUSPICIOUS.some(pattern => pattern.test(form)) || hasBase64Block(form),
119  )
120}
121
122/**
123 * How the roster, the thread, and `/highcommand` name a trust level, as in `verified sender`; no label for a peer
124 * that only received sends.
125 */
126export function trustLabel(trust: HighCommandTrust | undefined): string | undefined {
127  return trust === undefined ? undefined : `${trust} sender`
128}
129
130/** A count and its noun: `1 peer`, `2 peers`. */
131export function count(n: number, noun: string): string {
132  return `${n} ${noun}${n === 1 ? '' : 's'}`
133}
134
135/**
136 * The unread counts that the band and the status line show, as in `3 unread (retained) · 2 unread in agent lanes`:
137 * the main inbox's count, and the agent lanes' count when it is above zero; '' when both are zero.
138 */
139export function unreadText(main: number, lanes = 0): string {
140  return [main > 0 ? `${main} unread (retained)` : undefined, lanes > 0 ? `${lanes} unread in agent lanes` : undefined]
141    .filter(part => part !== undefined).join(' · ')
142}
143
144/**
145 * A full peer identity: one line without control or format characters
146 * (bidi controls, zero-width marks). Bracketed text belongs to the name.
147 */
148export function peerKey(name: string): string {
149  return oneLine(sanitize(name).replace(FORMAT, ''))
150}
151
152/** A display label for a normalized peer identity, clipped without changing its state key. */
153export function peerLabel(peer: string): string {
154  if (peer.length <= LIMITS.nameChars) return peer
155  let hash = 2166136261
156  for (let index = 0; index < peer.length; index++) hash = Math.imul(hash ^ peer.charCodeAt(index), 16777619)
157  return `${clip(peer, LIMITS.nameChars - 11)} [${(hash >>> 0).toString(16).padStart(8, '0')}]`
158}
159
160/** Resolves even hash collisions across the current retained roster. */
161export function peerLabels(peers: readonly string[]): Map<string, string> {
162  const groups = new Map<string, string[]>()
163  for (const peer of new Set(peers)) {
164    const label = peerLabel(peer)
165    groups.set(label, [...(groups.get(label) ?? []), peer])
166  }
167  const result = new Map<string, string>()
168  const reserved = new Set(groups.keys())
169  for (const [label, group] of groups) {
170    group.sort()
171    let index = 0
172    for (const peer of group) {
173      let resolved = label
174      if (group.length > 1) {
175        do { resolved = `${clip(label, 50)} [${++index}]` } while (reserved.has(resolved))
176        reserved.add(resolved)
177      }
178      result.set(peer, resolved)
179    }
180  }
181  return result
182}
183
184/** A conversation-local marker, preserved across module reloads. */
185export function captureLabel(capture: { at: number; reset: boolean }, now: number): string {
186  return `Capture window: ${clockLabel(capture.at, now)} · current conversation only. ${capture.reset ? 'Previous High Command history was not restored.' : 'Earlier history is not included.'}`
187}
188
189/** Fixed-width identity pages never separate the halves of a surrogate pair. */
190export function identityPart(peer: string, page: number): string {
191  const boundary = (at: number): number => {
192    const char = peer.charCodeAt(at)
193    return char >= 0xdc00 && char <= 0xdfff ? at - 1 : at
194  }
195  return peer.slice(boundary(page * 1000), boundary((page + 1) * 1000))
196}
197
hooks/views.tsx 258 lines
1import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2
3import type { HighCommandEntry, HighCommandAgent, HighCommandCapture } from '../types'
4import { isNewer, labelOf, unreadTotal, type RosterRow, type Thread } from './inbox'
5import { LIMITS } from './limits'
6import { captureLabel, identityPart, clockLabel, count, peerLabel, peerLabels, stateLabel, trustLabel, unreadText } from './text'
7
8/** The elements High Command draws with; every surface's table has them. */
9type HighCommandElements = {
10  Box: ElementConstructor<BoxProps>
11  Text: ElementConstructor<TextProps>
12  Button: ElementConstructor<ButtonProps>
13}
14
15/** What the pane draws: the roster, or the open thread. */
16type PaneModel = {
17  rows: RosterRow[]; now: number; thread: Thread | undefined
18  evicted?: number; capture?: HighCommandCapture | null
19  rosterPage?: number; laneId?: string; laneIds?: string[]; lanePage?: number; agents?: HighCommandAgent[]
20  /** Each agent lane's unread count, by agent id. */
21  laneUnread?: ReadonlyMap<string, number>
22}
23
24/**
25 * Each action returns its work, and each Button's onPress returns the action's promise: a press stays the
26 * person's own input only while that work runs.
27 */
28export type PaneActions = {
29  openThread: (peer: string) => Promise<void>
30  back: () => Promise<void>
31  markRead: (peer: string) => Promise<void>
32  readAll: () => Promise<void>
33  page: (page: number) => Promise<void>
34  identity: (page: number | undefined) => Promise<void>
35  reason: (seq: number) => Promise<void>
36  lane: (id: string) => Promise<void>
37  lanePage: (page: number) => Promise<void>
38  rosterPage: (page: number) => Promise<void>
39}
40
41export type BandActions = { open: () => Promise<void>; readAll: () => Promise<void>; hide: () => Promise<void> }
42
43/** The defined parts, joined with ` · `. */
44function joined(parts: readonly (string | undefined)[]): string {
45  return parts.filter(part => part !== undefined).join(' · ')
46}
47
48/** Labels send attempts and inbound results or failures before their summary. */
49function kindPrefix(entry: HighCommandEntry): string {
50  if (entry.direction === 'out') return 'send attempted: '
51  const { word } = labelOf(entry)
52  return word !== 'message' ? `${word}: ` : ''
53}
54
55/** The sender label and the suspicious mark come before the long state, so a narrow pane cuts the state first. */
56function rowText(row: RosterRow, now: number): string {
57  const { arrow } = labelOf(row.last)
58  return joined([
59    row.unread > 0 ? `${row.unread} new` : undefined,
60    trustLabel(row.trust),
61    row.isSuspicious ? 'suspicious' : undefined,
62    clockLabel(row.last.at, now),
63    stateLabel(row.state, now),
64    `${arrow} ${kindPrefix(row.last)}${row.last.summary}${row.last.delivery?.isDelivered === false ? ' (not delivered)' : ''}`,
65  ])
66}
67
68function entryHeader(entry: HighCommandEntry, isFresh: boolean, now: number): string {
69  const delivery =
70    entry.delivery === undefined ? '' : entry.delivery.isDelivered ? ' · reported send result: queued' : ` · reported send result: not delivered: ${entry.delivery.reason}`
71  const marks = joined([trustLabel(entry.trust), entry.isSuspicious === true ? 'suspicious' : undefined])
72  return `${labelOf(entry).arrow} ${clockLabel(entry.at, now)} ${kindPrefix(entry)}${entry.summary}${delivery}${marks === '' ? '' : ` · ${marks}`}${isFresh ? ' · new' : ''}`
73}
74
75function rosterView({ Box, Text, Button }: HighCommandElements, model: PaneModel, actions: PaneActions, labels: Map<string, string>): RenderElement {
76  const unread = unreadTotal(model.rows)
77  const pages = Math.max(1, Math.ceil(model.rows.length / LIMITS.rosterRows))
78  const page = Math.max(0, Math.min(model.rosterPage ?? 0, pages - 1))
79  return (
80    <Box flexDirection="column">
81      <Box flexDirection="row" columnGap={1}>
82        <Text bold>High Command</Text>
83        <Text dimColor>{`· ${unread} unread (retained) · ${count(model.rows.length, 'peer')}`}</Text>
84        {unread > 0 && <Button key="pane:read-all" label="Read all" hotkey="r" onPress={actions.readAll} />}
85      </Box>
86      {model.rows.length === 0 && <Text dimColor>No messages from teammates or other sessions in this capture window.</Text>}
87      {model.rows.slice(page * LIMITS.rosterRows, (page + 1) * LIMITS.rosterRows).map((row, index) => (
88        <Box flexDirection="row" columnGap={1}>
89          <Button
90            key={`open:${row.last.seq}`}
91            label={labels.get(row.peer)!}
92            plain
93            {...(index < LIMITS.hotkeys ? { hotkey: String(index + 1) } : {})}
94            onPress={() => actions.openThread(row.peer)}
95          />
96          <Text bold={row.unread > 0} wrap="truncate-end">
97            {rowText(row, model.now)}
98          </Text>
99        </Box>
100      ))}
101      {pages > 1 && <Text dimColor>{`Peer page ${page + 1} of ${pages}`}</Text>}
102      {page > 0 && <Button key="roster:previous" label="Previous peers" onPress={() => actions.rosterPage(page - 1)} />}
103      {page + 1 < pages && <Button key="roster:next" label="More peers" onPress={() => actions.rosterPage(page + 1)} />}
104    </Box>
105  )
106}
107
108function threadView({ Box, Text, Button }: HighCommandElements, now: number, thread: Thread, actions: PaneActions, label: string): RenderElement {
109  const { row, since, shown, hidden, fresh } = thread
110  const details = joined([
111    stateLabel(row.state, now),
112    trustLabel(row.trust),
113    count(shown.length + hidden, 'retained message'),
114    fresh > 0 ? `${fresh} new` : undefined,
115  ])
116  return (
117    <Box flexDirection="column">
118      <Box flexDirection="row" columnGap={1}>
119        <Button key="pane:back" label="Back" hotkey="b" onPress={actions.back} />
120        <Text bold>{label}</Text>
121        <Button key="pane:identity" label="Identity" onPress={() => actions.identity(thread.identityPage === undefined ? 0 : undefined)} />
122        <Text dimColor>{`· ${details}`}</Text>
123        {row.unread > 0 && <Button key="pane:mark-read" label="Mark read" hotkey="r" onPress={() => actions.markRead(row.peer)} />}
124      </Box>
125      {thread.identityPage !== undefined && <Box flexDirection="column">
126        <Text>{`Identity part ${thread.identityPage + 1} of ${Math.ceil(row.peer.length / 1000)} (${row.peer.length} characters):`}</Text>
127        <Text>{identityPart(row.peer, thread.identityPage)}</Text>
128        {thread.identityPage > 0 && <Button key="identity:previous" label="Previous identity part" onPress={() => actions.identity(thread.identityPage! - 1)} />}
129        {(thread.identityPage + 1) * 1000 < row.peer.length && <Button key="identity:next" label="Next identity part" onPress={() => actions.identity(thread.identityPage! + 1)} />}
130      </Box>}
131      {shown.some(entry => entry.direction === 'out') && (
132        <Text dimColor>Send attempts show observed text. Reported success is not a receipt for that text. Core evidence covers only the latest traced call, never reading or every send.</Text>
133      )}
134      {shown.map(entry => {
135        const isFresh = isNewer(entry, since)
136        return (
137          <Box flexDirection="column" marginTop={1}>
138            <Text bold={isFresh}>{entryHeader(entry, isFresh, now)}</Text>
139            <Text wrap="wrap">{entry.body}</Text>
140            {(entry.refusal !== undefined || entry.core !== undefined) && <Button key={`reason:${entry.seq}`} label="Send details" onPress={() => actions.reason(entry.seq)} />}
141            {thread.reasonSeq === entry.seq && entry.refusal !== undefined && <Box flexDirection="column">
142              <Text>{entry.refusal.body}</Text>
143              {entry.refusal.fullChars > entry.refusal.body.length && <Text dimColor>{`Refusal truncated: ${entry.refusal.fullChars} characters originally; ${entry.refusal.body.length} retained.`}</Text>}
144            </Box>}
145            {thread.reasonSeq === entry.seq && entry.core !== undefined && <Box flexDirection="column">
146              <Text>{`Latest traced core recipient (bounded): ${entry.core.to}`}</Text>
147              <Text>{`Core queue outcome: ${entry.core.isDelivered ? 'accepted, not read' : `refused: ${entry.core.reason}`}`}</Text>
148              <Text>{entry.core.body}</Text>
149              {entry.core.fullChars > entry.core.body.length && <Text>{`Core text truncated: ${entry.core.fullChars} characters originally.`}</Text>}
150            </Box>}
151            {entry.fullChars > entry.body.length && (
152              <Text dimColor>{`(${count(entry.fullChars - entry.body.length, 'more character')} in the transcript.)`}</Text>
153            )}
154          </Box>
155        )
156      })}
157      {thread.page > 0 && <Button key="pane:newer" label="Newer" onPress={() => actions.page(thread.page - 1)} />}
158      {thread.page + 1 < thread.pages && <Button key="pane:older" label="Older" onPress={() => actions.page(thread.page + 1)} />}
159      {hidden > 0 && (
160        <Box marginTop={1}>
161          <Text dimColor>{`Page ${thread.page + 1} of ${thread.pages}; ${count(hidden, 'retained message')} on other pages.`}</Text>
162        </Box>
163      )}
164    </Box>
165  )
166}
167
168/** Includes JSON structure, escaped strings, and space for serialized button handlers. */
169export function fitsBounds(tree: RenderElement): boolean {
170  const pending = [{ node: tree as unknown, depth: 1 }]
171  let nodes = 0
172  while (pending.length > 0) {
173    const { node, depth } = pending.pop()!
174    if (++nodes > LIMITS.drawingNodes || depth > LIMITS.drawingDepth) return false
175    if (typeof node !== 'object' || node === null) continue
176    const children: unknown = Reflect.get(node, 'children')
177    if (Array.isArray(children)) for (const child of children) pending.push({ node: child, depth: depth + 1 })
178  }
179  try {
180    return JSON.stringify(tree, (_key, value: unknown) => typeof value === 'function' ? 'handler'.padEnd(256, '_') : value).length <= LIMITS.drawingText
181  } catch {
182    return false
183  }
184}
185
186/** The pane's tree: the roster, or one peer's thread, newest entry first. */
187export function paneTree(ui: HighCommandElements, model: PaneModel, actions: PaneActions): RenderElement {
188  const { Box, Text, Button } = ui
189  const labels = peerLabels(model.rows.map(row => row.peer))
190  const agentMap = new Map((model.agents ?? []).map(agent => [agent.id, agent]))
191  const ids = [...new Set([...(model.laneIds ?? []), ...agentMap.keys()])].sort()
192  const pages = Math.max(1, Math.ceil(ids.length / 10))
193  const page = Math.max(0, Math.min(model.lanePage ?? 0, pages - 1))
194  return <Box flexDirection="column">
195    {model.capture && <Text dimColor>{captureLabel(model.capture, model.now)}</Text>}
196    <Text dimColor>Order: observation time. Counts cover retained messages.</Text>
197    {(model.evicted ?? 0) > 0 && <Text>{`History trimmed: ${count(model.evicted!, 'message')} permanently evicted.`}</Text>}
198    {ids.length > 0 && <Box flexDirection="column">
199      <Text bold>{model.laneId === undefined ? 'Main inbox' : `Agent observations: ${peerLabel(model.laneId)}`}</Text>
200      <Text dimColor>Send and receive observations are separate. Split-pane traffic is unavailable; roster snapshots do not confirm liveness.</Text>
201      <Button key="lane:main" label="Main inbox" onPress={() => actions.lane('')} />
202      {ids.slice(page * 10, page * 10 + 10).map((id, index) => {
203        const agent = agentMap.get(id)
204        const unread = model.laneUnread?.get(id) ?? 0
205        // The status goes on its own line under the button: beside a long button, a narrow dock left it a few columns.
206        return <Box flexDirection="column">
207          <Button key={`lane:${page * 10 + index}`} label={`${peerLabel(agent?.name ?? id)} (${peerLabel(id)})${unread > 0 ? ` · ${unread} unread` : ''}`} onPress={() => actions.lane(id)} />
208          {agent && <Box paddingLeft={2}>
209            <Text>{`${agent.status}; ${agent.source} at ${clockLabel(agent.at, model.now)}${agent.lastEvent ? `; lifecycle observation: ${agent.lastEvent.word} at ${clockLabel(agent.lastEvent.at, model.now)}` : ''}`}</Text>
210          </Box>}
211        </Box>
212      })}
213      {page > 0 && <Button key="lanes:previous" label="Previous agents" onPress={() => actions.lanePage(page - 1)} />}
214      {page + 1 < pages && <Button key="lanes:next" label="More agents" onPress={() => actions.lanePage(page + 1)} />}
215    </Box>}
216    {model.thread === undefined ? rosterView(ui, model, actions, labels) : threadView(ui, model.now, model.thread, actions, labels.get(model.thread.row.peer)!)}
217  </Box>
218}
219
220/**
221 * The band row above what other mods draw there. `waiting` holds only rows with unread entries; `laneUnread` is the
222 * unread count of all agent lanes, above zero only with agentLanesInBand on.
223 */
224export function bandTree(
225  { Box, Text, Button }: HighCommandElements,
226  waiting: readonly RosterRow[],
227  actions: BandActions,
228  below: RenderElement,
229  labels: Map<string, string>,
230  laneUnread = 0,
231): RenderElement {
232  const named = waiting.slice(0, LIMITS.bandPeers).map(row => `${labels.get(row.peer)} ${row.unread}`)
233  const more = waiting.length > LIMITS.bandPeers ? [`+${waiting.length - LIMITS.bandPeers} more`] : []
234  const peers = [...named, ...more]
235  return (
236    <Box flexDirection="column">
237      <Box flexDirection="row" columnGap={1}>
238        <Text wrap="truncate-end">{`High Command · ${unreadText(unreadTotal(waiting), laneUnread)}${peers.length === 0 ? '' : ` — ${peers.join(' · ')}`}`}</Text>
239        <Button key="band:open" label="Open" hotkey="o" onPress={actions.open} />
240        <Button key="band:read-all" label="Read all" hotkey="r" onPress={actions.readAll} />
241        <Button key="band:hide" label="Hide" hotkey="h" onPress={actions.hide} />
242      </Box>
243      {below}
244    </Box>
245  )
246}
247
248/** Keeps only the counts and Open when the full band exceeds the shared budget. */
249export function compactBandTree({ Box, Text, Button }: HighCommandElements, waiting: readonly RosterRow[], actions: BandActions, below: RenderElement, laneUnread = 0): RenderElement {
250  return <Box flexDirection="column">
251    <Box flexDirection="row">
252      <Text>{`High Command · ${unreadText(unreadTotal(waiting), laneUnread)}`}</Text>
253      <Button key="band:open" label="Open" hotkey="o" onPress={actions.open} />
254    </Box>
255    {below}
256  </Box>
257}
258
hooks/limits.ts 40 lines
1/** Every bound of High Command, in one place. */
2export const LIMITS = {
3  /** Entries kept in all; the oldest drop first. */
4  entries: 200,
5  /** Entries kept for one peer; a busy peer drops its own oldest entries first. */
6  peerEntries: 50,
7  /** Characters of JSON text that one agent lane's entries keep; past it, that lane drops its own oldest entries first. */
8  laneChars: 500_000,
9  /**
10   * Characters of JSON text that all agent lanes keep together: they are saved as one value, and Claude Code 2.1.296
11   * refuses a value over 4,194,304 characters. Past it, the entries observed first drop; each lane keeps its newest.
12   */
13  lanesChars: 4_000_000,
14  /** Peers kept; the ones reported least recently drop first. */
15  peers: 100,
16  /** Characters of a body kept; the transcript keeps the rest. */
17  bodyChars: 2000,
18  /** Characters of a summary line. */
19  summaryChars: 120,
20  /** Characters of a displayed peer label; identity keys keep the full normalized name. */
21  nameChars: 60,
22  /** Characters of a ListAgents state word. */
23  stateChars: 24,
24  /** Peer rows on one roster page. */
25  rosterRows: 50,
26  /** Entries drawn in one thread. */
27  threadEntries: 20,
28  /**
29   * The engine's bounds on one drawing: characters of text, nodes (strings count), and depth. The engine
30   * refuses a drawing past any of them, so the band checks the drawing it wraps against them.
31   */
32  drawingText: 100_000,
33  drawingNodes: 20_000,
34  drawingDepth: 32,
35  /** Peers named in the band. */
36  bandPeers: 3,
37  /** Roster rows that get a digit hotkey. */
38  hotkeys: 9,
39} as const
40
types/index.d.ts 129 lines
1/** A peer's last reported state. */
2export type HighCommandState = {
3  /** 'idle', 'interrupted', 'failed', 'ended', 'shut down', or a ListAgents state word. */
4  word: string
5  source?: string
6  /** $.clock.now() when the state was reported. */
7  at: number
8}
9
10/**
11 * How far Claude Code vouches for an inbound sender name: 'verified' for a team mailbox
12 * entry the harness wrote under that name, 'local' for another session's or agent's message that Claude
13 * Code delivered, 'unverified' for every other origin, a name only a plugin claims included.
14 */
15export type HighCommandTrust = 'verified' | 'local' | 'unverified'
16
17/** One receive observation ('in') or send attempt ('out') in its conversation lane. */
18export type HighCommandEntry = {
19  /** Lane-unique and increasing from 1: the recording order. */
20  seq: number
21  /** The full normalized peer identity: the thread this entry belongs to. */
22  peer: string
23  direction: 'in' | 'out'
24  /** 'result': a teammate's final answer. 'failure': a teammate's failure. */
25  kind: 'message' | 'result' | 'failure'
26  /** One line, at most LIMITS.summaryChars characters. */
27  summary: string
28  /** The sanitized body, cut at LIMITS.bodyChars characters. */
29  body: string
30  /** The length of the sanitized body before the cut. */
31  fullChars: number
32  /** $.clock.now() when High Command observed the event. */
33  at: number
34  /** Inbound entries only: the trust level of the sender name, set from origin fields alone. */
35  trust?: HighCommandTrust
36  /** Inbound entries only: the text matches the suspicious-text list; a display mark. */
37  isSuspicious?: true
38  /** Agent lane inbound entries only: the main conversation's own message to that agent, never unread. */
39  fromMain?: true
40  /** Cumulative retention loss, carried atomically by the newest recorded entry. */
41  evictedTotal?: number
42  /** The detailed refusal, bounded separately from its one-line preview. */
43  refusal?: { body: string; fullChars: number }
44  /** Evidence from the latest downstream call only, never a complete send history. */
45  core?: { to: string; body: string; fullChars: number; isDelivered: boolean; reason?: string }
46  /** Outbound entries only: the reported outcome, separate from core evidence. */
47  delivery?: { isDelivered: true } | { isDelivered: false; reason: string }
48}
49
50/** What High Command knows about a peer besides its messages. */
51export type HighCommandPeer = {
52  /** $.clock.now() of the last report about this peer; the peer cap keeps the newest. */
53  seenAt: number
54  state?: HighCommandState
55  /** The `from` address the newest cross-session frame named for this peer, and $.clock.now() when it was reported. */
56  address?: {
57    value: string
58    at: number
59    /** Address observation order for timestamp ties; absent in older saved state. */
60    order?: number
61  }
62}
63
64/** The thread the pane shows, with the peer's read mark from the moment it opened. */
65export type HighCommandThread = {
66  peer: string
67  page?: number
68  identityPage?: number
69  reasonSeq?: number
70  /** Inbound entries with a higher seq draw as new while the thread stays open. */
71  since: number
72  /** The newest seq when the person opened the thread: an append that it saw never closes it. */
73  from: number
74}
75
76export type HighCommandLane = {
77  rosterPage?: number
78  entries: HighCommandEntry[]
79  peers: Record<string, HighCommandPeer>
80  readThrough: Record<string, number>
81  openThread: HighCommandThread | null
82  /**
83   * The seq of the newest entry recorded that can be unread; it only grows and outlives the entry's eviction.
84   * Absent before the lane's first append.
85   */
86  inboundThrough?: number
87}
88
89export type HighCommandAgent = {
90  id: string
91  name?: string
92  status: string
93  at: number
94  source: 'roster snapshot' | 'lifecycle observation'
95  lastEvent?: { word: string; at: number }
96}
97
98export type HighCommandCapture = { at: number; reset: boolean }
99
100declare module 'claude-code' {
101  interface PluginState {
102    'high-command': {
103      lanes: Record<string, HighCommandLane>
104      selectedLane: string | null
105      lanePage: number
106      rosterPage: number
107      agents: Record<string, HighCommandAgent>
108      capture: HighCommandCapture | null
109      entries: HighCommandEntry[]
110      peers: Record<string, HighCommandPeer>
111      readThrough: Record<string, number>
112      openThread: HighCommandThread | null
113      /** The newest seq that a press of Hide had seen; 0 before any press. It only grows. */
114      hiddenThrough: number
115      /** The seq of the newest inbound entry recorded; it only grows and outlives the entry's eviction. */
116      inboundThrough: number
117      /**
118       * Per agent lane, the newest seq that a press of Hide had seen while agentLanesInBand is on; a mark only grows.
119       */
120      hiddenLanes: Record<string, number>
121      /**
122       * The status line as last pinned: the versions of `entries` and `readThrough` it counts, and its count. With
123       * agentLanesInBand on, also the version of `lanes` and the unread count of all agent lanes.
124       */
125      statusFrom: { entries: number; marks: number; unread: number; lanes?: number; laneUnread?: number }
126    }
127  }
128}
129