diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index bfe56ca..d3bca12 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "high-command",
- "version": "1.2.2",
+ "version": "1.3.0",
"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",
diff --git a/AGENTS.md b/AGENTS.md
index e3a425e..e93930c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,14 +45,14 @@ claude plugin list --json | jq -r '.[] | select(.id == "high-command@high-comman
| Path | Role |
| --- | --- |
-| `hooks/register.tsx` | The hooks module: every hook, the `$.state` atoms, diagnostics, and button handlers. The only file that uses `$`. |
+| `hooks/register.tsx` | The hooks module: every hook, the inbox of each conversation in module memory (the source of truth), the copy of the current inbox it writes to `$.state` for drawing and reloads, diagnostics, and button handlers. The only file that uses `$`. |
| `hooks/envelope.ts` | Parses a delivery into entries and peer reports. |
| `hooks/listing.ts` | Reads peer states from a ListAgents listing. |
| `hooks/inbox.ts` | Pure state transforms: append, read marks, roster, threads, recipients. |
| `hooks/summary.ts` | The `/highcommand` text. |
| `hooks/views.tsx` | The pane and band trees, and the check that keeps the band within the engine's drawing bounds. |
| `hooks/text.ts`, `hooks/limits.ts` | Text helpers and bounds. |
-| `types/index.d.ts` | The `PluginState` contract. |
+| `types/index.d.ts` | The entry, peer, and lane types, and the `PluginState` contract of the `$.state` copy. |
| `tests/` | Local test files (`*.test.ts`) for the test kit. Git does not track them. |
## Commands
diff --git a/README.md b/README.md
index 21ca33f..31d43dc 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
+
One inbox for agent-team teammates, named subagents, and other Claude Code sessions.
@@ -159,7 +159,7 @@ Set options in `/config`, or under `pluginConfigs["high-command@high-command"].o
| --- | --- | --- |
| `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.
+A change takes effect at once: Claude Code reloads the mod with the new values. The reload keeps the current inbox, but it drops the inboxes that the mod kept for `/resume` (see **Reloads** in [Known limits](#known-limits)).
## How it compares
@@ -171,7 +171,7 @@ Claude Code and other mods also show peer messages. Each row says what the tool
| Built-in `/list-agents` (`/peers`) ([cross-session messaging](https://code.claude.com/docs/en/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](https://code.claude.com/docs/en/agent-teams)) | Lists teammates and subagents. Enter opens one teammate's transcript. | One inbox across teammates, named subagents, and other sessions. |
| [session-orchestra](https://github.com/atarazevich/session-orchestra) | A 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-crosstalk](https://github.com/kbrdn1/claude-crosstalk) | A 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. |
+| [claude-crosstalk](https://github.com/kbrdn1/claude-crosstalk) | A 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 Claude Code process. |
## Known limits
@@ -183,7 +183,7 @@ Claude Code and other mods also show peer messages. Each row says what the tool
### What it does not catch
-- **Earlier history**: messages from before this session, or from before `/clear`, `/resume`, or `/branch`.
+- **Earlier history**: messages from before this Claude Code process started, or from before `/clear`. `/branch` keeps the parent conversation's inbox. `/resume` brings an inbox back only for one of the last 10 conversations that ended in this process since the mod last loaded. Any other `/resume` starts empty (see **Branches and resumes** below).
- **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.
@@ -199,7 +199,9 @@ The list below holds the limits you can hit in daily use. [docs/usage.md](docs/u
- **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. **Show 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 reported send result. **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.
+- **Branches and resumes**: High Command starts empty in each new Claude Code process. `/branch` keeps the inbox as it stood at the branch point: the messages of the main inbox and of each agent lane, the peers and their reply addresses, the read marks, and **Hide**. New messages go on top. A message you had not read stays unread, with its sender label and any `suspicious` mark, so the band and the status line count it again. The pane says when you branched and how many messages came from the parent conversation, and `/highcommand` says the same. That count covers the main inbox and the agent lanes. A branch of a branch also keeps the messages of each conversation before it, and `/highcommand` counts those conversations. `/resume` of a conversation that ended in the same process brings back its inbox as it was when it ended. The process keeps the inboxes of the 10 conversations that ended last, so a resume of an older one starts empty. `/resume` never keeps the inbox of the conversation you leave. A `/resume` of any other conversation, such as one from an earlier process in the `/resume` list, starts empty, with no agent lanes, as after `/clear`. The open thread and the roster page start over, in the main inbox and in each agent lane, and so does the lane you picked.
+- **Reloads**: a reload of the mod keeps the current inbox, but it drops every inbox kept for `/resume`. A `/resume` after the reload then starts empty, also for a conversation that ended in this process. Claude Code reloads the mod when you change one of its [options](#options), and when it loads new code of the mod, as at `/reload-plugins` after an update. `/branch` still keeps the inbox after a reload.
+- **No earlier history**: `/clear` starts an empty inbox. High Command keeps the inboxes in the memory of the Claude Code process only, never on disk. So `claude --resume` in a new process, `--fork-session`, and a `/fork` copy, which runs in a process of its own, start empty, even when the conversation that Claude reads holds earlier messages. A conversation that you move to the background, with `/background`, `/bg`, or the left arrow (`←`) on an empty prompt, starts empty too: Claude Code continues it in a new process, as after `claude --resume`. The pane and `/highcommand` say when the capture window began. After `/clear`, and in each of these cases, 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.
@@ -208,19 +210,20 @@ The list below holds the limits you can hit in daily use. [docs/usage.md](docs/u
- 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.
+- It keeps up to 200 messages in the main inbox and in each agent lane, and the states of up to 100 peers, in the memory of the Claude Code process: one inbox for each conversation, for the current one and the 10 used last before it. A message, a refusal reason, and the text in **Send details** keep at most 2,000 characters each, with control characters removed. It also writes a copy of the current inbox to its own `$.state` values, which Claude Code keeps in this process only, so that a reload of the mod keeps the inbox. `/branch` copies the inbox to the branch. `/clear` starts an empty inbox. A reload of the mod drops every inbox but the current one, and the end of the process drops all of 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.
+The first nine 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.
| Event | What the hook does |
| --- | --- |
| `session.start` | Registers `/highcommand`, starts the capture window, and sets the status line. |
-| `classic.SessionStart`, on `/clear`, `/resume`, and `/branch` | Starts a new capture window and sets the status line again. It adds no context, instructions, or settings. |
+| `session.end` | On `/clear`, `/resume`, and `/branch`, notes the conversation that ends: the parent of a branch. The inboxes stay in memory. At an exit it does nothing. |
+| `classic.SessionStart` | Sets the conversation that High Command shows. On `/branch`, copies the parent conversation's inbox to the branch. On `/resume`, takes back the inbox of the resumed conversation when this process kept one. On `/clear`, and otherwise after a switch, starts an empty inbox. Then writes the copy of the inbox to `$.state` and sets the status line again. It adds no context, instructions, or settings. |
| `session.receive` | For a message from a peer, records the text that Claude Code queues for Claude. |
| `prompt.submit`, for peer prompts | Records a named subagent's `` prompt, and reads the local agent list for its sender label. It submits no prompt. |
| `session.send`, for Claude's sends | Records each plain-text SendMessage attempt and its reported result. |
@@ -241,7 +244,7 @@ High Command has no hook on a permission check, so it never changes a permission
- **`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 as `unnamed peer`**: the delivery carried no sender name. Start Claude Code with `--debug`. Each such delivery writes `high-command: session.receive: a delivery named no sender` to the debug log, or the same line with `prompt.submit` for a subagent's message. Please open an issue with your Claude Code version and that line.
- **No band in VS Code or the mobile app**: these surfaces draw no band. Use `/highcommand open` for the pane, or `/highcommand`.
-- **High Command is empty after `/clear`, `/resume`, or `/branch`**: High Command holds the current conversation only.
+- **High Command is empty after `/clear` or `/resume`**: `/clear` starts an empty inbox. `/resume` brings an inbox back only for one of the last 10 conversations that ended in the same Claude Code process, and only when the mod did not reload after that conversation ended. A change to an [option](#options) reloads the mod, and so does `/reload-plugins` when it loads new code of the mod. High Command keeps nothing on disk, so `claude --resume` in a new process starts empty, and so does a conversation that `/background`, `/bg`, or `←` on an empty prompt moves to a new process.
- **`claude -p "/highcommand"` shows no messages**: a `-p` run reports only the messages of that run. Use `/highcommand` in a long-running session or an SDK host.
- **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.
diff --git a/docs/usage.md b/docs/usage.md
index e65ef5f..a44e8dc 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`. 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.
+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`. After `/branch`, and after a `/resume` that brings an inbox back, it counts the unread messages of that inbox again (see **Branches and resumes** in [Known limits](#known-limits)). 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
@@ -55,7 +55,7 @@ A roster or a thread reads from the top down:
1. A location line, such as `High Command › Main inbox` or `High Command › Main inbox › researcher`. When the line has room, it ends with the dim hint `ctrl+x tab or click for keys · esc to prompt`.
2. In a roster, the counts, such as `3 unread (retained) · 4 peers`, and **Read all**. In a thread, the peer's last reported state, then the facts line with **Show identity** and **Mark read**.
-3. A dim line that says when the capture window began, that it covers this conversation only, and that the order is observation order. After `/clear`, `/resume`, or `/branch`, it also says that earlier history was not restored.
+3. A dim line that says when the capture window began, that it covers this conversation only, and that the order is observation order. After `/clear`, and after a `/resume` or a `/fork` copy that brings no inbox back, it also says that earlier history was not restored. A conversation that you moved to the background (`/background`, `/bg`, or `←` on an empty prompt) runs in a new process, so its line says this too. In a branch, the line starts with the time of `/branch` and the count of messages the branch kept from the parent conversation, as in `Branched 13:04 · 12 messages from the parent conversation · since 12:04 · observation order`.
4. A notice that counts the messages dropped for good, when there are any.
5. The peer rows, or the thread's messages, then a page row when there is more than one page.
6. In a roster, the agent lanes.
@@ -113,7 +113,7 @@ Set options in `/config`, or under `pluginConfigs["high-command@high-command"].o
| --- | --- | --- |
| `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.
+A change takes effect at once: Claude Code reloads the mod with the new values. The reload keeps the current inbox, but it drops the inboxes that the mod kept for `/resume` (see **Reloads** in [Known limits](#known-limits)).
## Known limits
@@ -126,7 +126,9 @@ The first list holds the limits you can hit in daily use. The second list holds
- **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. **Show 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 reported send result. **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.
+- **Branches and resumes**: High Command starts empty in each new Claude Code process. `/branch` keeps the inbox as it stood at the branch point: the messages of the main inbox and of each agent lane, the peers and their reply addresses, the read marks, and **Hide**. New messages go on top. A message you had not read stays unread, with its sender label and any `suspicious` mark, so the band and the status line count it again. The pane says when you branched and how many messages came from the parent conversation, and `/highcommand` says the same. That count covers the main inbox and the agent lanes. A branch of a branch also keeps the messages of each conversation before it, and `/highcommand` counts those conversations. `/resume` of a conversation that ended in the same process brings back its inbox as it was when it ended. The process keeps the inboxes of the 10 conversations that ended last, so a resume of an older one starts empty. `/resume` never keeps the inbox of the conversation you leave. A `/resume` of any other conversation, such as one from an earlier process in the `/resume` list, starts empty, with no agent lanes, as after `/clear`. The open thread and the roster page start over, in the main inbox and in each agent lane, and so does the lane you picked.
+- **Reloads**: a reload of the mod keeps the current inbox, but it drops every inbox kept for `/resume`. A `/resume` after the reload then starts empty, also for a conversation that ended in this process. Claude Code reloads the mod when you change one of its [options](#options), and when it loads new code of the mod, as at `/reload-plugins` after an update. `/branch` still keeps the inbox after a reload.
+- **No earlier history**: `/clear` starts an empty inbox. High Command keeps the inboxes in the memory of the Claude Code process only, never on disk. So `claude --resume` in a new process, `--fork-session`, and a `/fork` copy, which runs in a process of its own, start empty, even when the conversation that Claude reads holds earlier messages. A conversation that you move to the background, with `/background`, `/bg`, or the left arrow (`←`) on an empty prompt, starts empty too: Claude Code continues it in a new process, as after `claude --resume`. The pane and `/highcommand` say when the capture window began. After `/clear`, and in each of these cases, 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
@@ -137,11 +139,12 @@ The first list holds the limits you can hit in daily use. The second list holds
- 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.
+ 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 reach 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**. High Command then keeps no change that would pass it. A new agent message 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 ...`, and a new peer state in a lane writes `high-command: tool.call ListAgents: could not record peer states: ...`. 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` starts a new capture window, which empties every lane and the main inbox. `/branch` keeps the parent conversation's lanes. `/resume` brings back lanes only with a kept inbox, and otherwise starts with none (see **Branches and resumes**).
- **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. **Show 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.
+- **Messages and presses during a switch**: High Command keeps the inbox of each conversation in the memory of the Claude Code process and shows the current one. A message that arrives while `/branch`, `/resume`, or `/clear` runs goes to the conversation that was current when it arrived. During `/branch` that is the parent, so the branch gets the message too, unless its delivery to Claude ends only after the branch began: then it stays with the parent. **Read all**, **Mark read**, **Hide**, and an opened thread cover only the messages that the pane or the band showed when you pressed. A press on a drawing of another conversation, such as one drawn before a switch, does nothing.
- **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**: below 90 columns, a roster row takes two lines, so the roster is twice as tall. The first line holds the name, the unread count, the time, and the state, and a narrow pane cuts the state first. The second line starts with the marks: the sender label, any `suspicious` mark, and any `(not delivered)` mark. They wrap and are never cut. The latest message follows them, cut at its end, or takes a line of its own when the marks leave it fewer than 20 columns. From 90 columns, each row is one line of fixed columns: a name wider than 16 columns shows its start and its end, as in `integrat…r-alpha`, and a message from another day shows only its date. When two names would show the same label, each shows a shorter cut and a 4-digit code from its full name, as in `inte…nner [f2e3]` and `inte…nner [e886]`. A label comes from its own name, so a new look-alike can switch a name to its code but cannot take its label. Names that also share the code are numbered in name order, and only those numbers can move to another peer. The state column widens to the longest state on the page but leaves the message at least 27 columns, so a state is cut only when the row is full. Those columns hold the `suspicious` and `(not delivered)` marks, so a cut never reaches them. A thread shows the name up to 60 characters, the full state, and the time of day. The column header scrolls away on a long roster. The location line leaves out its key hint when the hint does not fit, and a thread moves **Show identity** and **Mark read** to a line of their own.
- **Band space**: when other mods draw a lot in the area above the prompt, the band first leaves out its key hint, then 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.
@@ -161,6 +164,9 @@ The first list holds the limits you can hit in daily use. The second list holds
| `unverified sender` on every teammate | Your teammates run in split panes, which write through a file that Claude Code cannot check. |
| 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. |
| States never appear | Peer states come from teammates' notices and from ListAgents results that Claude gets. Local agent status is separate: open the pane or run `/highcommand` to read it again. |
+| `high-command: : could not update the view: ...` | High Command could not write its copy of the inbox to `$.state`. The pane and the band still show the inbox, but they can draw a change late, and a reload of the mod can bring back an older copy. The line names the cause. |
+| `high-command: : could not take back the inbox: ...`, or `high-command: : could not read the session id: ...` | After a reload of the mod, High Command could not read its copy of the inbox in `$.state`, or could not tell which session it belongs to. Until it can, it records nothing and never writes over the copy, and it tries again at the next event. Messages that arrive meanwhile still reach Claude, but High Command does not list them. The line names the cause. |
+| `high-command: : the agent lanes of 1.2.2 pass the space of one saved value, ...` | After an update from 1.2.2, the agent lanes and their **Hide** marks together were too large for one saved value. High Command kept the lanes and dropped their **Hide** marks, so the band can count those lanes again. When even the lanes alone were too large, the line also counts the lanes it dropped, those whose newest message was oldest. |
| `high-command: : could not update the status line: ...` | The status line could not be updated, so it can show an old count. The line names the cause. High Command tries again at the next message to or from your session, and when you open a thread or press **Mark read** or **Read all**. |
| A harmless message is marked `suspicious` | The mark comes from a short fixed list of patterns, so a normal message can match. Nothing is blocked. |
| 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 while a Claude Code survey shows, and when another mod draws so much below it that the band would pass Claude Code's limits for one drawing. In each case `/highcommand open` shows the pane. |
diff --git a/hooks/inbox.ts b/hooks/inbox.ts
index 6095abc..5087df4 100644
--- a/hooks/inbox.ts
+++ b/hooks/inbox.ts
@@ -103,7 +103,12 @@ export function applyReports(peers: Readonly, reports: readonly PeerRepor
...(address !== undefined && isCurrent(known?.address) ? { address: { value: address, at, order: ++order } } : {}),
})
}
- const all = [...next]
+ return keepPeers(next)
+}
+
+/** The LIMITS.peers peers reported most recently. */
+function keepPeers(peers: ReadonlyMap): Peers {
+ const all = [...peers]
return Object.fromEntries(all.length <= LIMITS.peers ? all : all.sort(([, a], [, b]) => b.seenAt - a.seenAt).slice(0, LIMITS.peers))
}
@@ -201,10 +206,10 @@ export function lanesUnread(lanes: Readonly>): n
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 {
+/** Per lane, the newest seq that a press of Hide saw, `seen`. A mark never moves back. */
+export function hideLanes(hidden: Readonly>, seen: ReadonlyMap): 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)))
+ for (const [id, seq] of seen) next.set(id, Math.max(next.get(id) ?? 0, seq))
return Object.fromEntries(next)
}
@@ -221,22 +226,6 @@ export function lanesSeen(lanes: Readonly>, hidd
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 || (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))
-}
-
/**
* Whether an append keeps the open thread: the person opened it after the append, or its peer
* has entries before and after it. So the thread on a peer the append drops closes, and so does a thread
diff --git a/hooks/limits.ts b/hooks/limits.ts
index 452d49f..4cb46dd 100644
--- a/hooks/limits.ts
+++ b/hooks/limits.ts
@@ -7,12 +7,19 @@ export const LIMITS = {
/** 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.
+ * Characters of JSON text that all agent lanes keep together: their copy in `$.state` is 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,
+ /** Characters of JSON text in one saved value that Claude Code 2.1.296 accepts: a `$.state` value past it is refused. */
+ savedChars: 4_194_304,
/** Peers kept; the ones reported least recently drop first. */
peers: 100,
+ /**
+ * Conversations besides the current one whose inbox the process keeps in module memory, for a branch or a resume;
+ * the one used least recently drops first.
+ */
+ snapshots: 10,
/** Characters of a body kept; the transcript keeps the rest. */
bodyChars: 2000,
/** Characters of a summary line. */
diff --git a/hooks/register.tsx b/hooks/register.tsx
index ad8bcc1..028c290 100644
--- a/hooks/register.tsx
+++ b/hooks/register.tsx
@@ -1,7 +1,7 @@
-import { atom, read, update } from 'claude-code'
+import { atom, read } from 'claude-code'
import type { AgentInfo, CommandRunResult, EngineInterface, Register, SessionReceiveOrigin, SessionSendResult, TraceEntry } from 'claude-code'
-import type { HighCommandEntry, HighCommandLane, HighCommandThread } from '../types'
+import type { HighCommandAgent, HighCommandCapture, HighCommandEntry, HighCommandLane, HighCommandPeer, HighCommandThread, HighCommandView } from '../types'
import { agentSenderOf, isAgentMessage, parseDelivery, spawnerOf, UNNAMED, type NewEntry, type PeerReport } from './envelope'
import {
appendEntries,
@@ -9,7 +9,6 @@ import {
applyReports,
fitLanes,
hideLanes,
- isNewerView,
keepsThread,
lanesSeen,
lanesUnread,
@@ -25,6 +24,7 @@ import {
unreadTotal,
type RosterRow,
} from './inbox'
+import { LIMITS } from './limits'
import { parseListing, parseStructuredListing } from './listing'
import { summaryText } from './summary'
import { count, sanitize, peerKey, peerKeys, peerLabels, unreadText } from './text'
@@ -39,58 +39,210 @@ const NO_PANES =
/** The origin kinds whose deliveries can hold messages from peers; `unclassified` counts only with a frame. */
const MESSAGE_ORIGINS: ReadonlySet = new Set(['peer', 'coordinator', 'peer-send-message', 'unclassified'])
-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, {})
-const readThrough = atom(READ_THROUGH, {})
-const openThread = atom({ plugin: 'high-command', key: 'openThread' } as const, null)
-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(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 agentStatus = atom({ plugin: 'high-command', key: 'agentStatus' } as const, false)
-const rosterPage = atom({ plugin: 'high-command', key: 'rosterPage' } as const, 0)
-const agents = atom({ plugin: 'high-command', key: 'agents' } as const, {})
-const capture = atom({ plugin: 'high-command', key: 'capture' } as const, null)
+const VIEW = { plugin: 'high-command', key: 'view' } as const
+const LANE_VIEW = { plugin: 'high-command', key: 'laneView' } as const
+
+/** The values of 1.2.2, read once after an update (adoptOld). */
+const OLD_ENTRIES = { plugin: 'high-command', key: 'entries' } as const
+const OLD_PEERS = { plugin: 'high-command', key: 'peers' } as const
+const OLD_READ_THROUGH = { plugin: 'high-command', key: 'readThrough' } as const
+const OLD_OPEN_THREAD = { plugin: 'high-command', key: 'openThread' } as const
+const OLD_HIDDEN_THROUGH = { plugin: 'high-command', key: 'hiddenThrough' } as const
+const OLD_INBOUND_THROUGH = { plugin: 'high-command', key: 'inboundThrough' } as const
+const OLD_LANES = { plugin: 'high-command', key: 'lanes' } as const
+const OLD_HIDDEN_LANES = { plugin: 'high-command', key: 'hiddenLanes' } as const
+const OLD_CAPTURE = { plugin: 'high-command', key: 'capture' } as const
+const OLD_SELECTED_LANE = { plugin: 'high-command', key: 'selectedLane' } as const
+const OLD_LANE_PAGE = { plugin: 'high-command', key: 'lanePage' } as const
+const OLD_AGENT_STATUS = { plugin: 'high-command', key: 'agentStatus' } as const
+const OLD_ROSTER_PAGE = { plugin: 'high-command', key: 'rosterPage' } as const
+const OLD_AGENTS = { plugin: 'high-command', key: 'agents' } as const
+
+/**
+ * The mirror of the current inbox in `$.state`. The pane and the band read it, so Claude Code draws them again after
+ * each mirror write, but they draw the inbox in module memory: a late mirror write cannot show another conversation.
+ */
+const view = atom(VIEW, {
+ session: '', epoch: 0, revision: 0, entries: [], peers: {}, readThrough: {}, hiddenThrough: 0, inboundThrough: 0, capture: null,
+ openThread: null, rosterPage: 0, selectedLane: null, lanePage: 0, agentStatus: false, agents: {},
+})
+
+/**
+ * One conversation's inbox. Module memory holds it, and it survives /branch, /resume, and /clear, so the reset of
+ * `$.state` at a switch cannot race it. Each hook and press changes it at once, without an await in between (edit).
+ */
+type Inbox = {
+ /** A new number for each new inbox, a branch copy included: a press from a drawing of another inbox marks nothing. */
+ epoch: number
+ entries: HighCommandEntry[]
+ peers: Record
+ readThrough: Record
+ hiddenThrough: number
+ inboundThrough: number
+ lanes: Record
+ hiddenLanes: Record
+ capture: HighCommandCapture | null
+ openThread: HighCommandThread | null
+ rosterPage: number
+ selectedLane: string | null
+ lanePage: number
+ agentStatus: boolean
+}
+
+/** Inboxes made since this module loaded. */
+let epochs = 0
+
+function emptyInbox(): Inbox {
+ epochs += 1
+ return {
+ epoch: epochs, entries: [], peers: {}, readThrough: {}, hiddenThrough: 0, inboundThrough: 0, lanes: {}, hiddenLanes: {},
+ capture: null, openThread: null, rosterPage: 0, selectedLane: null, lanePage: 0, agentStatus: false,
+ }
+}
+
+/**
+ * The inbox of each conversation this process saw, by session id, the one used last at the end: a branch copies its
+ * parent's, and a resume takes its own back. It keeps the current one and LIMITS.snapshots others.
+ */
+const inboxes = new Map()
+/** The session whose inbox the hooks change and the pane and the band draw; '' when Claude Code did not say. */
+let current: string | undefined
+/** The session that ended last at a switch, until the next conversation starts: the parent of a branch. */
+let lastEnded: string | undefined
+/** The local agents of the process: every conversation shows them. */
+let agents: Record = {}
+/** Counts the changes of module data: the mirror and the status line write only after one. */
+let changes = 0
+
+/** Changes `inbox` at once and counts the change. */
+function edit(inbox: Inbox, change: Partial): void {
+ Object.assign(inbox, change)
+ changes += 1
+}
+
+/** Files `inbox` under `id` as the one used last, and drops the inboxes used least recently past the bound. */
+function keep(id: string, inbox: Inbox): Inbox {
+ inboxes.delete(id)
+ inboxes.set(id, inbox)
+ for (const old of inboxes.keys()) if (inboxes.size > LIMITS.snapshots + 1 && old !== id) inboxes.delete(old)
+ changes += 1
+ return inbox
+}
+
+/** The inbox of the session `id`, a new empty one when the process holds none. */
+function inboxOf(id: string): Inbox {
+ return inboxes.get(id) ?? keep(id, emptyInbox())
+}
+
+/** The current conversation's inbox. */
+function inboxNow(): Inbox {
+ return inboxOf(current ?? '')
+}
+
+/**
+ * The inbox a press acts on: the one its drawing showed, while that conversation is still the current one and its
+ * inbox the same; else undefined, and the press does nothing.
+ */
+function pressed(drawn: Drawn): Inbox | undefined {
+ const inbox = current === drawn.session ? inboxes.get(drawn.session) : undefined
+ return inbox?.epoch === drawn.epoch ? inbox : undefined
+}
+
+/** What a drawing showed: its conversation and inbox, and the newest seq it drew, also per agent lane. */
+type Drawn = { session: string; epoch: number; through: number; lanes: ReadonlyMap }
+
+function drawnOf(inbox: Inbox, list: readonly HighCommandEntry[]): Drawn {
+ const lanes = new Map(Object.entries(inbox.lanes).map(([id, lane]) => [id, newestSeq(lane.entries)]))
+ return { session: current ?? '', epoch: inbox.epoch, through: newestSeq(list), lanes }
+}
+
+/** The entries of `list` that a drawing through seq `through` showed: a mark never covers a message drawn later. */
+function seenOf(list: readonly HighCommandEntry[], through: number): HighCommandEntry[] {
+ return list.filter(entry => entry.seq <= through)
+}
function emptyLane(): HighCommandLane {
return { entries: [], peers: {}, readThrough: {}, openThread: null }
}
-async function readLane($: EngineInterface, id: string): Promise {
- const all = await read($, lanes)
- return Object.hasOwn(all, id) ? all[id]! : emptyLane()
+function laneOf(inbox: Inbox, id: string): HighCommandLane {
+ return Object.hasOwn(inbox.lanes, id) ? inbox.lanes[id]! : emptyLane()
}
-/** Changes one agent lane; fitLanes keeps all lanes within the space of one saved value. */
-async function changeLane($: EngineInterface, id: string, change: (lane: HighCommandLane) => HighCommandLane): Promise {
- await update($, lanes, known => {
- const next = new Map(Object.entries(known))
- next.set(id, change(next.get(id) ?? emptyLane()))
- return fitLanes(Object.fromEntries(next), id)
- })
+/** Characters that the copy of the lanes adds around them: its session id, its epoch, and its keys. */
+const LANE_VIEW_FRAME = 256
+
+/** Characters of the copy of `lanes` and `hiddenLanes` as one saved value. */
+function savedSize(lanes: Readonly>, hiddenLanes: Readonly>): number {
+ return JSON.stringify(lanes).length + JSON.stringify(hiddenLanes).length + LANE_VIEW_FRAME
+}
+
+/**
+ * Lanes and their Hide marks cut to the space of one saved value, for lanes that no change built, as those of 1.2.2:
+ * fitLanes first, then the Hide marks go, so that the band counts those lanes again, and then the lanes whose newest
+ * message is oldest. `isCut` says whether a Hide mark or a lane went; `dropped` counts the lanes.
+ */
+function fitSaved(lanes: Readonly>, hiddenLanes: Readonly>): { lanes: Record; hiddenLanes: Record; isCut: boolean; dropped: number } {
+ const fitted = fitLanes(lanes)
+ if (savedSize(fitted, hiddenLanes) <= LIMITS.savedChars) return { lanes: fitted, hiddenLanes: { ...hiddenLanes }, isCut: false, dropped: 0 }
+ const kept = new Map(Object.entries(fitted))
+ let size = savedSize(fitted, {})
+ let dropped = 0
+ const oldestFirst = [...kept].sort(([, a], [, b]) => (a.entries.at(-1)?.at ?? 0) - (b.entries.at(-1)?.at ?? 0))
+ for (const [id, lane] of oldestFirst) {
+ if (size <= LIMITS.savedChars) break
+ kept.delete(id)
+ size -= JSON.stringify(id).length + JSON.stringify(lane).length + 2
+ dropped += 1
+ }
+ return { lanes: Object.fromEntries(kept), hiddenLanes: {}, isCut: true, dropped }
+}
+
+/**
+ * Sets the agent lanes and their Hide marks of `inbox`, unless their copy would pass the space of one saved value.
+ * fitLanes drops old lane messages, but each lane keeps its newest message, so very many lanes can still pass it: the
+ * change then throws before memory changes, and the hook or the press says so.
+ */
+function setLanes(inbox: Inbox, lanes: Record, hiddenLanes = inbox.hiddenLanes): void {
+ const size = JSON.stringify(lanes).length + JSON.stringify(hiddenLanes).length + LANE_VIEW_FRAME
+ if (size > LIMITS.savedChars) {
+ throw new Error(`the agent lanes would pass the ${count(LIMITS.savedChars, 'character')} that Claude Code saves in one value, so the change is not kept`)
+ }
+ edit(inbox, { lanes, hiddenLanes })
+}
+
+/** Changes one agent lane; fitLanes keeps all lanes within the space of one saved value where it can (setLanes). */
+function changeLane(inbox: Inbox, id: string, change: (lane: HighCommandLane) => HighCommandLane): void {
+ const next = new Map(Object.entries(inbox.lanes))
+ next.set(id, change(next.get(id) ?? emptyLane()))
+ setLanes(inbox, fitLanes(Object.fromEntries(next), id))
+}
+
+/** Changes every agent lane at once; `change` gets each lane and its id. */
+function changeLanes(inbox: Inbox, change: (lane: HighCommandLane, id: string) => HighCommandLane): void {
+ setLanes(inbox, fitLanes(Object.fromEntries(Object.entries(inbox.lanes).map(([id, lane]) => [id, change(lane, id)]))))
+}
+
+function changeThread(inbox: Inbox, id: string | undefined, change: (thread: HighCommandThread | null) => HighCommandThread | null): void {
+ if (id === undefined) edit(inbox, { openThread: change(inbox.openThread) })
+ else changeLane(inbox, id, lane => ({ ...lane, openThread: change(lane.openThread) }))
}
-/** 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)]))))
+function ensureCapture(inbox: Inbox, at: number): void {
+ if (inbox.capture === null) edit(inbox, { capture: { at, reset: false } })
}
-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) }))
+/** The open threads, roster pages, lane picked, and lane page of `inbox` start over, as after a reset of `$.state`. */
+function startOver(inbox: Inbox): Inbox {
+ const lanes = Object.fromEntries(Object.entries(inbox.lanes).map(([id, { openThread: _thread, rosterPage: _page, ...lane }]) => [id, { ...lane, openThread: null }]))
+ edit(inbox, { lanes, openThread: null, rosterPage: 0, selectedLane: null, lanePage: 0, agentStatus: false })
+ return inbox
}
-async function ensureCapture($: EngineInterface, at: number, reset = false): Promise {
- if (await read($, capture) !== null) return
- await update($, capture, known => known ?? { at, reset })
+/** A branch of `parent`: a copy of its inbox under a new epoch, where the views start over (startOver). */
+function branchOf(parent: Inbox): Inbox {
+ epochs += 1
+ return startOver({ ...parent, epoch: epochs })
}
/** Reads status without treating a repeated split-pane roster word as liveness evidence. */
@@ -98,15 +250,14 @@ async function refreshAgents($: EngineInterface, event: string): Promise {
try {
const at = await $.clock.now()
const list = await $.agent.list()
- await update($, agents, known => {
- const next = new Map(Object.entries(known).filter(([, value]) => value.at > at))
- for (const agent of list) if (!next.has(agent.id)) next.set(agent.id, {
- id: agent.id, name: agent.name === undefined ? undefined : peerKey(agent.name),
- status: agent.status, at, source: 'roster snapshot',
- lastEvent: Object.hasOwn(known, agent.id) ? known[agent.id]?.lastEvent : undefined,
- })
- return Object.fromEntries(next)
+ const next = new Map(Object.entries(agents).filter(([, value]) => value.at > at))
+ for (const agent of list) if (!next.has(agent.id)) next.set(agent.id, {
+ id: agent.id, name: agent.name === undefined ? undefined : peerKey(agent.name),
+ status: agent.status, at, source: 'roster snapshot',
+ lastEvent: Object.hasOwn(agents, agent.id) ? agents[agent.id]?.lastEvent : undefined,
})
+ agents = Object.fromEntries(next)
+ changes += 1
} catch (error) {
diagnose($, `high-command: ${event}: could not refresh local agents: ${causeOf(error)}`)
}
@@ -129,68 +280,207 @@ function diagnose($: EngineInterface, line: string): void {
$.ui.log(line.replace(/^high-command: /, ''))
}
+/** Whether this module took the inbox back from `$.state`, or found no copy there: only then may it write the mirror. */
+let isAdopted = false
+/** The take-back under way or done; undefined until the first hook, and again after one that could not read the copy. */
+let ready: Promise | undefined
+
+/**
+ * Takes the inbox back once per module load (adopt), before any write. Resolves false while the copy in `$.state`
+ * cannot be read: the hook then records nothing and writes nothing, and the next hook tries again. So memory that is
+ * empty after a reload never writes over the copy that the module before it left.
+ */
+function ensureReady($: EngineInterface, event: string): Promise {
+ if (ready === undefined) {
+ const adopting = adopt($, event)
+ ready = adopting
+ void adopting.then(isDone => {
+ if (!isDone && ready === adopting) ready = undefined
+ })
+ }
+ return ready
+}
+
/**
- * 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`. `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
- * after the last write of either value reads both final values; a refresh with an older view loses the
- * ifVersion race, and its next reads show what stands. A pin can still show after a newer one when its
- * answer comes late, so each refresh then writes its pin again with ifVersion: a miss means a newer pin
- * landed, and the refresh shows that one. `isForced` pins the first view whatever the line shows: a reload
- * may drop the line.
+ * Takes back the inbox that `$.state` mirrors when this module has none for the current session, as after a reload of
+ * the mod, which empties module memory, keeps `$.state`, and runs session.start again. A mirror of another session
+ * stays out, and one that names no session counts as this session's. Resolves whether it could read the copy.
*/
-async function syncStatus($: EngineInterface, event: string, isForced = false, withLanes = false): Promise {
+async function adopt($: EngineInterface, event: string): Promise {
+ if (current === undefined) {
+ // Without the id, no copy can be told apart from another session's: take nothing back, and try again later.
+ try {
+ current = await $.session.id()
+ } catch (error) {
+ diagnose($, `high-command: ${event}: could not read the session id: ${causeOf(error)}`)
+ return false
+ }
+ }
+ const id = current
+ if (inboxes.has(id)) {
+ isAdopted = true
+ return true
+ }
+ const inbox = emptyInbox()
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, ...(all === undefined ? {} : { lanes: all.version }) }
- if (!isNewerView(view, isForced && isFirst ? undefined : pinned.value)) return
- 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(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
- pin = newer.value
- version = newer.version
+ const main = (await $.state.get(VIEW)).value
+ const laneView = (await $.state.get(LANE_VIEW)).value
+ const isOwn = (session: string | undefined) => session === undefined || session === id
+ if (main !== undefined && isOwn(main.session)) {
+ edit(inbox, {
+ entries: main.entries ?? [], peers: main.peers ?? {}, readThrough: main.readThrough ?? {},
+ hiddenThrough: main.hiddenThrough ?? 0, inboundThrough: main.inboundThrough ?? 0, capture: main.capture ?? null,
+ openThread: main.openThread ?? null, rosterPage: main.rosterPage ?? 0, selectedLane: main.selectedLane ?? null,
+ lanePage: main.lanePage ?? 0, agentStatus: main.agentStatus ?? false,
+ })
+ agents = main.agents ?? {}
+ }
+ if (laneView !== undefined && isOwn(laneView.session)) edit(inbox, { lanes: laneView.lanes ?? {}, hiddenLanes: laneView.hiddenLanes ?? {} })
+ // Until both copies have landed, the values of 1.2.2 still hold what a missing copy would have held.
+ if (main === undefined || laneView === undefined) await adoptOld($, inbox, event, main === undefined, laneView === undefined)
+ } catch (error) {
+ diagnose($, `high-command: ${event}: could not take back the inbox: ${causeOf(error)}`)
+ return false
+ }
+ keep(id, inbox)
+ isAdopted = true
+ return true
+}
+
+/**
+ * Takes over into `inbox` the values that 1.2.2 kept in `$.state` itself, after an update by /reload-plugins: the
+ * values belong to the current session, since Claude Code resets them at each switch. `isMain` and `isLanes` say which
+ * copy has not landed yet, and so which part to take: until a copy lands, each load takes its part from these values.
+ * The lanes take the space of one saved value at most (fitSaved), so that their copy can land.
+ */
+async function adoptOld($: EngineInterface, inbox: Inbox, event: string, isMain: boolean, isLanes: boolean): Promise {
+ if (isMain) {
+ const main = {
+ entries: (await $.state.get(OLD_ENTRIES)).value, peers: (await $.state.get(OLD_PEERS)).value,
+ readThrough: (await $.state.get(OLD_READ_THROUGH)).value, openThread: (await $.state.get(OLD_OPEN_THREAD)).value,
+ hiddenThrough: (await $.state.get(OLD_HIDDEN_THROUGH)).value, inboundThrough: (await $.state.get(OLD_INBOUND_THROUGH)).value,
+ capture: (await $.state.get(OLD_CAPTURE)).value, selectedLane: (await $.state.get(OLD_SELECTED_LANE)).value,
+ lanePage: (await $.state.get(OLD_LANE_PAGE)).value, agentStatus: (await $.state.get(OLD_AGENT_STATUS)).value,
+ rosterPage: (await $.state.get(OLD_ROSTER_PAGE)).value, agents: (await $.state.get(OLD_AGENTS)).value,
+ }
+ if (Object.values(main).some(value => value !== undefined)) {
+ edit(inbox, {
+ entries: main.entries ?? [], peers: main.peers ?? {}, readThrough: main.readThrough ?? {}, openThread: main.openThread ?? null,
+ hiddenThrough: main.hiddenThrough ?? 0, inboundThrough: main.inboundThrough ?? 0, capture: main.capture ?? null,
+ selectedLane: main.selectedLane ?? null, lanePage: main.lanePage ?? 0, agentStatus: main.agentStatus ?? false, rosterPage: main.rosterPage ?? 0,
+ })
+ agents = main.agents ?? agents
+ }
+ }
+ if (isLanes) {
+ const lanes = (await $.state.get(OLD_LANES)).value
+ const hiddenLanes = (await $.state.get(OLD_HIDDEN_LANES)).value
+ if (lanes === undefined && hiddenLanes === undefined) return
+ const fitted = fitSaved(lanes ?? {}, hiddenLanes ?? {})
+ if (fitted.isCut) {
+ const dropped = fitted.dropped === 0 ? '' : ` and ${count(fitted.dropped, 'lane')} whose newest message was oldest`
+ diagnose($, `high-command: ${event}: the agent lanes of 1.2.2 pass the space of one saved value, so High Command dropped their Hide marks${dropped}`)
+ }
+ edit(inbox, { lanes: fitted.lanes, hiddenLanes: fitted.hiddenLanes })
+ }
+}
+
+/** The `changes` count that the mirror last wrote. */
+let mirrored = -1
+/** Mirror writes issued, and the newest one that answered. */
+let writes = 0
+let answered = 0
+/** The lane value the mirror wrote last; undefined once Claude Code resets `$.state` or a lane write may be stale. */
+let laneMirror: { session: string; epoch: number; lanes: Record; hiddenLanes: Record } | undefined
+
+/**
+ * Writes the current inbox to `$.state`, each value whole: the main view, and the lanes when they changed. Each write
+ * holds the data of the moment it leaves, so the last one wins with current data. A write that answers after a newer
+ * one may have landed last, so it writes the data again.
+ */
+async function publish($: EngineInterface): Promise {
+ if (!isAdopted || changes === mirrored) return
+ for (;;) {
+ mirrored = changes
+ const session = current ?? ''
+ const inbox = inboxNow()
+ writes += 1
+ const write = writes
+ const sent = laneMirror
+ // A failed lane write is reported once the main view is written, so it never keeps a main message from the copy.
+ let laneFailure: { error: unknown } | undefined
+ if (sent === undefined || sent.session !== session || sent.epoch !== inbox.epoch || sent.lanes !== inbox.lanes || sent.hiddenLanes !== inbox.hiddenLanes) {
+ const lanes = { session, epoch: inbox.epoch, lanes: inbox.lanes, hiddenLanes: inbox.hiddenLanes }
+ laneMirror = lanes
+ try {
+ await $.state.set(LANE_VIEW, lanes)
+ } catch (error) {
+ laneMirror = undefined
+ laneFailure = { error }
}
}
+ const main: HighCommandView = {
+ session, epoch: inbox.epoch, revision: write, entries: inbox.entries, peers: inbox.peers, readThrough: inbox.readThrough,
+ hiddenThrough: inbox.hiddenThrough, inboundThrough: inbox.inboundThrough, capture: inbox.capture, openThread: inbox.openThread,
+ rosterPage: inbox.rosterPage, selectedLane: inbox.selectedLane, lanePage: inbox.lanePage, agentStatus: inbox.agentStatus, agents,
+ }
+ await $.state.set(VIEW, main)
+ if (write > answered) {
+ answered = write
+ if (laneFailure !== undefined) throw laneFailure.error
+ return
+ }
+ laneMirror = undefined
+ }
+}
+
+/** Writes the mirror after a change; a failure is diagnosed under `event`. */
+async function mirror($: EngineInterface, event: string): Promise {
+ try {
+ await publish($)
+ } catch (error) {
+ diagnose($, `high-command: ${event}: could not update the view: ${causeOf(error)}`)
+ }
+}
+
+/** The `changes` count that the status line last showed. */
+let pinned = -1
+
+/**
+ * Pins the unread count as this plugin's status line, which no other mod's band can hide, or clears the line at zero,
+ * once per change of module data. The count comes from module memory as the call leaves, so the newest call carries
+ * the newest count. `isForced` pins whatever the line shows: a reload may drop the line. `withLanes` (agentLanesInBand)
+ * adds the unread count of all agent lanes. A failure is diagnosed under `event`.
+ */
+function syncStatus($: EngineInterface, event: string, isForced = false, withLanes = false): void {
+ if (!isForced && changes === pinned) return
+ pinned = changes
+ try {
+ const inbox = inboxNow()
+ $.ui.status(unreadText(unreadTotal(rosterOf(inbox.entries, {}, inbox.readThrough)), withLanes ? lanesUnread(inbox.lanes) : 0) || undefined)
} catch (error) {
diagnose($, `high-command: ${event}: could not update the status line: ${causeOf(error)}`)
}
}
-/** The stored entries and the roster built from them, as `/highcommand` and the pane read them. */
-async function readRoster($: EngineInterface, laneId?: string): Promise<{ list: HighCommandEntry[]; rows: RosterRow[] }> {
+/** The entries of the main inbox or of a lane, and the roster built from them, as `/highcommand` and the pane read them. */
+function rosterIn(inbox: Inbox, laneId?: string): { list: HighCommandEntry[]; rows: RosterRow[] } {
if (laneId !== undefined) {
- const lane = await readLane($, laneId)
+ const lane = laneOf(inbox, laneId)
return { list: lane.entries, rows: rosterOf(lane.entries, lane.peers, lane.readThrough) }
}
- const list = await read($, entries)
- return { list, rows: rosterOf(list, await read($, peers), await read($, readThrough)) }
+ return { list: inbox.entries, rows: rosterOf(inbox.entries, inbox.peers, inbox.readThrough) }
}
/**
- * 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`, 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.
+ * 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; 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 {
+function append(inbox: Inbox, fresh: readonly NewEntry[], at: number, laneId?: string): void {
if (laneId !== undefined) {
- await changeLane($, laneId, lane => {
+ changeLane(inbox, laneId, lane => {
const list = appendEntries(lane.entries, fresh, at)
return {
...lane, entries: list, inboundThrough: Math.max(lane.inboundThrough ?? 0, newestUnreadableSeq(list)),
@@ -199,26 +489,26 @@ async function append($: EngineInterface, fresh: readonly NewEntry[], at: number
})
return
}
- let before: readonly HighCommandEntry[] = []
- const list = await update($, entries, known => {
- before = known
- return appendEntries(known, fresh, at)
+ const before = inbox.entries
+ const list = appendEntries(before, fresh, at)
+ const open = inbox.openThread
+ edit(inbox, {
+ entries: list, inboundThrough: Math.max(inbox.inboundThrough, newestInboundSeq(list)),
+ openThread: open === null || keepsThread(open, before, list) ? open : null,
})
- await update($, openThread, open => (open === null || keepsThread(open, before, list) ? open : null))
- await update($, inboundThrough, seq => Math.max(seq, newestInboundSeq(list)))
}
-async function saveReports($: EngineInterface, reports: readonly PeerReport[], at: number, laneId?: string): Promise {
- if (laneId === undefined) await update($, peers, known => applyReports(known, reports, at))
- else await changeLane($, laneId, lane => ({ ...lane, peers: applyReports(lane.peers, reports, at) }))
+function saveReports(inbox: Inbox, reports: readonly PeerReport[], at: number, laneId?: string): void {
+ if (laneId === undefined) edit(inbox, { peers: applyReports(inbox.peers, reports, at) })
+ else changeLane(inbox, laneId, lane => ({ ...lane, peers: applyReports(lane.peers, reports, at) }))
}
/**
- * Records the entries and peer states of one queued delivery, and says in the debug log when it names no
- * sender. `event` is the hook that saw it: session.receive, or prompt.submit for a local agent's message.
- * `sender`, when given, files all of the delivery under that name, whatever names its text claims.
+ * Records the entries and peer states of one queued delivery in `inbox`, and says in the debug log when it names no
+ * sender. `event` is the hook that saw it: session.receive, or prompt.submit for a local agent's message. `sender`,
+ * when given, files all of the delivery under that name, whatever names its text claims.
*/
-async function recordDelivery($: EngineInterface, event: string, text: string, origin: SessionReceiveOrigin, at: number, laneId?: string, sender?: string): Promise {
+function recordDelivery($: EngineInterface, inbox: Inbox, event: string, text: string, origin: SessionReceiveOrigin, at: number, laneId?: string, sender?: string): void {
const parsed = parseDelivery(text, origin, sender)
if (parsed.incomplete) diagnose($, `high-command: ${event}: incomplete frame parsing; consult the transcript for original framing`)
if ([...parsed.entries, ...parsed.reports].some(item => item.peer === UNNAMED)) {
@@ -227,8 +517,8 @@ async function recordDelivery($: EngineInterface, event: string, text: string, o
if (parsed.entries.length === 0 && parsed.reports.length === 0) return
// In an agent lane, a coordinator delivery is the main conversation's own message to that agent: never unread.
const fresh = laneId !== undefined && origin.kind === 'coordinator' ? parsed.entries.map(entry => ({ ...entry, fromMain: true as const })) : parsed.entries
- if (fresh.length > 0) await append($, fresh, at, laneId)
- if (parsed.reports.length > 0) await saveReports($, parsed.reports, at, laneId)
+ if (fresh.length > 0) append(inbox, fresh, at, laneId)
+ if (parsed.reports.length > 0) saveReports(inbox, parsed.reports, at, laneId)
}
/** The local agents for a subagent message's sender label; a failure keeps the label and goes to the debug log. */
@@ -241,20 +531,24 @@ async function agentsForLabel($: EngineInterface): Promise
}
}
-/** Records the observed send attempt and its result; an attempt to an agent with no name is left out. */
-async function recordSend($: EngineInterface, to: string, text: string, sent: SessionSendResult, trace: readonly TraceEntry<'session.send'>[], at: number, laneId?: string): Promise {
- const lane = laneId === undefined ? undefined : await readLane($, laneId)
- const peer = recipientOf(to, lane?.peers ?? await read($, peers), await $.agent.list(), lane?.entries ?? await read($, entries))
+/** Records the observed send attempt and its result in the inbox of `session`; an attempt to an agent with no name is left out. */
+async function recordSend($: EngineInterface, session: string, to: string, text: string, sent: SessionSendResult, trace: readonly TraceEntry<'session.send'>[], at: number, laneId?: string): Promise {
+ const listed = await $.agent.list()
+ const inbox = inboxes.get(session)
+ if (inbox === undefined) return
+ const lane = laneId === undefined ? undefined : laneOf(inbox, laneId)
+ const peer = recipientOf(to, lane?.peers ?? inbox.peers, listed, lane?.entries ?? inbox.entries)
if (peer === undefined) return
- await append($, [{ ...outboundEntry(peer, text, sent), core: coreEvidence(trace) }], at, laneId)
+ append(inbox, [{ ...outboundEntry(peer, text, sent), core: coreEvidence(trace) }], at, laneId)
}
-/** Records the peer states a ListAgents listing names, or says in the debug log that no row gave one. */
-async function recordListing($: EngineInterface, { reports, rowCount }: ReturnType, laneId?: string): Promise {
+/** Records the peer states a ListAgents listing names in the inbox of `session`, or says in the debug log that no row gave one. */
+async function recordListing($: EngineInterface, session: string, { reports, rowCount }: ReturnType, laneId?: string): Promise {
reports.forEach(report => { report.source ??= 'formatted listing' })
if (reports.length > 0) {
const at = await $.clock.now()
- await saveReports($, reports, at, laneId)
+ const inbox = inboxes.get(session)
+ if (inbox !== undefined) saveReports(inbox, reports, at, laneId)
} else if (rowCount > 0) {
$.ui.log(
`tool.call ListAgents: no listing row gave a peer state (${count(rowCount, 'row')} read); peer states are unchanged`,
@@ -266,10 +560,9 @@ async function recordListing($: EngineInterface, { reports, rowCount }: ReturnTy
/** Opens the main inbox roster; answers why the pane is not drawn, or undefined when it is. */
async function openPane($: EngineInterface): Promise {
await refreshAgents($, 'ui.open')
- try { await ensureCapture($, await $.clock.now()) } catch (error) { diagnose($, `high-command: ui.open: could not set capture window: ${causeOf(error)}`) }
- await update($, selectedLane, () => '')
- await update($, rosterPage, () => 0)
- await update($, openThread, () => null)
+ try { ensureCapture(inboxNow(), await $.clock.now()) } catch (error) { diagnose($, `high-command: ui.open: could not set capture window: ${causeOf(error)}`) }
+ edit(inboxNow(), { selectedLane: '', rosterPage: 0, openThread: null })
+ await publish($)
const opened = await $.ui.open(PANE)
return opened.isPlaced ? undefined : `the pane waits to be drawn: ${opened.reason}`
}
@@ -295,26 +588,29 @@ function press($: EngineInterface, action: string, work: () => 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`).
+ * Marks `peer`, or every peer when none is given, read through its newest inbound entry that the drawing `drawn`
+ * showed, in the lane `laneId` or the main inbox of `inbox`.
*/
-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', false, withLanes)
+function markPeersRead(inbox: Inbox, drawn: Drawn, peer?: string, laneId?: string): void {
+ if (laneId !== undefined) changeLane(inbox, laneId, lane => ({ ...lane, readThrough: markRead(lane.readThrough, seenOf(lane.entries, drawn.through), peer) }))
+ else edit(inbox, { readThrough: markRead(inbox.readThrough, seenOf(inbox.entries, drawn.through), peer) })
}
-/** `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)
- })
+/**
+ * What a press changes, for the status line: read marks (`marks`), agent lane content (`lane`), which can evict lane
+ * messages (fitLanes), or only the view (`view`).
+ */
+type PressKind = 'marks' | 'lane' | 'view'
+
+/**
+ * Refreshes the status line after a press of `kind`, then writes the mirror. Read marks count in the main inbox, and in
+ * an agent lane only when the line counts agent lanes (`withLanes`); a lane change counts only then too. A failed
+ * mirror write rejects, so the press shows it.
+ */
+async function afterPress($: EngineInterface, kind: PressKind, laneId: string | undefined, withLanes: boolean): Promise {
+ const counts = kind === 'marks' ? laneId === undefined || withLanes : kind === 'lane' && laneId !== undefined && withLanes
+ if (counts) syncStatus($, 'ui.press', false, withLanes)
+ await publish($)
}
/**
@@ -335,101 +631,117 @@ async function focusOn($: EngineInterface, key: string | undefined): Promise {
- const { rows } = await readRoster($, laneId)
+function showPeerRow(inbox: Inbox, laneId: string | undefined, peer: string): string | undefined {
+ const { rows } = rosterIn(inbox, laneId)
const page = rosterPageOf(rows, peer)
if (page === undefined) return undefined
- if (laneId === undefined) await update($, rosterPage, () => page)
- else await changeLane($, laneId, lane => ({ ...lane, rosterPage: page }))
+ if (laneId === undefined) edit(inbox, { rosterPage: page })
+ else changeLane(inbox, laneId, lane => ({ ...lane, rosterPage: page }))
return peerKeys(rows.map(row => row.peer)).get(peer)
}
/** Whether the lane page shown draws the agent `id`'s lane Button. */
-async function isLaneShown($: EngineInterface, id: string): Promise {
- const all = Object.values(await read($, agents))
- return laneIdsShown(laneIdsOf(Object.keys(await read($, lanes)), all), await read($, lanePage), all).includes(id)
+function isLaneShown(inbox: Inbox, id: string): boolean {
+ const all = Object.values(agents)
+ return laneIdsShown(laneIdsOf(Object.keys(inbox.lanes), all), inbox.lanePage, all).includes(id)
}
/** Where the ring goes once the lane `id` ('' for the main inbox) shows: Back of its open thread, else `key`. */
-async function laneFocus($: EngineInterface, id: string, key: string): Promise {
- const open = id === '' ? await read($, openThread) : (await readLane($, id)).openThread
+function laneFocus(inbox: Inbox, id: string, key: string): string {
+ const open = id === '' ? inbox.openThread : laneOf(inbox, id).openThread
return open === null ? key : 'pane:back'
}
-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)
- }
+/**
+ * The pane's buttons. Each acts on the inbox its drawing showed (`drawn`), and does nothing once another conversation
+ * or another inbox is current (pressed).
+ */
+function paneActions($: EngineInterface, withLanes: boolean, drawn: Drawn, laneId?: string): PaneActions {
+ /**
+ * Runs `change` on the drawn inbox, then refreshes the status line and writes the mirror (afterPress); nothing once
+ * the inbox changed. `change` answers the key that the focus ring moves to, if any.
+ */
+ const act = (action: string, kind: PressKind, change: (inbox: Inbox) => string | undefined | void) => press($, action, async () => {
+ const inbox = pressed(drawn)
+ if (inbox === undefined) return
+ const key = change(inbox)
+ await afterPress($, kind, laneId, withLanes)
+ if (typeof key === 'string') await focusOn($, key)
+ })
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, withLanes)
- await focusOn($, 'pane:back')
+ openThread: peer => act('open thread', 'marks', inbox => {
+ const marks = laneId === undefined ? inbox.readThrough : laneOf(inbox, laneId).readThrough
+ changeThread(inbox, laneId, () => ({ peer, since: markOf(marks, peer), from: drawn.through }))
+ markPeersRead(inbox, drawn, peer, laneId)
+ return 'pane:back'
}),
// Up one level, to the roster page that draws the peer that was open, with the ring on its row.
- back: () => press($, 'back', thenSync(async () => {
+ back: () => act('back', 'lane', inbox => {
const closed: { peer?: string } = {}
- await changeThread($, laneId, open => {
+ changeThread(inbox, laneId, open => {
closed.peer = open?.peer
return null
})
- if (closed.peer !== undefined) await focusOn($, await showPeerRow($, laneId, closed.peer))
- })),
- 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 }))),
+ return closed.peer === undefined ? undefined : showPeerRow(inbox, laneId, closed.peer)
+ }),
+ markRead: peer => act('mark read', 'marks', inbox => markPeersRead(inbox, drawn, peer, laneId)),
+ readAll: () => act('read all', 'marks', inbox => markPeersRead(inbox, drawn, undefined, laneId)),
+ page: page => act('page', 'lane', inbox => changeThread(inbox, laneId, open => open === null ? null : { ...open, page })),
// Opening the Identity level puts the ring on its Back; closing it puts the ring back on Show identity.
- identity: identityPage => press($, 'identity', thenSync(async () => {
+ identity: identityPage => act('identity', 'lane', inbox => {
const before: { wasOpen?: boolean } = {}
- await changeThread($, laneId, open => {
+ changeThread(inbox, laneId, open => {
before.wasOpen = open?.identityPage !== undefined
return open === null ? null : { ...open, identityPage }
})
- if (identityPage === undefined) await focusOn($, 'pane:identity')
- else if (before.wasOpen === false) await focusOn($, 'pane:back')
- })),
- reason: reasonSeq => press($, 'send details', thenSync(() => changeThread($, laneId, open => open === null ? null : { ...open, reasonSeq: open.reasonSeq === reasonSeq ? undefined : reasonSeq }))),
- lane: id => press($, 'select lane', async () => {
- await update($, selectedLane, () => id)
- await focusOn($, await laneFocus($, id, id === '' ? 'lane:main' : `lane:${id}`))
+ if (identityPage === undefined) return 'pane:identity'
+ return before.wasOpen === false ? 'pane:back' : undefined
+ }),
+ reason: reasonSeq => act('send details', 'lane', inbox => changeThread(inbox, laneId, open => open === null ? null : { ...open, reasonSeq: open.reasonSeq === reasonSeq ? undefined : reasonSeq })),
+ lane: id => act('select lane', 'view', inbox => {
+ edit(inbox, { selectedLane: id })
+ return laneFocus(inbox, id, id === '' ? 'lane:main' : `lane:${id}`)
}),
// Up one level from an agent lane, to the main inbox, with the ring on the Button of the lane that was open.
- leaveLane: () => press($, 'back', async () => {
- await update($, selectedLane, () => '')
- const key = laneId !== undefined && await isLaneShown($, laneId) ? `lane:${laneId}` : 'lane:main'
- await focusOn($, await laneFocus($, '', key))
+ leaveLane: () => act('back', 'view', inbox => {
+ edit(inbox, { selectedLane: '' })
+ return laneFocus(inbox, '', laneId !== undefined && isLaneShown(inbox, laneId) ? `lane:${laneId}` : 'lane:main')
}),
- 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)),
- agentStatus: () => press($, 'agent status', () => update($, agentStatus, open => !open)),
+ rosterPage: page => act('peer page', 'lane', inbox => {
+ if (laneId === undefined) edit(inbox, { rosterPage: page })
+ else changeLane(inbox, laneId, lane => ({ ...lane, rosterPage: page }))
+ }),
+ lanePage: page => act('agent page', 'view', inbox => edit(inbox, { lanePage: page })),
+ agentStatus: () => act('agent status', 'view', inbox => edit(inbox, { agentStatus: !inbox.agentStatus })),
}
}
-/** The band's buttons; with agentLanesInBand (`withLanes`), Read all and Hide also cover agent lanes. */
-function bandActions($: EngineInterface, withLanes: boolean): BandActions {
+/**
+ * The band's buttons; with agentLanesInBand (`withLanes`), Read all and Hide also cover agent lanes. Read all and Hide
+ * cover what the drawing `drawn` showed, and do nothing once another conversation or another inbox is current.
+ */
+function bandActions($: EngineInterface, withLanes: boolean, drawn: Drawn): BandActions {
return {
open: () =>
press($, 'the pane did not open', async () => {
const problem = await openPane($)
if (problem !== undefined) $.ui.toast(`ui.press: ${problem}`)
}),
- readAll: () => readAll($, withLanes),
+ readAll: () =>
+ press($, 'read all', async () => {
+ const inbox = pressed(drawn)
+ if (inbox === undefined) return
+ if (withLanes) changeLanes(inbox, (lane, id) => ({ ...lane, readThrough: markRead(lane.readThrough, seenOf(lane.entries, drawn.lanes.get(id) ?? 0)) }))
+ markPeersRead(inbox, drawn)
+ await afterPress($, 'marks', undefined, 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))
+ const inbox = pressed(drawn)
+ if (inbox === undefined) return
+ if (withLanes) setLanes(inbox, inbox.lanes, hideLanes(inbox.hiddenLanes, drawn.lanes))
+ edit(inbox, { hiddenThrough: Math.max(inbox.hiddenThrough, drawn.through) })
+ await publish($)
}),
}
}
@@ -444,32 +756,79 @@ export const register: Register = (on, options) => {
} catch (error) {
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, withLanes)
+ if (!(await ensureReady($, 'session.start'))) return next(e)
+ try { ensureCapture(inboxNow(), await $.clock.now()) } catch (error) { diagnose($, `high-command: session.start: could not set capture window: ${causeOf(error)}`) }
+ syncStatus($, 'session.start', true, withLanes)
+ await mirror($, 'session.start')
+ return next(e)
+ })
+
+ // At /clear, /resume, and /branch, Claude Code runs session.end, then resets $.state, then runs classic.SessionStart.
+ // The inbox stays in module memory, so the hook only remembers the session that ends: the parent of a branch. Any
+ // other reason is an exit with no switch after it, so the hook does nothing then.
+ on('session.end', async ($, e, next) => {
+ if (e.reason !== 'clear' && e.reason !== 'resume') return next(e)
+ await ensureReady($, 'session.end')
+ lastEnded = e.sessionId
+ laneMirror = undefined
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', false, withLanes)
+ // Any source sets the current session from `e.session_id`: here `$.session.id()` still answers the session that
+ // ended. A branch copies its parent's inbox (the session that ended last), a resume takes its own inbox back when
+ // this process kept it, and a clear, or a resume or branch with nothing kept, starts a new empty inbox. Then the hook
+ // writes the mirror again, so the last mirror write after the switch holds the new conversation's inbox.
+ on('classic.SessionStart', async ($, e, next) => {
+ const isSwitch = e.source === 'fork' || e.source === 'resume' || e.source === 'clear'
+ if (!(await ensureReady($, 'classic.SessionStart'))) {
+ if (!isSwitch) return next(e)
+ // The copy that could not be read is the inbox of the conversation that ended, so the next one may write its own.
+ ready = Promise.resolve(true)
+ isAdopted = true
+ }
+ const id = e.session_id
+ const parent = e.source === 'fork' && lastEnded !== undefined ? inboxes.get(lastEnded) : undefined
+ const known = e.source === 'resume' ? inboxes.get(id) : undefined
+ if (isSwitch) lastEnded = undefined
+ current = id
+ laneMirror = undefined
+ const inbox = keep(id, parent !== undefined ? branchOf(parent) : known !== undefined ? startOver(known) : isSwitch ? emptyInbox() : inboxOf(id))
+ // A branch counts what it copied, in the same moment: the parent can record more while the clock answers.
+ const copied = parent === undefined ? undefined : {
+ window: inbox.capture, messages: inbox.entries.length + Object.values(inbox.lanes).reduce((sum, lane) => sum + lane.entries.length, 0),
+ }
+ try {
+ const now = await $.clock.now()
+ if (copied !== undefined) {
+ const window = copied.window ?? { at: now, reset: true }
+ edit(inbox, { capture: { ...window, branch: { at: now, messages: copied.messages, depth: (window.branch?.depth ?? 0) + 1 } } })
+ } else if (isSwitch && known === undefined) edit(inbox, { capture: { at: inbox.capture?.at ?? now, reset: true } })
+ else ensureCapture(inbox, now)
+ } catch (error) {
+ diagnose($, `high-command: classic.SessionStart: could not set capture window: ${causeOf(error)}`)
+ }
+ syncStatus($, 'classic.SessionStart', parent !== undefined || known !== undefined, withLanes)
+ await mirror($, 'classic.SessionStart')
return next(e)
})
on('session.receive', async ($, e, next) => {
if (!MESSAGE_ORIGINS.has(e.origin.kind)) return next(e)
+ if (!(await ensureReady($, 'session.receive'))) return next(e)
+ // The message belongs to the conversation that is current as it arrives, even when a switch comes before it records.
+ const session = current ?? ''
let at: number | undefined
- try { at = await $.clock.now(); await ensureCapture($, at) } catch (error) { diagnose($, `high-command: session.receive: could not observe delivery time: ${causeOf(error)}`) }
+ try { at = await $.clock.now(); ensureCapture(inboxOf(session), at) } catch (error) { diagnose($, `high-command: session.receive: could not observe delivery time: ${causeOf(error)}`) }
const queued = await next(e)
if (queued.consumed !== undefined) return queued
+ const inbox = inboxes.get(session)
try {
- if (at !== undefined) {
- await recordDelivery($, 'session.receive', queued.text, e.origin, at, e.agentId)
- }
+ if (at !== undefined && inbox !== undefined) recordDelivery($, inbox, 'session.receive', queued.text, e.origin, at, e.agentId)
} catch (error) {
diagnose($, `high-command: session.receive: could not record a delivery (${e.origin.kind}): ${causeOf(error)}`)
}
- if (e.agentId === undefined || withLanes) await syncStatus($, 'session.receive', false, withLanes)
+ if (e.agentId === undefined || withLanes) syncStatus($, 'session.receive', false, withLanes)
+ await mirror($, 'session.receive')
return queued
})
@@ -482,8 +841,10 @@ export const register: Register = (on, options) => {
// the list is read before the mods beneath run, while an agent that ends meanwhile is still listed.
on('prompt.submit', { origin: { kind: 'peer' } }, async ($, e, next) => {
if (!isAgentMessage(e.text)) return next(e)
+ if (!(await ensureReady($, 'prompt.submit'))) return next(e)
+ const session = current ?? ''
let at: number | undefined
- try { at = await $.clock.now(); await ensureCapture($, at) } catch (error) { diagnose($, `high-command: prompt.submit: could not observe delivery time: ${causeOf(error)}`) }
+ try { at = await $.clock.now(); ensureCapture(inboxOf(session), at) } catch (error) { diagnose($, `high-command: prompt.submit: could not observe delivery time: ${causeOf(error)}`) }
const named = agentSenderOf(e.text, e.origin)
const listed = named === undefined ? undefined : agentsForLabel($)
const entered = await next(e)
@@ -499,32 +860,39 @@ export const register: Register = (on, options) => {
}
const plugin = named === undefined || listed === undefined ? undefined : spawnerOf(await listed, named)
const origin = plugin === undefined ? e.origin : { kind: 'peer' as const, plugin }
- await recordDelivery($, 'prompt.submit', entered.text, origin, at, undefined, sender)
+ const inbox = inboxes.get(session)
+ if (inbox !== undefined) recordDelivery($, inbox, 'prompt.submit', entered.text, origin, at, undefined, sender)
}
} catch (error) {
diagnose($, `high-command: prompt.submit: could not record a delivery (${e.origin.kind}): ${causeOf(error)}`)
}
- await syncStatus($, 'prompt.submit', false, withLanes)
+ syncStatus($, 'prompt.submit', false, withLanes)
+ await mirror($, 'prompt.submit')
return entered
})
on('session.send', async ($, e, next) => {
if (e.origin.kind !== 'model') return next(e)
+ if (!(await ensureReady($, 'session.send'))) return next(e)
+ const session = current ?? ''
let at: number | undefined
- try { at = await $.clock.now(); await ensureCapture($, at) } catch (error) { diagnose($, `high-command: session.send: could not observe send time: ${causeOf(error)}`) }
+ try { at = await $.clock.now(); ensureCapture(inboxOf(session), at) } catch (error) { diagnose($, `high-command: session.send: could not observe send time: ${causeOf(error)}`) }
const sent = await next(e)
try {
if (at !== undefined) {
- await recordSend($, e.to, e.text, sent, next.trace, at, e.agentId)
+ await recordSend($, session, e.to, e.text, sent, next.trace, at, e.agentId)
}
} catch (error) {
diagnose($, `high-command: session.send: could not record a message to ${sanitize(e.to)}: ${causeOf(error)}`)
}
- if (e.agentId === undefined || withLanes) await syncStatus($, 'session.send', false, withLanes)
+ if (e.agentId === undefined || withLanes) syncStatus($, 'session.send', false, withLanes)
+ await mirror($, 'session.send')
return sent
})
on('tool.call', { tool: 'ListAgents' }, async ($, e, next) => {
+ if (!(await ensureReady($, 'tool.call ListAgents'))) return next(e)
+ const session = current ?? ''
const out = await next(e)
const result: unknown = out.isError === true ? undefined : out.result
const structured = parseStructuredListing(result)
@@ -532,56 +900,60 @@ export const register: Register = (on, options) => {
if (structured === undefined && typeof listing !== 'string') return out
try {
if (structured !== undefined) {
- await recordListing($, structured, e.agentId)
- } else if (typeof listing === 'string') await recordListing($, parseListing(listing), e.agentId)
+ await recordListing($, session, structured, e.agentId)
+ } else if (typeof listing === 'string') await recordListing($, session, parseListing(listing), e.agentId)
} 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)
+ if (e.agentId !== undefined && withLanes) syncStatus($, 'tool.call ListAgents', false, true)
+ await mirror($, 'tool.call ListAgents')
return out
})
on('agent.spawn', async ($, e, next) => {
const result = await next(e)
+ if (!(await ensureReady($, 'agent.spawn'))) return result
await refreshAgents($, 'agent.spawn')
+ await mirror($, 'agent.spawn')
return result
})
on('turn.complete', async ($, e, next) => {
const result = await next(e)
- if (e.agentId !== undefined) {
+ if (e.agentId !== undefined && await ensureReady($, 'turn.complete')) {
await refreshAgents($, 'turn.complete')
try {
const at = await $.clock.now()
- const id = e.agentId
- await update($, agents, known => {
- const observed = new Map(Object.entries(known))
- const agent = observed.get(id)
- const word = e.reason === 'answer' ? 'turn answered' : `turn ${e.reason}`
- if ((agent?.lastEvent?.at ?? -Infinity) <= at) observed.set(id, {
- ...agent, id, status: agent?.status ?? word, at: agent?.at ?? at,
- source: agent?.source ?? 'lifecycle observation', lastEvent: { word, at },
- })
- return Object.fromEntries(observed)
+ const observed = new Map(Object.entries(agents))
+ const agent = observed.get(e.agentId)
+ const word = e.reason === 'answer' ? 'turn answered' : `turn ${e.reason}`
+ if ((agent?.lastEvent?.at ?? -Infinity) <= at) observed.set(e.agentId, {
+ ...agent, id: e.agentId, status: agent?.status ?? word, at: agent?.at ?? at,
+ source: agent?.source ?? 'lifecycle observation', lastEvent: { word, at },
})
+ agents = Object.fromEntries(observed)
+ changes += 1
} catch (error) { diagnose($, `high-command: turn.complete: could not record lifecycle observation: ${causeOf(error)}`) }
+ await mirror($, 'turn.complete')
}
return result
})
on('command.run', { command: 'highcommand' }, async ($, e) => {
const argument = e.args.trim()
+ if (!(await ensureReady($, 'command.run'))) return { text: 'command.run: could not take back the inbox from $.state' }
if (argument === 'open') return openFromCommand($)
if (argument !== '') return { text: USAGE }
try {
await refreshAgents($, 'command.run')
const now = await $.clock.now()
- await ensureCapture($, now)
- const { list, rows } = await readRoster($)
- return { text: summaryText(rows, list.length, now, {
- evicted: list.at(-1)?.evictedTotal, capture: await read($, capture), agents: Object.values(await read($, agents)), lanes: await read($, lanes),
- }) }
+ const inbox = inboxNow()
+ ensureCapture(inbox, now)
+ const { list, rows } = rosterIn(inbox)
+ const text = summaryText(rows, list.length, now, { evicted: list.at(-1)?.evictedTotal, capture: inbox.capture, agents: Object.values(agents), lanes: inbox.lanes })
+ await mirror($, 'command.run')
+ return { text }
} catch (error) {
return { text: `command.run: could not build the summary: ${causeOf(error)}` }
}
@@ -590,18 +962,22 @@ export const register: Register = (on, options) => {
on('ui.render', { component: 'Pane', requestId: 'high-command' }, async ($, e) => {
const ui = $.ui.resolve(e)
try {
- const chosen = await read($, selectedLane)
+ if (!(await ensureReady($, 'ui.render'))) throw new Error('could not take back the inbox from $.state')
+ await read($, view)
+ const inbox = inboxNow()
+ const chosen = inbox.selectedLane
const laneId = chosen === '' ? undefined : chosen ?? e.props.view.agentId
- const { list, rows } = await readRoster($, laneId)
- const thread = threadOf(list, rows, laneId === undefined ? await read($, openThread) : (await readLane($, laneId)).openThread)
+ const { list, rows } = rosterIn(inbox, laneId)
+ const lane = laneId === undefined ? undefined : laneOf(inbox, laneId)
+ const thread = threadOf(list, rows, lane === undefined ? inbox.openThread : lane.openThread)
+ const drawn = drawnOf(inbox, list)
+ const laneIds = Object.keys(inbox.lanes)
+ const laneUnread = new Map(Object.entries(inbox.lanes).map(([id, known]) => [id, unreadTotal(rosterOf(known.entries, {}, known.readThrough))]))
const now = await $.clock.now()
- const all = await read($, lanes)
- return paneTree(ui, { rows, now, thread, laneId, rosterPage: laneId === undefined ? await read($, rosterPage) : (await readLane($, laneId)).rosterPage, lanePage: await read($, lanePage),
- 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,
- columns: e.props.bodyColumns, statusOpen: await read($, agentStatus),
- }, paneActions($, withLanes, laneId))
+ return paneTree(ui, { rows, now, thread, laneId, rosterPage: lane === undefined ? inbox.rosterPage : lane.rosterPage, lanePage: inbox.lanePage,
+ laneIds, agents: Object.values(agents), laneUnread, capture: inbox.capture, evicted: list.at(-1)?.evictedTotal ?? 0,
+ columns: e.props.bodyColumns, statusOpen: inbox.agentStatus,
+ }, paneActions($, withLanes, drawn, laneId))
} catch (error) {
const { Text } = ui
return {`high-command: ui.render (Pane): ${causeOf(error)}`}
@@ -611,20 +987,23 @@ export const register: Register = (on, options) => {
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
if (e.props.hasSurvey) return next(e)
const below = await next(e)
- const list = await read($, entries)
+ if (!(await ensureReady($, 'ui.render'))) return below
+ await read($, view)
+ const inbox = inboxNow()
+ const list = inbox.entries
// A Hide holds while the person had seen every inbound entry: those in the window and those recorded before.
- const isHidden = (await read($, hiddenThrough)) >= Math.max(await read($, inboundThrough), newestInboundSeq(list))
+ const isHidden = inbox.hiddenThrough >= Math.max(inbox.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 all = withLanes ? inbox.lanes : {}
+ if (isHidden && (!withLanes || lanesSeen(all, inbox.hiddenLanes))) return below
+ const rows = rosterOf(list, {}, inbox.readThrough)
const waiting = rows.filter(row => row.unread > 0)
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 drops the key hint, then
// keeps only the counts and Open, then yields.
const ui = $.ui.resolve(e)
- const actions = bandActions($, withLanes)
+ const actions = bandActions($, withLanes, drawnOf(inbox, list))
const labels = peerLabels(rows.map(row => row.peer))
const columns = e.props.bodyColumns
const bands = [
diff --git a/hooks/text.ts b/hooks/text.ts
index f742281..ce61522 100644
--- a/hooks/text.ts
+++ b/hooks/text.ts
@@ -1,4 +1,4 @@
-import type { HighCommandState, HighCommandTrust } from '../types'
+import type { HighCommandCapture, HighCommandState, HighCommandTrust } from '../types'
import { LIMITS } from './limits'
const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
@@ -341,9 +341,23 @@ export function peerKeys(peers: readonly string[]): Map {
return keys
}
-/** A conversation-local marker, preserved across module reloads. */
-export function captureLabel(capture: { at: number; reset: boolean }, now: number): string {
- return `Capture window: ${clockLabel(capture.at, now)} · current conversation only. ${capture.reset ? 'Previous High Command history was not restored.' : 'Earlier history is not included.'}`
+/** How a branch's capture window names the messages it took from the parent: `Branched 13:04 · 12 messages from the parent conversation`. */
+export function branchLabel(branch: { at: number; messages: number }, now: number): string {
+ return `Branched ${clockLabel(branch.at, now)} · ${count(branch.messages, 'message')} from the parent conversation`
+}
+
+/**
+ * The conversations a branch's capture window covers: its parent, and one earlier conversation for each /branch step
+ * before that, as in `this conversation, the one it branched from, and 1 earlier conversation`.
+ */
+function branchScope(depth: number): string {
+ return depth <= 1 ? 'this conversation and the one it branched from' : `this conversation, the one it branched from, and ${count(depth - 1, 'earlier conversation')}`
+}
+
+/** A conversation-local marker, preserved across module reloads; a branch's covers the conversations it branched from too. */
+export function captureLabel(capture: HighCommandCapture, now: number): string {
+ const scope = capture.branch === undefined ? 'current conversation only.' : `${branchScope(capture.branch.depth)}. ${branchLabel(capture.branch, now)}.`
+ return `Capture window: ${clockLabel(capture.at, now)} · ${scope} ${capture.reset ? 'Previous High Command history was not restored.' : 'Earlier history is not included.'}`
}
/** Fixed-width identity pages never separate the halves of a surrogate pair. */
diff --git a/hooks/views.tsx b/hooks/views.tsx
index a5b7872..535bece 100644
--- a/hooks/views.tsx
+++ b/hooks/views.tsx
@@ -6,6 +6,7 @@ import { LIMITS } from './limits'
import {
BAND_KEY_HINT,
PANE_KEY_HINT,
+ branchLabel,
cellWidth,
clockLabel,
count,
@@ -284,14 +285,16 @@ function locationRow(
)
}
-/** The dim context line: when capture began, this conversation only, the order; after a reset, that history is gone. */
+/**
+ * The dim context line: when capture began, this conversation only, the order; after a reset, that history is gone. A
+ * branch's line starts with the branch time and the messages it took from the parent conversation (branchLabel).
+ */
function contextLine(Text: HighCommandElements['Text'], model: PaneModel): RenderElement | false {
const capture = model.capture
- return capture !== undefined && capture !== null && (
-
- {`Since ${clockLabel(capture.at, model.now)} · this conversation only · observation order${capture.reset ? ' · earlier history not restored' : ''}`}
-
- )
+ if (capture === undefined || capture === null) return false
+ const since = clockLabel(capture.at, model.now)
+ const scope = capture.branch === undefined ? `Since ${since} · this conversation only` : `${branchLabel(capture.branch, model.now)} · since ${since}`
+ return {`${scope} · observation order${capture.reset ? ' · earlier history not restored' : ''}`}
}
/** The notice of messages dropped for good, at full strength: it says whether the list is complete. */
diff --git a/types/index.d.ts b/types/index.d.ts
index 1fe67b1..4d37a7f 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -95,36 +95,73 @@ export type HighCommandAgent = {
lastEvent?: { word: string; at: number }
}
-export type HighCommandCapture = { at: number; reset: boolean }
+/**
+ * The capture window: when it began, and whether it began after a reset that dropped earlier history. A branch keeps
+ * its parent's window, and `branch` says when it branched and how many kept messages it took from the parent. `depth`
+ * counts the /branch steps from the conversation where the window began: 1 for its branch, 2 for a branch of that.
+ */
+export type HighCommandCapture = { at: number; reset: boolean; branch?: { at: number; messages: number; depth: number } }
+
+/**
+ * The copy of one conversation's inbox that `$.state` holds: what a reload of the mod takes back. The hooks module keeps
+ * the inbox itself in memory, and the pane and the band draw from there. `session` and `epoch` name the inbox it copies.
+ */
+export type HighCommandView = {
+ session: string
+ /** The number of the inbox in the module that wrote it; a branch copy and a fresh inbox each take a new one. */
+ epoch: number
+ /** Grows with each write: Claude Code draws the pane and the band again when it changes. */
+ revision: number
+ entries: HighCommandEntry[]
+ peers: Record
+ readThrough: Record
+ /** The newest seq that a press of Hide had seen; 0 before any press. It only grows. */
+ hiddenThrough: number
+ /** The seq of the newest inbound entry recorded; it only grows and outlives the entry's eviction. */
+ inboundThrough: number
+ capture: HighCommandCapture | null
+ openThread: HighCommandThread | null
+ rosterPage: number
+ selectedLane: string | null
+ lanePage: number
+ /** Whether the pane's Agent status block is open. */
+ agentStatus: boolean
+ /** The local agents of the process, which every conversation shows. */
+ agents: Record
+}
+
+/** The copy of one conversation's agent lanes, apart from HighCommandView: each saved value has a size limit. */
+export type HighCommandLaneView = {
+ session: string
+ epoch: number
+ lanes: Record
+ /**
+ * Per agent lane, the newest seq that a press of Hide had seen while agentLanesInBand is on; a mark only grows.
+ */
+ hiddenLanes: Record
+}
declare module 'claude-code' {
interface PluginState {
'high-command': {
- lanes: Record
- selectedLane: string | null
- lanePage: number
- /** Whether the pane's Agent status block is open. */
- agentStatus: boolean
- rosterPage: number
- agents: Record
- capture: HighCommandCapture | null
+ view: HighCommandView
+ laneView: HighCommandLaneView
+ // The values of 1.2.2, which kept the inbox in `$.state` itself. After an update, when neither copy above holds
+ // anything, the module reads them once to take the inbox over, and never writes them.
entries: HighCommandEntry[]
peers: Record
readThrough: Record
openThread: HighCommandThread | null
- /** The newest seq that a press of Hide had seen; 0 before any press. It only grows. */
hiddenThrough: number
- /** The seq of the newest inbound entry recorded; it only grows and outlives the entry's eviction. */
inboundThrough: number
- /**
- * Per agent lane, the newest seq that a press of Hide had seen while agentLanesInBand is on; a mark only grows.
- */
+ lanes: Record
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 }
+ capture: HighCommandCapture | null
+ selectedLane: string | null
+ lanePage: number
+ agentStatus: boolean
+ rosterPage: number
+ agents: Record
}
}
}