From 73e5b24b0769f819066916a52f01a8f0c18bfe5b Mon Sep 17 00:00:00 2001
From: dot-agi
Date: Mon, 12 Oct 2026 15:14:27 +0530
Subject: [PATCH] Release 1.1.1
---
.claude-plugin/plugin.json | 12 +++-
README.md | 50 +++++++++------
docs/usage.md | 43 ++++++++++---
hooks/inbox.ts | 110 ++++++++++++++++++++++++++++---
hooks/limits.ts | 7 ++
hooks/register.tsx | 128 +++++++++++++++++++++++++------------
hooks/text.ts | 9 +++
hooks/views.tsx | 17 +++--
types/index.d.ts | 16 ++++-
9 files changed, 305 insertions(+), 87 deletions(-)
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index ec6d06e..b969111 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "high-command",
- "version": "1.1.0",
+ "version": "1.1.1",
"description": "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, and /highcommand.",
"author": { "name": "dot-agi" },
"license": "MIT",
@@ -11,5 +11,13 @@
"privacyPolicyUrl": "https://github.com/dot-agi/high-command#privacy",
"keywords": ["mod", "claude-code", "agent-teams", "messaging", "inbox"],
"types": "./types/index.d.ts",
- "icon": "./docs/images/icon.png"
+ "icon": "./docs/images/icon.png",
+ "userConfig": {
+ "agentLanesInBand": {
+ "type": "boolean",
+ "title": "Agent lanes in the band and status line",
+ "description": "Add the unread count of all agent lanes to the band and the status line. Read all in the band then also marks every agent lane read, and Hide also covers agent lanes. Off: the band and the status line count the main inbox only.",
+ "default": false
+ }
+ }
}
diff --git a/README.md b/README.md
index 9fe3d9f..c078fb2 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,11 @@
-
+
One inbox for agent-team teammates, named subagents, and other Claude Code sessions.

