From 9b4f5050574d239c52b1b6370d82a696750392d9 Mon Sep 17 00:00:00 2001 From: dot-agi Date: Sun, 25 Oct 2026 20:35:48 +0530 Subject: [PATCH] Release 1.3.1 --- .claude-plugin/plugin.json | 2 +- README.md | 27 ++-- docs/usage.md | 17 +-- hooks/limits.ts | 6 + hooks/register.tsx | 253 +++++++++++++++++++++++++++++++++---- 5 files changed, 260 insertions(+), 45 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d3bca12..6ebba9b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "high-command", - "version": "1.3.0", + "version": "1.3.1", "description": "See the messages your teammates, named subagents, and other Claude Code sessions send you, grouped by peer with unread counts: a pane, a band above the prompt, and /highcommand.", "author": { "name": "dot-agi" }, "license": "MIT", diff --git a/README.md b/README.md index 31d43dc..b4b65aa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Claude Code 2.1.288+ mod License: MIT - Version: 1.3.0 + Version: 1.3.1

One inbox for agent-team teammates, named subagents, and other Claude Code sessions.

@@ -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 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). +- **Earlier history**: messages from before this Claude Code process started, or from before `/clear`. `/branch` keeps the parent conversation's inbox, except with managed settings or a Team or Enterprise sign-in. `/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,9 +199,10 @@ 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. -- **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. +- **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. With managed settings or a Team or Enterprise sign-in, `/branch` starts empty instead (see **Managed settings and Team or Enterprise sign-ins** below). +- **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. With managed settings or a Team or Enterprise sign-in, a reload that no slash command and no `/config` change causes, before High Command takes a `/resume` and writes its copy, drops the inbox that it would bring back (see **Managed settings and Team or Enterprise sign-ins** below). +- **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. With managed settings or a Team or Enterprise sign-in, a new process does not say this (see the next item). +- **Managed settings and Team or Enterprise sign-ins**: with managed settings on the machine, or with a Team or Enterprise sign-in, Claude Code does not pass `classic.SessionStart` to installed mods. High Command then reads the session ID at once after a `/clear`, `/branch`, or in-app `/resume`, then every 500 ms for up to 5 minutes, and at each of its events, such as a message, a send, the end of a turn, `/highcommand`, a press, or a drawing of the band or the pane. It also reads the session ID before each slash command, such as `/reload-plugins` or `/config`, and before each change in `/config`. At the first read that names the new conversation, it takes the switch. Before a slash command or a `/config` change goes on, High Command takes the switch this way and waits until its copy of the inbox is written, also when a drawing of the band or the pane took the switch first. So a reload that the command or the change causes keeps the inbox. Only a reload of the mod that no slash command and no `/config` change causes, such as one after an edit of a settings file, can come before that copy is written. That reload drops the inbox that `/resume` would bring back. `/clear` starts an empty inbox, and `/resume` brings back an inbox that this process kept, as described above. There High Command cannot tell `/branch` from a `/resume` of a conversation from another process, so `/branch` starts empty, with no agent lanes, and the pane and `/highcommand` say that earlier history was not restored. The parent conversation keeps its own inbox, and a `/resume` of it brings that inbox back. A new process, such as `claude --resume`, `--fork-session`, a `/fork` copy, or a conversation in the background, starts empty too, but there the pane and `/highcommand` do not 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. @@ -209,31 +210,33 @@ The list below holds the limits you can hit in daily use. [docs/usage.md](docs/u ## Privacy - High Command makes no network, file, process, or model call of its own, and it keeps nothing on disk. It reads no credential, token, settings file, environment variable, or other file. The `suspicious` patterns in `hooks/text.ts` name credential files, such as `.aws/credentials`, only to mark a message that asks an agent to read one. -- Besides the events in the table below, it reads the local agent list (each agent's ID, name, status, and the mod that started it) to file sends, label subagent messages, and show each local agent's status. It reads which surfaces show the session, so `/highcommand open` can say when no pane can draw. It also reads the clock and its own session state. -- It keeps up to 200 messages in the main inbox and in each agent lane, and the states of up to 100 peers, in 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. +- 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, the session ID, and its own session state. At `/clear`, `/branch`, and in-app `/resume`, it sets a timer that reads the session ID at once, and one that reads it every 500 ms, until it takes the switch, for 5 minutes at most. While such a switch is under way, it also reads the session ID before each slash command and each `/config` change. When it takes a switch while it draws the band or the pane, it sets one more timer to write its copy of the inbox, because a drawing cannot write it. A slash command or a `/config` change that comes first writes the copy at once. While the copy of a switch is under way, each slash command and each `/config` change waits for it, with one more timer, for 2 seconds at most. +- 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, except with managed settings or a Team or Enterprise sign-in, where the branch starts empty. `/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 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. +The first eleven 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. | -| `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.end` | On `/clear`, `/resume`, and `/branch`, notes the conversation that ends, the parent of a branch, and the switch for the hooks after it. It also reads the session ID at once, with a timer of 0 ms, then every 500 ms until High Command takes the switch, for 5 minutes at most. 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. With managed settings or a Team or Enterprise sign-in, this event does not reach High Command: the timers of `session.end` and each other hook then first read the session ID, and the first read that names the new conversation does this work, but `/branch` starts an empty inbox. | | `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. | | `tool.call`, for ListAgents | Reads the peer states in the result. | | `agent.spawn` | Reads the local agent list after the agent starts. | | `turn.complete` | When a local agent's turn ends, reads the local agent list and notes how the turn ended. | +| `command.run`, for every command | While a switch that no `classic.SessionStart` reported is under way, takes it before the command runs (see **Managed settings and Team or Enterprise sign-ins** in [Known limits](#known-limits)). Then it waits until the copy of the inbox for that switch is in `$.state`, also when a drawing took the switch, for 2 seconds at most. So a reload that the command causes, as at `/reload-plugins`, keeps the inbox. With no such switch and no such copy under way, it does nothing. | +| `config.set`, for every `/config` row | Does the same before a `/config` row changes, so the reload after a change of an [option](#options) keeps the inbox. It never refuses a change and never changes a value. | | `command.run`, for `/highcommand` | Prints the summary, or opens the pane. | | `ui.render`, for its own pane and the area above the prompt | Draws the pane, and the band above other mods' content. During a survey, or when the band does not fit, it leaves that area as it was. | -High Command has no hook on a permission check, so it never changes a permission decision. Its `agent.spawn` hook only reads the local agent list after the spawn. It never changes an agent's prompt, type, model, or permission mode, and it never refuses a spawn. +High Command has no hook on a permission check, so it never changes a permission decision. Its `command.run` and `config.set` hooks never refuse or change a command or a `/config` change. Its `agent.spawn` hook only reads the local agent list after the spawn. It never changes an agent's prompt, type, model, or permission mode, and it never refuses a spawn. ## Troubleshooting @@ -244,7 +247,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` 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. +- **High Command is empty after `/clear`, `/resume`, or `/branch`**: `/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. With managed settings on the machine, or with a Team or Enterprise sign-in, `/branch` starts empty too, and the parent conversation keeps its inbox for a `/resume` (see [Known limits](#known-limits)). - **`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 a44e8dc..59d5362 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`. 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. +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)). With managed settings or a Team or Enterprise sign-in, `/branch` starts empty, so the line clears then too. 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`, 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`. +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`. With managed settings or a Team or Enterprise sign-in, a branch starts empty and its line says that earlier history was not restored, but a new process does not say it (see **Managed settings and Team or Enterprise sign-ins** in [Known limits](#known-limits)). 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. @@ -126,9 +126,10 @@ 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. -- **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. +- **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. With managed settings or a Team or Enterprise sign-in, `/branch` starts empty instead (see **Managed settings and Team or Enterprise sign-ins**). +- **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. With managed settings or a Team or Enterprise sign-in, a reload that no slash command and no `/config` change causes, before High Command takes a `/resume` and writes its copy, drops the inbox that it would bring back (see **Managed settings and Team or Enterprise sign-ins**). +- **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. With managed settings or a Team or Enterprise sign-in, a new process does not say this (see the next item). +- **Managed settings and Team or Enterprise sign-ins**: with managed settings on the machine, or with a Team or Enterprise sign-in, Claude Code does not pass `classic.SessionStart` to installed mods. High Command then reads the session ID at once after a `/clear`, `/branch`, or in-app `/resume`, then every 500 ms for up to 5 minutes, and at each of its events, such as a message, a send, the end of a turn, `/highcommand`, a press, or a drawing of the band or the pane. It also reads the session ID before each slash command, such as `/reload-plugins` or `/config`, and before each change in `/config`. At the first read that names the new conversation, it takes the switch. Before a slash command or a `/config` change goes on, High Command takes the switch this way and waits until its copy of the inbox is written, also when a drawing of the band or the pane took the switch first. So a reload that the command or the change causes keeps the inbox. Only a reload of the mod that no slash command and no `/config` change causes, such as one after an edit of a settings file, can come before that copy is written. That reload drops the inbox that `/resume` would bring back. `/clear` starts an empty inbox, and `/resume` brings back an inbox that this process kept, as described in **Branches and resumes**. There High Command cannot tell `/branch` from a `/resume` of a conversation from another process, so `/branch` starts empty, with no agent lanes, and the pane and `/highcommand` say that earlier history was not restored. The parent conversation keeps its own inbox, and a `/resume` of it brings that inbox back. A new process, such as `claude --resume`, `--fork-session`, a `/fork` copy, or a conversation in the background, starts empty too, but there the pane and `/highcommand` do not 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 @@ -139,12 +140,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 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**). + 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, except with managed settings or a Team or Enterprise sign-in. `/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. +- **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. With managed settings or a Team or Enterprise sign-in, the branch starts empty, so such a message stays with the parent only. **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. @@ -165,7 +166,7 @@ The first list holds the limits you can hit in daily use. The second list holds | 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: : 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. With managed settings or a Team or Enterprise sign-in, the session ID line can also come during a `/clear`, `/branch`, or in-app `/resume`. Until a read of the session ID works, High Command cannot tell that the conversation changed. It then shows the conversation that ended, and it files the messages and sends that arrive meanwhile under that conversation, so the new conversation does not show them. They still reach Claude. 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. | diff --git a/hooks/limits.ts b/hooks/limits.ts index 4cb46dd..e9c95eb 100644 --- a/hooks/limits.ts +++ b/hooks/limits.ts @@ -20,6 +20,12 @@ export const LIMITS = { * the one used least recently drops first. */ snapshots: 10, + /** Milliseconds between two reads of the session id after a switch, after the read at 0 ms, until the switch settles (watch). */ + switchReadMs: 500, + /** Periods of switchReadMs after a switch in which the session id is read at most: 600 of 500 ms take 5 minutes. */ + switchReads: 600, + /** Milliseconds that a slash command or a /config change waits at most for the copy that a settled switch writes (settleFirst). */ + settleWaitMs: 2000, /** 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 028c290..3683640 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,5 +1,5 @@ import { atom, read } from 'claude-code' -import type { AgentInfo, CommandRunResult, EngineInterface, Register, SessionReceiveOrigin, SessionSendResult, TraceEntry } from 'claude-code' +import type { AgentInfo, CommandRunResult, EngineInterface, Register, SessionReceiveOrigin, SessionSendResult, Timer, TraceEntry } from 'claude-code' import type { HighCommandAgent, HighCommandCapture, HighCommandEntry, HighCommandLane, HighCommandPeer, HighCommandThread, HighCommandView } from '../types' import { agentSenderOf, isAgentMessage, parseDelivery, spawnerOf, UNNAMED, type NewEntry, type PeerReport } from './envelope' @@ -109,6 +109,14 @@ const inboxes = new Map() 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 switch that session.end noted, until classic.SessionStart or settle takes it: its reason and the session that + * ended. With managed settings or a Team or Enterprise sign-in, Claude Code passes no classic event to installed mods, + * so only settle sees the switch. + */ +let pending: { reason: 'clear' | 'resume'; ended: string } | undefined +/** The session that settle moved to since the last session.end: a classic.SessionStart for it changes no inbox. */ +let settled: 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. */ @@ -393,12 +401,24 @@ 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 +/** The mirror writes under way (publish), until each lands or fails: the copy of a settled switch waits for them too (startCopy). */ +const publishing = new Set>() + +/** Writes the mirror (writeMirror), and notes the write in `publishing` while it is under way. */ +function publish($: EngineInterface): Promise { + const run = writeMirror($) + publishing.add(run) + const done = () => { publishing.delete(run) } + void run.then(done, done) + return run +} + /** * 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 { +async function writeMirror($: EngineInterface): Promise { if (!isAdopted || changes === mirrored) return for (;;) { mirrored = changes @@ -463,6 +483,155 @@ function syncStatus($: EngineInterface, event: string, isForced = false, withLan } } +/** + * Takes the switch that session.end noted when no classic.SessionStart reports it, at the first hook after + * `$.session.id()` stops answering the session that ended. Until then the switch is still under way, and nothing + * changes. Then, once: a clear starts an empty inbox; a resume takes back the inbox that this process kept for the + * session, else starts an empty one. Here a branch cannot be told from a resume of another process's conversation, so + * it starts empty too, and shows nothing of another conversation. Without the guard, a branch never settles here: + * `$.session.id()` answers the parent until classic.SessionStart has run. `write` says when the copy of the inbox goes + * to `$.state` (CopyWrite, startCopy). + */ +async function settle($: EngineInterface, event: string, withLanes: boolean, write: CopyWrite = 'await'): Promise { + const waiting = pending + if (waiting === undefined) return + let id: string + try { + id = await $.session.id() + } catch (error) { + diagnose($, `high-command: ${event}: could not read the session id: ${causeOf(error)}`) + return + } + if (pending !== waiting || id === waiting.ended) return + let now: number | undefined + try { now = await $.clock.now() } catch (error) { diagnose($, `high-command: ${event}: could not set capture window: ${causeOf(error)}`) } + // Another hook, classic.SessionStart, or a newer session.end took the switch meanwhile. + if (pending !== waiting) return + pending = undefined + settled = id + lastEnded = undefined + laneMirror = undefined + const known = waiting.reason === 'resume' ? inboxes.get(id) : undefined + current = id + const inbox = keep(id, known !== undefined ? startOver(known) : emptyInbox()) + if (now !== undefined) { + if (known === undefined) edit(inbox, { capture: { at: now, reset: true } }) + else ensureCapture(inbox, now) + } + syncStatus($, event, true, withLanes) + const copy = noteCopy() + if (write === 'timer') $.clock.after(0, () => { void startCopy($, copy, event) }) + else if (write === 'start') void startCopy($, copy, event) + else await startCopy($, copy, event) +} + +/** + * When a settle writes its copy of the inbox to `$.state` (startCopy): `await`, before the settle resolves; `timer`, in + * a timer at 0 ms, as a drawing may not write `$.state`; `start`, at once, while the caller waits for it (settleFirst). + */ +type CopyWrite = 'await' | 'timer' | 'start' + +/** The copy of the inbox that a settle writes to `$.state`: its write once begun (startCopy). */ +type SettleCopy = { written?: Promise } + +/** + * The copy that the last settle writes, from the settle until that write has landed or failed (noteCopy). A slash + * command or a /config change waits for it (settleFirst), and writes it at once when a drawing left it to its timer. + */ +let settleCopy: SettleCopy | undefined + +/** Notes a new copy of a settle in `settleCopy`, not begun yet. */ +function noteCopy(): SettleCopy { + const copy: SettleCopy = {} + settleCopy = copy + return copy +} + +/** + * Writes `copy` once, and resolves once that write has landed or failed: whoever starts it first writes it with its own + * `$`, the timer of a drawing or a hook, and each later call gets the same write. The write is a mirror write under + * `event`, then a wait for every mirror write under way (publishing): a write that another hook began after the settle + * holds its data, and may not have landed yet. Then the note goes. + */ +function startCopy($: EngineInterface, copy: SettleCopy, event: string): Promise { + copy.written ??= (async () => { + await mirror($, event) + await Promise.allSettled([...publishing]) + if (settleCopy === copy) settleCopy = undefined + })() + return copy.written +} + +/** ensureReady, then settle a switch that no classic.SessionStart reported. Resolves what ensureReady resolves. */ +async function ensureCurrent($: EngineInterface, event: string, withLanes: boolean, write: CopyWrite = 'await'): Promise { + if (!(await ensureReady($, event))) return false + if (pending !== undefined) await settle($, event, withLanes, write) + return true +} + +/** + * Settles a pending switch before a slash command or a /config change goes on (settle), then waits until the copy of + * the inbox that a settle writes has landed (settleCopy), LIMITS.settleWaitMs at most. Either can reload the mod, as + * /reload-plugins and a change of an option do: the reload empties module memory, and Claude Code reset `$.state` at + * the switch, so the copy must be in `$.state` first. A copy that a drawing left to its timer is written here at once. + * With no switch pending and no copy under way, it calls nothing and waits for nothing. + */ +async function settleFirst($: EngineInterface, event: string, withLanes: boolean): Promise { + if (pending !== undefined) await ensureCurrent($, event, withLanes, 'start') + const copy = settleCopy + if (copy === undefined) return + let passed = () => {} + const deadline = new Promise(resolve => { passed = resolve }) + const timer = $.clock.after(LIMITS.settleWaitMs, () => passed()) + try { + await Promise.race([startCopy($, copy, event), deadline]) + } finally { + timer.cancel() + } +} + +/** The timers of the last switch that session.end noted (watch). */ +let watcher: Timer | undefined + +/** + * Settles the switch that session.end noted without waiting for another hook: on a surface with no band and with the + * pane closed, none may come before a reload of the mod, which would find `$.state` reset and the inbox gone. It reads + * `$.session.id()` (ensureCurrent) once at 0 ms, then every LIMITS.switchReadMs, one read at a time. It stops once the + * switch settled, by these timers, another hook, or classic.SessionStart, and after LIMITS.switchReads periods. A newer + * switch stops it. + */ +function watch($: EngineInterface, withLanes: boolean): void { + watcher?.cancel() + const waiting = pending + if (waiting === undefined) return + const timers: Timer[] = [] + const stop = () => { for (const timer of timers) timer.cancel() } + let periods = 0 + let isReading = false + const readId = () => { + if (pending !== waiting) { + stop() + return + } + if (isReading) return + isReading = true + void ensureCurrent($, 'session.end', withLanes).finally(() => { + isReading = false + if (pending !== waiting) stop() + }) + } + timers.push($.clock.after(0, readId)) + timers.push($.clock.every(LIMITS.switchReadMs, () => { + periods += 1 + if (periods > LIMITS.switchReads) { + stop() + return + } + readId() + })) + watcher = { cancel: stop } +} + /** 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) { @@ -581,10 +750,12 @@ async function openFromCommand($: EngineInterface): Promise { /** * Runs a button's work; a failure becomes a toast that names the action. The press counts as the person's * own input only while its onPress work runs, so each handler returns this promise: a `$.ui.open` after - * awaits is then asked, and the pane is placed at any width. + * awaits is then asked, and the pane is placed at any width. A switch that no classic.SessionStart reported + * settles first, so a press on a drawing of the conversation that ended does nothing (pressed). */ -function press($: EngineInterface, action: string, work: () => Promise): Promise { - return work().then(() => undefined, (error: unknown) => $.ui.toast(`ui.press: ${action}: ${causeOf(error)}`)) +function press($: EngineInterface, action: string, withLanes: boolean, work: () => Promise): Promise { + const run = pending === undefined ? work() : settle($, 'ui.press', withLanes).then(work) + return run.then(() => undefined, (error: unknown) => $.ui.toast(`ui.press: ${action}: ${causeOf(error)}`)) } /** @@ -661,7 +832,7 @@ function paneActions($: EngineInterface, withLanes: boolean, drawn: Drawn, laneI * 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 act = (action: string, kind: PressKind, change: (inbox: Inbox) => string | undefined | void) => press($, action, withLanes, async () => { const inbox = pressed(drawn) if (inbox === undefined) return const key = change(inbox) @@ -723,12 +894,12 @@ function paneActions($: EngineInterface, withLanes: boolean, drawn: Drawn, laneI function bandActions($: EngineInterface, withLanes: boolean, drawn: Drawn): BandActions { return { open: () => - press($, 'the pane did not open', async () => { + press($, 'the pane did not open', withLanes, async () => { const problem = await openPane($) if (problem !== undefined) $.ui.toast(`ui.press: ${problem}`) }), readAll: () => - press($, 'read all', async () => { + press($, 'read all', withLanes, 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)) })) @@ -736,7 +907,7 @@ function bandActions($: EngineInterface, withLanes: boolean, drawn: Drawn): Band await afterPress($, 'marks', undefined, withLanes) }), hide: () => - press($, 'hide', async () => { + press($, 'hide', withLanes, async () => { const inbox = pressed(drawn) if (inbox === undefined) return if (withLanes) setLanes(inbox, inbox.lanes, hideLanes(inbox.hiddenLanes, drawn.lanes)) @@ -756,7 +927,7 @@ export const register: Register = (on, options) => { } catch (error) { diagnose($, `high-command: session.start: could not register /highcommand: ${causeOf(error)}`) } - if (!(await ensureReady($, 'session.start'))) return next(e) + if (!(await ensureCurrent($, 'session.start', withLanes))) 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') @@ -764,20 +935,26 @@ export const register: Register = (on, options) => { }) // 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. + // The inbox stays in module memory, so the hook only remembers the session that ends: the parent of a branch. It + // also notes the switch for settle, in case no classic.SessionStart comes, and starts the timers that settle it once + // the id changed (watch). 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) + pending = { reason: e.reason, ended: e.sessionId } + settled = undefined await ensureReady($, 'session.end') lastEnded = e.sessionId laneMirror = undefined + watch($, withLanes) return next(e) }) - // 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 + // Any source sets the current session from `e.session_id`: at a branch, `$.session.id()` still answers the parent + // here. 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. + // writes the mirror again, so the last mirror write after the switch holds the new conversation's inbox. When settle + // already moved to this conversation, the hook changes no inbox: settle gave the same one, and what was recorded + // since then stays. on('classic.SessionStart', async ($, e, next) => { const isSwitch = e.source === 'fork' || e.source === 'resume' || e.source === 'clear' if (!(await ensureReady($, 'classic.SessionStart'))) { @@ -787,6 +964,17 @@ export const register: Register = (on, options) => { isAdopted = true } const id = e.session_id + if (isSwitch) { + const isSettled = settled === id + pending = undefined + settled = undefined + if (isSettled) { + lastEnded = undefined + syncStatus($, 'classic.SessionStart', false, withLanes) + await mirror($, 'classic.SessionStart') + return next(e) + } + } const parent = e.source === 'fork' && lastEnded !== undefined ? inboxes.get(lastEnded) : undefined const known = e.source === 'resume' ? inboxes.get(id) : undefined if (isSwitch) lastEnded = undefined @@ -814,7 +1002,7 @@ export const register: Register = (on, options) => { on('session.receive', async ($, e, next) => { if (!MESSAGE_ORIGINS.has(e.origin.kind)) return next(e) - if (!(await ensureReady($, 'session.receive'))) return next(e) + if (!(await ensureCurrent($, 'session.receive', withLanes))) 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 @@ -841,7 +1029,7 @@ 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) + if (!(await ensureCurrent($, 'prompt.submit', withLanes))) return next(e) const session = current ?? '' let at: number | undefined try { at = await $.clock.now(); ensureCapture(inboxOf(session), at) } catch (error) { diagnose($, `high-command: prompt.submit: could not observe delivery time: ${causeOf(error)}`) } @@ -873,7 +1061,7 @@ export const register: Register = (on, options) => { on('session.send', async ($, e, next) => { if (e.origin.kind !== 'model') return next(e) - if (!(await ensureReady($, 'session.send'))) return next(e) + if (!(await ensureCurrent($, 'session.send', withLanes))) return next(e) const session = current ?? '' let at: number | undefined try { at = await $.clock.now(); ensureCapture(inboxOf(session), at) } catch (error) { diagnose($, `high-command: session.send: could not observe send time: ${causeOf(error)}`) } @@ -891,7 +1079,7 @@ export const register: Register = (on, options) => { }) on('tool.call', { tool: 'ListAgents' }, async ($, e, next) => { - if (!(await ensureReady($, 'tool.call ListAgents'))) return next(e) + if (!(await ensureCurrent($, 'tool.call ListAgents', withLanes))) return next(e) const session = current ?? '' const out = await next(e) const result: unknown = out.isError === true ? undefined : out.result @@ -913,15 +1101,17 @@ export const register: Register = (on, options) => { on('agent.spawn', async ($, e, next) => { const result = await next(e) - if (!(await ensureReady($, 'agent.spawn'))) return result + if (!(await ensureCurrent($, 'agent.spawn', withLanes))) return result await refreshAgents($, 'agent.spawn') await mirror($, 'agent.spawn') return result }) + // A main turn records nothing, but the first one after a switch that no classic.SessionStart reported settles it. on('turn.complete', async ($, e, next) => { const result = await next(e) - if (e.agentId !== undefined && await ensureReady($, 'turn.complete')) { + if (e.agentId === undefined && pending === undefined) return result + if (await ensureCurrent($, 'turn.complete', withLanes) && e.agentId !== undefined) { await refreshAgents($, 'turn.complete') try { const at = await $.clock.now() @@ -940,9 +1130,24 @@ export const register: Register = (on, options) => { return result }) + // Every slash command, /highcommand included, settles a pending switch first and waits for the copy of the inbox + // that a settle writes, also one that a drawing left to its timer, then runs as typed: a command can reload the mod, + // as /reload-plugins does (settleFirst). With no switch pending and no copy under way, the hook calls nothing. + on('command.run', async ($, e, next) => { + await settleFirst($, 'command.run', withLanes) + return next(e) + }) + + // Every /config change does the same first: a change of an option reloads the mod (settleFirst). The change then + // goes on as it came: the hook never refuses it and never changes its value. + on('config.set', async ($, e, next) => { + await settleFirst($, 'config.set', withLanes) + return next(e) + }) + 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 (!(await ensureCurrent($, 'command.run', withLanes))) return { text: 'command.run: could not take back the inbox from $.state' } if (argument === 'open') return openFromCommand($) if (argument !== '') return { text: USAGE } try { @@ -962,7 +1167,7 @@ export const register: Register = (on, options) => { on('ui.render', { component: 'Pane', requestId: 'high-command' }, async ($, e) => { const ui = $.ui.resolve(e) try { - if (!(await ensureReady($, 'ui.render'))) throw new Error('could not take back the inbox from $.state') + if (!(await ensureCurrent($, 'ui.render', withLanes, 'timer'))) throw new Error('could not take back the inbox from $.state') await read($, view) const inbox = inboxNow() const chosen = inbox.selectedLane @@ -987,7 +1192,7 @@ 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) - if (!(await ensureReady($, 'ui.render'))) return below + if (!(await ensureCurrent($, 'ui.render', withLanes, 'timer'))) return below await read($, view) const inbox = inboxNow() const list = inbox.entries