@@ -21,7 +21,7 @@ When your session works with other agents, their messages arrive in the transcri
## 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.
+- **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](#options) 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.
@@ -140,6 +140,16 @@ Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next
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](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`.
+
+| Option | Default | Effect |
+| --- | --- | --- |
+| `agentLanesInBand` | `false` | Adds 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.
@@ -154,28 +164,30 @@ Claude Code and other mods also show peer messages. Each row says what the tool
## Known limits
-High Command shows only what it saw in the current conversation, within fixed bounds, and a reported send result never proves that a peer read the message.
+### 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](docs/usage.md#known-limits) has every limit, also the rare ones: size caps, unusual names and frames, changes by other mods, and display gaps.
-Read the full list of known limits
+Read the limits you can hit in daily use
-- **Retained history**: the main inbox and each agent lane keep 200 messages in all and 50 per peer. A peer past 50 messages loses its own oldest ones first. Past 200 in all, the oldest messages drop. A notice counts the messages dropped for good, and unread counts cover kept messages only. A thread shows up to 20 messages per page, fewer when they are long. **Older** and **Newer** page through every kept message. Opening a thread marks all of that peer's kept messages read, also those on other pages. The roster shows 50 peers per page. Peer states are kept for the 100 peers reported most recently. A message keeps its first 2,000 characters, and its thread says how many more are in the transcript.
-- **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. The band, the status line, and **Hide** cover the main inbox only, so an unread message in an agent lane does not show there. 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.
-- **Sends by mod-started agents**: Claude Code reports a send by an agent that a mod started as that mod's send. High Command records no mod's sends, so these sends do not show in that agent's lane or in `/highcommand`. Messages to that agent still show in its lane. Its messages to your main conversation or to another local agent still show in the main inbox or in that agent's lane.
-- **Space for agent lanes**: High Command saves all agent lanes of a conversation as one value. Claude Code 2.1.296 refuses a saved value over 4,194,304 characters of JSON text. About nine full lanes of 2,000-character messages reach that size, so a lane can stop recording before it holds 200 messages. A new agent message then still reaches its recipient unchanged. High Command does not record it and writes `high-command: session.receive: could not record a delivery ...` or `high-command: session.send: could not record a message to ...`. Near that size, a lane button can also fail with a `high-command: ui.press: ...` toast. The main inbox is saved apart and keeps recording. Read the missed messages in that agent's transcript. `/clear`, `/resume`, or `/branch` starts a new capture window, which empties every lane and the main inbox.
+- **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](#options) 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.
-- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Identity** in the thread shows the full name. A send that Claude addresses by the ID of a listed local agent files under that agent's name, on one row with the agent's messages to you. These sends count toward that name's 50 kept messages, so many sends can push out an unread message. The send files under `name [ID]`, on a row of its own, when another listed local agent has the same name or a peer of that name has another session's address. A name that holds only spacing and invisible characters files under `[ID]`. High Command reads the local agent list at each send. Claude Code drops an agent from that list when the agent ends or some time later. A send to the ID of an agent that is no longer listed files under that ID alone, on a row of its own, apart from the agent's messages to you.
- **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.
-- **Sends changed by earlier mods**: a mod that runs before High Command can change a send's recipient and text. High Command then shows the changed recipient and text, and files the send under the changed recipient. It cannot show what Claude first wrote. Check the SendMessage call in the transcript.
- **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.
-- **Subagent messages**: Claude Code does not say whether a mod started or wrote a named subagent's message to your main conversation. High Command reads the local agent list when such a message arrives. The message is `unverified sender` when a mod started every listed agent whose name or ID its frame gives. Otherwise it is `local sender`: also when a mod wrote it from an agent that the model started, when its frames name several senders, and when the list cannot be read. Agents that left the list do not count, so a message from a model-started agent that already ended is `unverified sender` while a mod-started agent of the same name is listed. High Command counts such a message only when the prompt that reaches it holds a complete `` frame and no frame of another kind. A mod that runs after High Command can remove that frame. High Command then files the changed text under the one name that the frames gave, or under `unnamed peer` when they gave none or several. A sender name in the changed text, such as a `from` field, does not change this.
-- **Damaged frames**: Claude Code can wrap a peer's message in a frame, such as ``, whose attributes name the sender. A malformed attribute, a frame with no closing tag, or text beside complete frames makes High Command write an `incomplete frame parsing` line. A complete frame with a malformed attribute keeps the attributes that High Command could read, such as the sender name and summary. The sender label rules still apply to that name. A frame with no closing tag, and text beside complete frames, become a message from the delivery's sender, or from `unnamed peer`, without frame attributes. Check the transcript for the original frame. A delivery that Claude Code could not place is recorded only when it holds a complete frame.
-- **Display order**: the pane and `/highcommand` sort messages by observation time, the time High Command first saw each one, before other mods and Claude Code handled it. That is not always the order in which messages reached Claude. Read marks, **Hide**, which messages drop first, and the label of a peer with nothing unread follow recording sequence, the order in which High Command finished recording messages.
-- **Narrow panes**: a roster row is one line, and a narrow pane cuts its end. The unread count, sender label, and `suspicious` mark come first, so the state and the latest message are cut before them.
-- **Band space**: when other mods draw a lot in the area above the prompt, the band shrinks to the count and **Open**. When even that would pass Claude Code's limits for one drawing, the band does not show. The status line still counts unread messages. The band also gives way while a Claude Code survey shows.
-- **Summary as a list**: when a peer name holds a `|` after an odd number of backslashes, `/highcommand` prints a list instead of a table. A table cell cannot show such a name as written.
-- **Cut-off failure lines**: when High Command cannot record a send, the `high-command: session.send: could not record a message to ...` line names the recipient before the cause. The terminal draws the first 2,000 characters of a line. Remote surfaces and the debug log keep 10,000. A very long recipient can push the cause past that point. The SendMessage result in the transcript still says whether the message was queued.
- **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.
@@ -223,7 +235,7 @@ High Command has no hook on a permission check, so it never changes a permission
- **A `high-command:` line in the transcript**: it names the event and the cause of a failure. The transcript shows each distinct line once until the mod loads again, and repeats go to the debug log only. Errors that `/highcommand` prints show every time. Start Claude Code with `--debug` to see every line.
- **A debug line names high-command after you decline a message**: Claude Code reports the declined delivery under each mod that passed it on. High Command records nothing for that message.
- **A harmless message is marked `suspicious`**: the patterns are a short fixed list, so a normal message can match. The mark blocks nothing.
-- **The band is gone but the status line still counts messages**: you pressed **Hide**, and the band comes back with the next message. The band also gives way to a Claude Code survey and to large drawings by other mods (see [Known limits](#known-limits)). `/highcommand open` shows the pane, and **Read all** there clears the count.
+- **The band is gone but the status line still counts messages**: you pressed **Hide**, and the band comes back with the next message. The band also gives way to a Claude Code survey and to large drawings by other mods (see [Known limits](docs/usage.md#known-limits)). `/highcommand open` shows the pane, and **Read all** there clears the main inbox count. With the `agentLanesInBand` [option](#options) on, the line also counts agent lanes. To clear them, press each agent's button that shows an unread count, then **Read all** in its lane. **Read all** in the band, when it shows, clears both counts.
diff --git a/docs/usage.md b/docs/usage.md
index 7b99b53..a7a5c6b 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -14,7 +14,7 @@ A message to your session is unread until you open its peer's thread, press **Ma
When you open a thread, the messages that were unread are marked `new`, and so is each message that arrives while the thread is open. The marks stay until you go back to the roster.
-While unread messages wait, the status line under the prompt says `high-command: {n} unread (retained)`. It is the mod's own line, so another mod's band cannot hide it. It clears when nothing is unread, and after `/clear`, `/resume`, or `/branch`.
+While unread messages wait, the status line under the prompt says `high-command: {n} unread (retained)`. It is the mod's own line, so another mod's band cannot hide it. It clears when nothing is unread, and after `/clear`, `/resume`, or `/branch`. It counts the main inbox. With the `agentLanesInBand` [option](#options) on, it also counts the unread messages of all agent lanes, as in `high-command: 3 unread (retained) · 2 unread in agent lanes`, or `high-command: 2 unread in agent lanes` when the main inbox has none.
## Sender labels and marks
@@ -63,6 +63,8 @@ Both ways to open the pane show the main inbox, even while an agent transcript i
While unread messages wait, the band shows the total and up to three peers, the most recent first. **Open** shows the first page of the main inbox roster, even while an agent transcript is visible. The pane opens without the keyboard, because the band holds it: press Ctrl+X, then Tab, until its border is focused, or click the pane to give it the keys. **Read all** clears every unread count. **Hide** puts the band away until a newer message arrives; it marks nothing read, and the status line keeps the count. The band gives way to Claude Code's surveys.
+By default, the band counts the main inbox only. With the `agentLanesInBand` [option](#options) on, it also shows the unread count of all agent lanes, as in `High Command · 3 unread (retained) · 2 unread in agent lanes`, and it also shows while only agent lanes have unread messages. **Read all** then also marks every agent lane read. **Hide** then holds until a newer message arrives in the main inbox or in an agent lane, and the band comes back with both counts. **Open** still shows the main inbox: press an agent's button in the pane to read its lane.
+
## Keys
| Key | Where | Action |
@@ -80,27 +82,48 @@ Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next
Letter and digit keys work only while the band or the pane has the keyboard. Focus moves through each band button before it reaches the pane, so press Ctrl+X, then Tab, until the pane border shows focus. `/highcommand open` opens the pane directly. Opening a thread marks its kept messages read, so `r` works in a thread only when **Mark read** shows again, after that peer sends a new message.
+## 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`.
+
+| Option | Default | Effect |
+| --- | --- | --- |
+| `agentLanesInBand` | `false` | Adds the unread count of all agent lanes to the band and the status line, as described in [The band](#the-band). Off, they count the main inbox only. |
+
+A change takes effect at once: Claude Code reloads the mod with the new values.
+
## Known limits
-- **Retained history**: the main inbox and each agent lane keep 200 messages in all and 50 per peer. A peer past 50 messages loses its own oldest ones first. Past 200 in all, the oldest messages drop. A notice counts the messages dropped for good, and unread counts cover kept messages only. A thread shows up to 20 messages per page, fewer when they are long. **Older** and **Newer** page through every kept message. Opening a thread marks all of that peer's kept messages read, also those on other pages. The roster shows 50 peers per page. Peer states are kept for the 100 peers reported most recently. A message keeps its first 2,000 characters, and its thread says how many more are in the transcript.
-- **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. The band, the status line, and **Hide** cover the main inbox only, so an unread message in an agent lane does not show there. 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.
-- **Sends by mod-started agents**: Claude Code reports a send by an agent that a mod started as that mod's send. High Command records no mod's sends, so these sends do not show in that agent's lane or in `/highcommand`. Messages to that agent still show in its lane. Its messages to your main conversation or to another local agent still show in the main inbox or in that agent's lane.
-- **Space for agent lanes**: High Command saves all agent lanes of a conversation as one value. Claude Code 2.1.296 refuses a saved value over 4,194,304 characters of JSON text. About nine full lanes of 2,000-character messages reach that size, so a lane can stop recording before it holds 200 messages. A new agent message then still reaches its recipient unchanged. High Command does not record it and writes `high-command: session.receive: could not record a delivery ...` or `high-command: session.send: could not record a message to ...`. Near that size, a lane button can also fail with a `high-command: ui.press: ...` toast. The main inbox is saved apart and keeps recording. Read the missed messages in that agent's transcript. `/clear`, `/resume`, or `/branch` starts a new capture window, which empties every lane and the main inbox.
+The first list holds the limits you can hit in daily use. The second list holds rare cases: size caps, unusual names and frames, changes by other mods, and display gaps.
+
+### Limits in daily use
+
+- **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](#options) 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.
-- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Identity** in the thread shows the full name. A send that Claude addresses by the ID of a listed local agent files under that agent's name, on one row with the agent's messages to you. These sends count toward that name's 50 kept messages, so many sends can push out an unread message. The send files under `name [ID]`, on a row of its own, when another listed local agent has the same name or a peer of that name has another session's address. A name that holds only spacing and invisible characters files under `[ID]`. High Command reads the local agent list at each send. Claude Code drops an agent from that list when the agent ends or some time later. A send to the ID of an agent that is no longer listed files under that ID alone, on a row of its own, apart from the agent's messages to you.
- **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.
-- **Sends changed by earlier mods**: a mod that runs before High Command can change a send's recipient and text. High Command then shows the changed recipient and text, and files the send under the changed recipient. It cannot show what Claude first wrote. Check the SendMessage call in the transcript.
- **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.
+
+### Rare cases
+
+- **Retained history**: the main inbox and each agent lane keep 200 messages in all and 50 per peer. A peer past 50 messages loses its own oldest ones first. Past 200 in all, the oldest messages drop. A notice counts the messages dropped for good, and unread counts cover kept messages only. A thread shows up to 20 messages per page, fewer when they are long. **Older** and **Newer** page through every kept message. Opening a thread marks all of that peer's kept messages read, also those on other pages. The roster shows 50 peers per page. Peer states are kept for the 100 peers reported most recently. A message keeps its first 2,000 characters, and its thread says how many more are in the transcript. An agent lane also has a cap in characters (see **Space for agent lanes**).
+- **Sends by mod-started agents**: Claude Code reports a send by an agent that a mod started as that mod's send. High Command records no mod's sends, so these sends do not show in that agent's lane or in `/highcommand`. Messages to that agent still show in its lane. Its messages to your main conversation or to another local agent still show in the main inbox or in that agent's lane.
+- **Space for agent lanes**: High Command saves all agent lanes of a conversation as one value. Claude Code 2.1.296 refuses a saved value over 4,194,304 characters of JSON text, so High Command caps the lanes below that size:
+ - Each agent lane keeps at most 500,000 characters of saved message data. 200 messages of 2,000 characters fit. Sends with long **Send details**, and text with many line breaks or quotation marks, take more space, so such a lane can keep fewer than 200 messages. Past its cap, a lane drops its own oldest messages first.
+ - All agent lanes together keep at most 4,000,000 characters, about eight full lanes of 2,000-character messages. Past that, the oldest messages of all lanes drop. High Command compares lanes by observation time. Each lane still drops its messages in recording sequence and keeps its newest message.
+
+ New agent messages keep recording, and each lane's notice counts the messages it dropped for good. Because each lane keeps its newest message and its peer states, a conversation with very many agent lanes can still pass Claude Code's limit: about 1,800 lanes whose newest message has 2,000 characters, or fewer when those messages are sends with long **Send details**. A new agent message then still reaches its recipient unchanged. High Command does not record it and writes `high-command: session.receive: could not record a delivery ...` or `high-command: session.send: could not record a message to ...`. A lane button can also fail with a `high-command: ui.press: ...` toast. Read the missed messages in that agent's transcript. The main inbox is saved apart and keeps recording. `/clear`, `/resume`, or `/branch` starts a new capture window, which empties every lane and the main inbox.
+- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Identity** in the thread shows the full name. A send that Claude addresses by the ID of a listed local agent files under that agent's name, on one row with the agent's messages to you. These sends count toward that name's 50 kept messages, so many sends can push out an unread message. The send files under `name [ID]`, on a row of its own, when another listed local agent has the same name or a peer of that name has another session's address. A name that holds only spacing and invisible characters files under `[ID]`. High Command reads the local agent list at each send. Claude Code drops an agent from that list when the agent ends or some time later. A send to the ID of an agent that is no longer listed files under that ID alone, on a row of its own, apart from the agent's messages to you.
+- **Sends changed by earlier mods**: a mod that runs before High Command can change a send's recipient and text. High Command then shows the changed recipient and text, and files the send under the changed recipient. It cannot show what Claude first wrote. Check the SendMessage call in the transcript.
- **Subagent messages**: Claude Code does not say whether a mod started or wrote a named subagent's message to your main conversation. High Command reads the local agent list when such a message arrives. The message is `unverified sender` when a mod started every listed agent whose name or ID its frame gives. Otherwise it is `local sender`: also when a mod wrote it from an agent that the model started, when its frames name several senders, and when the list cannot be read. Agents that left the list do not count, so a message from a model-started agent that already ended is `unverified sender` while a mod-started agent of the same name is listed. High Command counts such a message only when the prompt that reaches it holds a complete `` frame and no frame of another kind. A mod that runs after High Command can remove that frame. High Command then files the changed text under the one name that the frames gave, or under `unnamed peer` when they gave none or several. A sender name in the changed text, such as a `from` field, does not change this.
- **Damaged frames**: Claude Code can wrap a peer's message in a frame, such as ``, whose attributes name the sender. A malformed attribute, a frame with no closing tag, or text beside complete frames makes High Command write an `incomplete frame parsing` line. A complete frame with a malformed attribute keeps the attributes that High Command could read, such as the sender name and summary. The sender label rules still apply to that name. A frame with no closing tag, and text beside complete frames, become a message from the delivery's sender, or from `unnamed peer`, without frame attributes. Check the transcript for the original frame. A delivery that Claude Code could not place is recorded only when it holds a complete frame.
-- **Display order**: the pane and `/highcommand` sort messages by observation time, the time High Command first saw each one, before other mods and Claude Code handled it. That is not always the order in which messages reached Claude. Read marks, **Hide**, which messages drop first, and the label of a peer with nothing unread follow recording sequence, the order in which High Command finished recording messages.
+- **Display order**: the pane and `/highcommand` sort messages by observation time, the time High Command first saw each one, before other mods and Claude Code handled it. That is not always the order in which messages reached Claude. Read marks, **Hide**, which messages drop first within the main inbox or one agent lane, and the label of a peer with nothing unread follow recording sequence, the order in which High Command finished recording messages. Between agent lanes, drops follow observation time (see **Space for agent lanes**).
- **Narrow panes**: a roster row is one line, and a narrow pane cuts its end. The unread count, sender label, and `suspicious` mark come first, so the state and the latest message are cut before them.
- **Band space**: when other mods draw a lot in the area above the prompt, the band shrinks to the count and **Open**. When even that would pass Claude Code's limits for one drawing, the band does not show. The status line still counts unread messages. The band also gives way while a Claude Code survey shows.
- **Summary as a list**: when a peer name holds a `|` after an odd number of backslashes, `/highcommand` prints a list instead of a table. A table cell cannot show such a name as written.
- **Cut-off failure lines**: when High Command cannot record a send, the `high-command: session.send: could not record a message to ...` line names the recipient before the cause. The terminal draws the first 2,000 characters of a line. Remote surfaces and the debug log keep 10,000. A very long recipient can push the cause past that point. The SendMessage result in the transcript still says whether the message was queued.
-- **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.
## Troubleshooting
diff --git a/hooks/inbox.ts b/hooks/inbox.ts
index b894df7..f41e632 100644
--- a/hooks/inbox.ts
+++ b/hooks/inbox.ts
@@ -1,6 +1,6 @@
import type { AgentInfo, SessionSendResult, TraceEntry } from 'claude-code'
-import type { HighCommandEntry, HighCommandPeer, HighCommandState, HighCommandThread, HighCommandTrust } from '../types'
+import type { HighCommandEntry, HighCommandLane, HighCommandPeer, HighCommandState, HighCommandThread, HighCommandTrust } from '../types'
import type { NewEntry, PeerReport } from './envelope'
import { LIMITS } from './limits'
import { capBody, lineOf, peerKey, sanitize, summaryOf } from './text'
@@ -195,17 +195,45 @@ export function rosterOf(entries: readonly HighCommandEntry[], peers: Readonly
Number(b.unread > 0) - Number(a.unread > 0) || b.last.at - a.last.at || b.last.seq - a.last.seq)
}
-/** The versions of `entries` and `readThrough` that a status line count comes from. */
-type StatusView = { entries: number; marks: number }
+/** The unread count of all agent lanes. */
+export function lanesUnread(lanes: Readonly>): number {
+ return Object.values(lanes).reduce((sum, lane) => sum + unreadTotal(rosterOf(lane.entries, {}, lane.readThrough)), 0)
+}
+
+/** Per lane, the newest seq a press of Hide saw in `lanes`. A mark never moves back. */
+export function hideLanes(hidden: Readonly>, lanes: Readonly>): Record {
+ const next = new Map(Object.entries(hidden))
+ for (const [id, lane] of Object.entries(lanes)) next.set(id, Math.max(next.get(id) ?? 0, newestSeq(lane.entries)))
+ return Object.fromEntries(next)
+}
+
+/** The seq of the newest lane entry that can be unread (isNewer), or 0 without one. */
+export function newestUnreadableSeq(list: readonly HighCommandEntry[]): number {
+ return list.findLast(entry => isNewer(entry, 0))?.seq ?? 0
+}
/**
- * Whether a status refresh may pin its count: its view is older in neither value than the
- * view behind the line and newer in one, or the line has no view yet.
+ * Whether a Hide saw every lane's newest entry that can be unread, also one that left its lane since: the lane's
+ * `inboundThrough` keeps its seq, so an eviction cannot bring back a Hide that the entry ended.
+ */
+export function lanesSeen(lanes: Readonly>, hidden: Readonly>): boolean {
+ return Object.entries(lanes).every(([id, lane]) => Math.max(lane.inboundThrough ?? 0, newestUnreadableSeq(lane.entries)) <= markOf(hidden, id))
+}
+
+/**
+ * The versions of `entries` and `readThrough` that a status line count comes from, and the version of `lanes` when
+ * the line also counts agent lanes.
+ */
+type StatusView = { entries: number; marks: number; lanes?: number }
+
+/**
+ * Whether a status refresh may pin its count: its view is older in no value than the view behind the line and
+ * newer in one, the line has no view yet, or the line counts agent lanes and the refresh does not, or the reverse.
*/
export function isNewerView(view: StatusView, shown: StatusView | undefined): boolean {
- if (shown === undefined) return true
- const isOlder = view.entries < shown.entries || view.marks < shown.marks
- return !isOlder && (view.entries > shown.entries || view.marks > shown.marks)
+ if (shown === undefined || (view.lanes === undefined) !== (shown.lanes === undefined)) return true
+ const isOlder = view.entries < shown.entries || view.marks < shown.marks || (view.lanes ?? 0) < (shown.lanes ?? 0)
+ return !isOlder && (view.entries > shown.entries || view.marks > shown.marks || (view.lanes ?? 0) > (shown.lanes ?? 0))
}
/**
@@ -218,6 +246,72 @@ export function keepsThread(open: HighCommandThread, before: readonly HighComman
return open.from >= newestSeq(after) || (has(before) && has(after))
}
+/** The characters of `value` as JSON text: the measure that Claude Code holds a saved value to. */
+function jsonChars(value: unknown): number {
+ return JSON.stringify(value).length
+}
+
+/**
+ * The lane without its `drop` oldest entries, where `drop` is less than its entry count. Its newest entry stays and
+ * counts them as evicted, and the open thread closes as an append that dropped them would close it.
+ */
+function dropOldest(lane: HighCommandLane, drop: number): HighCommandLane {
+ if (drop <= 0) return lane
+ const kept = lane.entries.slice(drop)
+ const last = kept.at(-1)!
+ kept[kept.length - 1] = { ...last, evictedTotal: (last.evictedTotal ?? 0) + drop }
+ const open = lane.openThread
+ return { ...lane, entries: kept, openThread: open === null || keepsThread(open, lane.entries, kept) ? open : null }
+}
+
+/** Drops the lane's oldest entries while its entries pass LIMITS.laneChars; it keeps its newest entry. */
+function capLane(lane: HighCommandLane): HighCommandLane {
+ let size = jsonChars(lane.entries)
+ if (size <= LIMITS.laneChars) return lane
+ let drop = 0
+ while (size > LIMITS.laneChars && drop < lane.entries.length - 1) {
+ size -= jsonChars(lane.entries[drop]) + 1
+ drop += 1
+ }
+ const next = dropOldest(lane, drop)
+ // The evicted count on the newest entry adds a few characters.
+ return drop > 0 && jsonChars(next.entries) > LIMITS.laneChars ? capLane(next) : next
+}
+
+/**
+ * Fits the agent lanes in the space of one saved value. The lane `id`, the one that changed, first drops its own
+ * oldest entries past LIMITS.laneChars. Then, while all lanes pass LIMITS.lanesChars, the entries observed first
+ * drop from every lane. An entry drops only with the older entries of its lane, so it ranks by the latest observation
+ * among them. Each lane keeps its newest entry, so its seq numbers and read marks stay in step.
+ */
+export function fitLanes(lanes: Readonly>, id?: string): Record {
+ const all = new Map(Object.entries(lanes))
+ const changed = id === undefined ? undefined : all.get(id)
+ if (changed !== undefined) all.set(id!, capLane(changed))
+ let over = jsonChars(Object.fromEntries(all)) - LIMITS.lanesChars
+ while (over > 0) {
+ const ranked: { lane: string; index: number; at: number; size: number }[] = []
+ for (const [name, lane] of all) {
+ let at = -Infinity
+ lane.entries.slice(0, -1).forEach((entry, index) => {
+ at = Math.max(at, entry.at)
+ ranked.push({ lane: name, index, at, size: jsonChars(entry) + 1 })
+ })
+ }
+ if (ranked.length === 0) break
+ ranked.sort((a, b) => a.at - b.at || a.index - b.index || (a.lane < b.lane ? -1 : a.lane > b.lane ? 1 : 0))
+ const drops = new Map()
+ for (const { lane, index, size } of ranked) {
+ if (over <= 0) break
+ drops.set(lane, index + 1)
+ over -= size
+ }
+ for (const [name, drop] of drops) all.set(name, dropOldest(all.get(name)!, drop))
+ over = jsonChars(Object.fromEntries(all)) - LIMITS.lanesChars
+ }
+ return Object.fromEntries(all)
+}
+
/** The thread of the open peer, or undefined when the pane draws the roster. */
export function threadOf(entries: readonly HighCommandEntry[], rows: readonly RosterRow[], open: HighCommandThread | null): Thread | undefined {
const row = rows.find(candidate => candidate.peer === open?.peer)
diff --git a/hooks/limits.ts b/hooks/limits.ts
index 2c0b02f..b6435d1 100644
--- a/hooks/limits.ts
+++ b/hooks/limits.ts
@@ -4,6 +4,13 @@ export const LIMITS = {
entries: 200,
/** Entries kept for one peer; a busy peer drops its own oldest entries first. */
peerEntries: 50,
+ /** Characters of JSON text that one agent lane's entries keep; past it, that lane drops its own oldest entries first. */
+ laneChars: 500_000,
+ /**
+ * Characters of JSON text that all agent lanes keep together: they are saved as one value, and Claude Code 2.1.296
+ * refuses a value over 4,194,304 characters. Past it, the entries observed first drop; each lane keeps its newest.
+ */
+ lanesChars: 4_000_000,
/** Peers kept; the ones reported least recently drop first. */
peers: 100,
/** Characters of a body kept; the transcript keeps the rest. */
diff --git a/hooks/register.tsx b/hooks/register.tsx
index 1d6b64d..2e5acf7 100644
--- a/hooks/register.tsx
+++ b/hooks/register.tsx
@@ -7,12 +7,17 @@ import {
appendEntries,
coreEvidence,
applyReports,
+ fitLanes,
+ hideLanes,
isNewerView,
keepsThread,
+ lanesSeen,
+ lanesUnread,
markOf,
markRead,
newestInboundSeq,
newestSeq,
+ newestUnreadableSeq,
outboundEntry,
recipientOf,
rosterOf,
@@ -22,7 +27,7 @@ import {
} from './inbox'
import { parseListing, parseStructuredListing } from './listing'
import { summaryText } from './summary'
-import { count, sanitize, peerKey, peerLabels } from './text'
+import { count, sanitize, peerKey, peerLabels, unreadText } from './text'
import { bandTree, compactBandTree, fitsBounds, paneTree, type BandActions, type PaneActions } from './views'
const DESCRIPTION = 'High Command: messages from teammates and other sessions (/highcommand open shows the pane)'
@@ -37,6 +42,7 @@ const MESSAGE_ORIGINS: ReadonlySet = new Set(['peer', 'coordinator', 'pe
const ENTRIES = { plugin: 'high-command', key: 'entries' } as const
const READ_THROUGH = { plugin: 'high-command', key: 'readThrough' } as const
const STATUS_FROM = { plugin: 'high-command', key: 'statusFrom' } as const
+const LANES = { plugin: 'high-command', key: 'lanes' } as const
const entries = atom(ENTRIES, [])
const peers = atom({ plugin: 'high-command', key: 'peers' } as const, {})
@@ -45,7 +51,8 @@ const openThread = atom({ plugin: 'high-command', key: 'openThread' } as const,
const hiddenThrough = atom({ plugin: 'high-command', key: 'hiddenThrough' } as const, 0)
const inboundThrough = atom({ plugin: 'high-command', key: 'inboundThrough' } as const, 0)
-const lanes = atom({ plugin: 'high-command', key: 'lanes' } as const, {})
+const lanes = atom(LANES, {})
+const hiddenLanes = atom({ plugin: 'high-command', key: 'hiddenLanes' } as const, {})
const selectedLane = atom({ plugin: 'high-command', key: 'selectedLane' } as const, null)
const lanePage = atom({ plugin: 'high-command', key: 'lanePage' } as const, 0)
const rosterPage = atom({ plugin: 'high-command', key: 'rosterPage' } as const, 0)
@@ -61,14 +68,20 @@ async function readLane($: EngineInterface, id: string): Promise HighCommandLane): Promise {
await update($, lanes, known => {
const next = new Map(Object.entries(known))
next.set(id, change(next.get(id) ?? emptyLane()))
- return Object.fromEntries(next)
+ return fitLanes(Object.fromEntries(next), id)
})
}
+/** Changes every agent lane at once. */
+async function changeLanes($: EngineInterface, change: (lane: HighCommandLane) => HighCommandLane): Promise {
+ await update($, lanes, known => fitLanes(Object.fromEntries(Object.entries(known).map(([id, lane]) => [id, change(lane)]))))
+}
+
async function changeThread($: EngineInterface, id: string | undefined, change: (thread: HighCommandThread | null) => HighCommandThread | null): Promise {
if (id === undefined) await update($, openThread, change)
else await changeLane($, id, lane => ({ ...lane, openThread: change(lane.openThread) }))
@@ -117,7 +130,8 @@ function diagnose($: EngineInterface, line: string): void {
/**
* Pins the unread count as this plugin's status line, which no other mod's band can hide, or clears the
- * line at zero. It runs after every change of the count; a failure is diagnosed under `event`.
+ * line at zero. It runs after every change of the count; a failure is diagnosed under `event`. `withLanes`
+ * (agentLanesInBand) adds the unread count of all agent lanes, and the version of `lanes` to the view.
*
* Refreshes overlap, and a dispatch reads state as it stood at its own last write, so a refresh pins its
* count only for a view newer than the one behind the line (STATUS_FROM, written with ifVersion). The refresh
@@ -127,19 +141,23 @@ function diagnose($: EngineInterface, line: string): void {
* landed, and the refresh shows that one. `isForced` pins the first view whatever the line shows: a reload
* may drop the line.
*/
-async function syncStatus($: EngineInterface, event: string, isForced = false): Promise {
+async function syncStatus($: EngineInterface, event: string, isForced = false, withLanes = false): Promise {
try {
for (let isFirst = true; ; isFirst = false) {
const list = await $.state.get(ENTRIES)
const marks = await $.state.get(READ_THROUGH)
+ const all = withLanes ? await $.state.get(LANES) : undefined
const pinned = await $.state.get(STATUS_FROM)
- const view = { entries: list.version, marks: marks.version }
+ const view = { entries: list.version, marks: marks.version, ...(all === undefined ? {} : { lanes: all.version }) }
if (!isNewerView(view, isForced && isFirst ? undefined : pinned.value)) return
- let pin = { ...view, unread: unreadTotal(rosterOf(list.value ?? [], {}, marks.value ?? {})) }
+ let pin: { entries: number; marks: number; unread: number; lanes?: number; laneUnread?: number } = {
+ ...view, unread: unreadTotal(rosterOf(list.value ?? [], {}, marks.value ?? {})),
+ ...(all === undefined ? {} : { laneUnread: lanesUnread(all.value ?? {}) }),
+ }
const written = await $.state.set(STATUS_FROM, pin, { ifVersion: pinned.version })
if (!written.isSet) continue
for (let version = written.version; ; ) {
- $.ui.status(pin.unread === 0 ? undefined : `${pin.unread} unread (retained)`)
+ $.ui.status(unreadText(pin.unread, pin.laneUnread) || undefined)
if ((await $.state.set(STATUS_FROM, pin, { ifVersion: version })).isSet) return
const newer = await $.state.get(STATUS_FROM)
if (newer.value === undefined) return
@@ -165,14 +183,18 @@ async function readRoster($: EngineInterface, laneId?: string): Promise<{ list:
/**
* Appends fresh entries. The open thread closes when this append drops its peer from the window, so the pane
* shows the roster and stays there when that peer writes again. The check runs inside update, so it judges
- * the thread open now, and keepsThread keeps a thread opened after this append. `inboundThrough` keeps the
- * newest inbound seq after its entry leaves the window, so a Hide that it outdates stays ended.
+ * the thread open now, and keepsThread keeps a thread opened after this append. `inboundThrough`, and a lane's
+ * own `inboundThrough`, keep the newest inbound seq after its entry leaves the window, so a Hide that it outdates
+ * stays ended.
*/
async function append($: EngineInterface, fresh: readonly NewEntry[], at: number, laneId?: string): Promise {
if (laneId !== undefined) {
await changeLane($, laneId, lane => {
const list = appendEntries(lane.entries, fresh, at)
- return { ...lane, entries: list, openThread: lane.openThread === null || keepsThread(lane.openThread, lane.entries, list) ? lane.openThread : null }
+ return {
+ ...lane, entries: list, inboundThrough: Math.max(lane.inboundThrough ?? 0, newestUnreadableSeq(list)),
+ openThread: lane.openThread === null || keepsThread(lane.openThread, lane.entries, list) ? lane.openThread : null,
+ }
})
return
}
@@ -271,63 +293,83 @@ function press($: EngineInterface, action: string, work: () => Promise)
return work().then(() => undefined, (error: unknown) => $.ui.toast(`ui.press: ${action}: ${causeOf(error)}`))
}
-/** Marks `peer`, or every peer when none is given, read through its newest inbound entry, and refreshes the status line. */
-async function markPeersRead($: EngineInterface, peer?: string, laneId?: string): Promise {
+/**
+ * Marks `peer`, or every peer when none is given, read through its newest inbound entry, and refreshes the status
+ * line: for an agent lane only when the line counts agent lanes (`withLanes`).
+ */
+async function markPeersRead($: EngineInterface, peer?: string, laneId?: string, withLanes = false): Promise {
if (laneId !== undefined) {
await changeLane($, laneId, lane => ({ ...lane, readThrough: markRead(lane.readThrough, lane.entries, peer) }))
+ if (withLanes) await syncStatus($, 'ui.press', false, true)
return
}
const list = await read($, entries)
await update($, readThrough, marks => markRead(marks, list, peer))
- await syncStatus($, 'ui.press')
+ await syncStatus($, 'ui.press', false, withLanes)
}
-/** `Read all` in the pane and in the band. */
-function readAll($: EngineInterface): Promise {
- return press($, 'read all', () => markPeersRead($))
+/** `Read all` in the band; with agentLanesInBand (`withLanes`), it also marks every agent lane read. */
+function readAll($: EngineInterface, withLanes: boolean): Promise {
+ return press($, 'read all', async () => {
+ if (withLanes) await changeLanes($, lane => ({ ...lane, readThrough: markRead(lane.readThrough, lane.entries) }))
+ await markPeersRead($, undefined, undefined, withLanes)
+ })
}
-function paneActions($: EngineInterface, laneId?: string): PaneActions {
+function paneActions($: EngineInterface, withLanes: boolean, laneId?: string): PaneActions {
+ /** Any change of a lane can evict lane messages (fitLanes); with agentLanesInBand, the status line then counts again. */
+ const thenSync = (work: () => Promise) => async (): Promise => {
+ await work()
+ if (laneId !== undefined && withLanes) await syncStatus($, 'ui.press', false, true)
+ }
return {
openThread: peer => press($, 'open thread', async () => {
const lane = laneId === undefined ? undefined : await readLane($, laneId)
const marks = lane?.readThrough ?? await read($, readThrough)
const from = newestSeq(lane?.entries ?? await read($, entries))
await changeThread($, laneId, () => ({ peer, since: markOf(marks, peer), from }))
- await markPeersRead($, peer, laneId)
+ await markPeersRead($, peer, laneId, withLanes)
}),
- back: () => press($, 'back', () => changeThread($, laneId, () => null)),
- markRead: peer => press($, 'mark read', () => markPeersRead($, peer, laneId)),
- readAll: () => press($, 'read all', () => markPeersRead($, undefined, laneId)),
- page: page => press($, 'page', () => changeThread($, laneId, open => open === null ? null : { ...open, page })),
- identity: identityPage => press($, 'identity', () => changeThread($, laneId, open => open === null ? null : { ...open, identityPage })),
- reason: reasonSeq => press($, 'send details', () => changeThread($, laneId, open => open === null ? null : { ...open, reasonSeq: open.reasonSeq === reasonSeq ? undefined : reasonSeq })),
+ back: () => press($, 'back', thenSync(() => changeThread($, laneId, () => null))),
+ markRead: peer => press($, 'mark read', () => markPeersRead($, peer, laneId, withLanes)),
+ readAll: () => press($, 'read all', () => markPeersRead($, undefined, laneId, withLanes)),
+ page: page => press($, 'page', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, page }))),
+ identity: identityPage => press($, 'identity', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, identityPage }))),
+ reason: reasonSeq => press($, 'send details', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, reasonSeq: open.reasonSeq === reasonSeq ? undefined : reasonSeq }))),
lane: id => press($, 'select lane', () => update($, selectedLane, () => id)),
- rosterPage: page => press($, 'peer page', async () => {
+ rosterPage: page => press($, 'peer page', thenSync(async () => {
if (laneId === undefined) await update($, rosterPage, () => page)
else await changeLane($, laneId, lane => ({ ...lane, rosterPage: page }))
- }),
+ })),
lanePage: page => press($, 'agent page', () => update($, lanePage, () => page)),
}
}
-function bandActions($: EngineInterface): BandActions {
+/** The band's buttons; with agentLanesInBand (`withLanes`), Read all and Hide also cover agent lanes. */
+function bandActions($: EngineInterface, withLanes: boolean): BandActions {
return {
open: () =>
press($, 'the pane did not open', async () => {
const problem = await openPane($)
if (problem !== undefined) $.ui.toast(`ui.press: ${problem}`)
}),
- readAll: () => readAll($),
+ readAll: () => readAll($, withLanes),
hide: () =>
press($, 'hide', async () => {
const through = newestSeq(await read($, entries))
+ if (withLanes) {
+ const all = await read($, lanes)
+ await update($, hiddenLanes, hidden => hideLanes(hidden, all))
+ }
await update($, hiddenThrough, seq => Math.max(seq, through))
}),
}
}
-export const register: Register = on => {
+export const register: Register = (on, options) => {
+ /** The `agentLanesInBand` option: the band and the status line add the unread count of all agent lanes. */
+ const withLanes = options.agentLanesInBand === true
+
on('session.start', async ($, e, next) => {
try {
await $.command.register({ name: 'highcommand', description: DESCRIPTION, argumentHint: '[open]', immediate: true })
@@ -335,14 +377,14 @@ export const register: Register = on => {
diagnose($, `high-command: session.start: could not register /highcommand: ${causeOf(error)}`)
}
try { await ensureCapture($, await $.clock.now()) } catch (error) { diagnose($, `high-command: session.start: could not set capture window: ${causeOf(error)}`) }
- await syncStatus($, 'session.start', true)
+ await syncStatus($, 'session.start', true, withLanes)
return next(e)
})
// /clear, /resume, and /branch reset $.state but leave the status line pinned.
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
try { await ensureCapture($, await $.clock.now(), true) } catch (error) { diagnose($, `high-command: classic.SessionStart: could not set capture window: ${causeOf(error)}`) }
- await syncStatus($, 'classic.SessionStart')
+ await syncStatus($, 'classic.SessionStart', false, withLanes)
return next(e)
})
@@ -359,7 +401,7 @@ export const register: Register = on => {
} catch (error) {
diagnose($, `high-command: session.receive: could not record a delivery (${e.origin.kind}): ${causeOf(error)}`)
}
- if (e.agentId === undefined) await syncStatus($, 'session.receive')
+ if (e.agentId === undefined || withLanes) await syncStatus($, 'session.receive', false, withLanes)
return queued
})
@@ -394,7 +436,7 @@ export const register: Register = on => {
} catch (error) {
diagnose($, `high-command: prompt.submit: could not record a delivery (${e.origin.kind}): ${causeOf(error)}`)
}
- await syncStatus($, 'prompt.submit')
+ await syncStatus($, 'prompt.submit', false, withLanes)
return entered
})
@@ -410,7 +452,7 @@ export const register: Register = on => {
} catch (error) {
diagnose($, `high-command: session.send: could not record a message to ${sanitize(e.to)}: ${causeOf(error)}`)
}
- if (e.agentId === undefined) await syncStatus($, 'session.send')
+ if (e.agentId === undefined || withLanes) await syncStatus($, 'session.send', false, withLanes)
return sent
})
@@ -427,6 +469,8 @@ export const register: Register = on => {
} catch (error) {
diagnose($, `high-command: tool.call ListAgents: could not record peer states: ${causeOf(error)}`)
}
+ // Peer states make a lane longer, so fitLanes can evict lane messages.
+ if (e.agentId !== undefined && withLanes) await syncStatus($, 'tool.call ListAgents', false, true)
return out
})
@@ -488,7 +532,7 @@ export const register: Register = on => {
laneIds: Object.keys(all), agents: Object.values(await read($, agents)),
laneUnread: new Map(Object.entries(all).map(([id, lane]) => [id, unreadTotal(rosterOf(lane.entries, {}, lane.readThrough))])),
capture: await read($, capture), evicted: list.at(-1)?.evictedTotal ?? 0,
- }, paneActions($, laneId))
+ }, paneActions($, withLanes, laneId))
} catch (error) {
const { Text } = ui
return {`high-command: ui.render (Pane): ${causeOf(error)}`}
@@ -500,14 +544,18 @@ export const register: Register = on => {
const below = await next(e)
const list = await read($, entries)
// A Hide holds while the person had seen every inbound entry: those in the window and those recorded before.
- if ((await read($, hiddenThrough)) >= Math.max(await read($, inboundThrough), newestInboundSeq(list))) return below
+ const isHidden = (await read($, hiddenThrough)) >= Math.max(await read($, inboundThrough), newestInboundSeq(list))
+ // With agentLanesInBand, it holds only while the person had also seen each agent lane's newest unread entry.
+ const all = withLanes ? await read($, lanes) : {}
+ if (isHidden && (!withLanes || lanesSeen(all, await read($, hiddenLanes)))) return below
const rows = rosterOf(list, {}, await read($, readThrough))
const waiting = rows.filter(row => row.unread > 0)
- if (waiting.length === 0) return below
+ const laneUnread = withLanes ? lanesUnread(all) : 0
+ if (waiting.length === 0 && laneUnread === 0) return below
// Other mods draw `below`; when the band would push it past the engine's bounds, it yields.
- const band = bandTree($.ui.resolve(e), waiting, bandActions($), below, peerLabels(rows.map(row => row.peer)))
+ const band = bandTree($.ui.resolve(e), waiting, bandActions($, withLanes), below, peerLabels(rows.map(row => row.peer)), laneUnread)
if (fitsBounds(band)) return band
- const compact = compactBandTree($.ui.resolve(e), waiting, bandActions($), below)
+ const compact = compactBandTree($.ui.resolve(e), waiting, bandActions($, withLanes), below, laneUnread)
return fitsBounds(compact) ? compact : below
})
}
diff --git a/hooks/text.ts b/hooks/text.ts
index 2eb0087..71c9a35 100644
--- a/hooks/text.ts
+++ b/hooks/text.ts
@@ -132,6 +132,15 @@ export function count(n: number, noun: string): string {
return `${n} ${noun}${n === 1 ? '' : 's'}`
}
+/**
+ * The unread counts that the band and the status line show, as in `3 unread (retained) · 2 unread in agent lanes`:
+ * the main inbox's count, and the agent lanes' count when it is above zero; '' when both are zero.
+ */
+export function unreadText(main: number, lanes = 0): string {
+ return [main > 0 ? `${main} unread (retained)` : undefined, lanes > 0 ? `${lanes} unread in agent lanes` : undefined]
+ .filter(part => part !== undefined).join(' · ')
+}
+
/**
* A full peer identity: one line without control or format characters
* (bidi controls, zero-width marks). Bracketed text belongs to the name.
diff --git a/hooks/views.tsx b/hooks/views.tsx
index 25b96bb..58755f2 100644
--- a/hooks/views.tsx
+++ b/hooks/views.tsx
@@ -3,7 +3,7 @@ import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProp
import type { HighCommandEntry, HighCommandAgent, HighCommandCapture } from '../types'
import { isNewer, labelOf, unreadTotal, type RosterRow, type Thread } from './inbox'
import { LIMITS } from './limits'
-import { captureLabel, identityPart, clockLabel, count, peerLabel, peerLabels, stateLabel, trustLabel } from './text'
+import { captureLabel, identityPart, clockLabel, count, peerLabel, peerLabels, stateLabel, trustLabel, unreadText } from './text'
/** The elements High Command draws with; every surface's table has them. */
type HighCommandElements = {
@@ -217,20 +217,25 @@ export function paneTree(ui: HighCommandElements, model: PaneModel, actions: Pan
}
-/** The band row above what other mods draw there. `waiting` holds only rows with unread entries. */
+/**
+ * The band row above what other mods draw there. `waiting` holds only rows with unread entries; `laneUnread` is the
+ * unread count of all agent lanes, above zero only with agentLanesInBand on.
+ */
export function bandTree(
{ Box, Text, Button }: HighCommandElements,
waiting: readonly RosterRow[],
actions: BandActions,
below: RenderElement,
labels: Map,
+ laneUnread = 0,
): RenderElement {
const named = waiting.slice(0, LIMITS.bandPeers).map(row => `${labels.get(row.peer)} ${row.unread}`)
const more = waiting.length > LIMITS.bandPeers ? [`+${waiting.length - LIMITS.bandPeers} more`] : []
+ const peers = [...named, ...more]
return (
- {`High Command · ${unreadTotal(waiting)} unread (retained) — ${[...named, ...more].join(' · ')}`}
+ {`High Command · ${unreadText(unreadTotal(waiting), laneUnread)}${peers.length === 0 ? '' : ` — ${peers.join(' · ')}`}`}
@@ -240,11 +245,11 @@ export function bandTree(
)
}
-/** Keeps only the count and Open when the full band exceeds the shared budget. */
-export function compactBandTree({ Box, Text, Button }: HighCommandElements, waiting: readonly RosterRow[], actions: BandActions, below: RenderElement): RenderElement {
+/** Keeps only the counts and Open when the full band exceeds the shared budget. */
+export function compactBandTree({ Box, Text, Button }: HighCommandElements, waiting: readonly RosterRow[], actions: BandActions, below: RenderElement, laneUnread = 0): RenderElement {
return
- {`High Command · ${unreadTotal(waiting)} unread (retained)`}
+ {`High Command · ${unreadText(unreadTotal(waiting), laneUnread)}`}
{below}
diff --git a/types/index.d.ts b/types/index.d.ts
index c5a49e1..48afa68 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -79,6 +79,11 @@ export type HighCommandLane = {
peers: Record
readThrough: Record
openThread: HighCommandThread | null
+ /**
+ * The seq of the newest entry recorded that can be unread; it only grows and outlives the entry's eviction.
+ * Absent before the lane's first append.
+ */
+ inboundThrough?: number
}
export type HighCommandAgent = {
@@ -109,8 +114,15 @@ declare module 'claude-code' {
hiddenThrough: number
/** The seq of the newest inbound entry recorded; it only grows and outlives the entry's eviction. */
inboundThrough: number
- /** The status line as last pinned: the versions of `entries` and `readThrough` it counts, and its count. */
- statusFrom: { entries: number; marks: number; unread: number }
+ /**
+ * Per agent lane, the newest seq that a press of Hide had seen while agentLanesInBand is on; a mark only grows.
+ */
+ hiddenLanes: Record
+ /**
+ * The status line as last pinned: the versions of `entries` and `readThrough` it counts, and its count. With
+ * agentLanesInBand on, also the version of `lanes` and the unread count of all agent lanes.
+ */
+ statusFrom: { entries: number; marks: number; unread: number; lanes?: number; laneUnread?: number }
}
}
}