diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 436ea13..ec6d06e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "high-command", - "version": "1.0.1", + "version": "1.1.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/README.md b/README.md index e9c97b0..3b21a52 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Claude Code 2.1.288+ mod License: MIT - Version: 1.0.1 + Version: 1.1.0

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

@@ -130,16 +130,16 @@ High Command shows only what it saw in the current conversation, within fixed bo Read the full list of known limits - **Retained history**: the main inbox and each agent lane keep 200 messages in all and 50 per peer. A peer past 50 messages loses its own oldest ones first. Past 200 in all, the oldest messages drop. A notice counts the messages dropped for good, and unread counts cover kept messages only. A thread shows up to 20 messages per page, fewer when they are long. **Older** and **Newer** page through every kept message. Opening a thread marks all of that peer's kept messages read, also those on other pages. The roster shows 50 peers per page. Peer states are kept for the 100 peers reported most recently. A message keeps its first 2,000 characters, and its thread says how many more are in the transcript. -- **Agent lanes**: messages that local agents send and receive go to a separate lane for each agent. Each lane has its own counts and read marks, and `/highcommand` lists them apart from the main inbox. The band, the status line, and **Hide** cover the main inbox only, so an unread message in an agent lane does not show there. A message between your main conversation and a local agent can show twice, once in the main inbox and once in that agent's lane. High Command does not see messages between teammates that run in their own split panes. +- **Agent lanes**: messages that local agents send and receive go to a separate lane for each agent. Each lane has its own counts and read marks. The pane shows each lane's unread count on that agent's button, and `/highcommand` lists the counts apart from the main inbox. The band, the status line, and **Hide** cover the main inbox only, so an unread message in an agent lane does not show there. A message between your main conversation and a local agent can show twice, once in the main inbox and once in that agent's lane. The lane copy of a message from your main conversation never counts as unread. High Command does not see messages between teammates that run in their own split panes. - **Sends by mod-started agents**: Claude Code reports a send by an agent that a mod started as that mod's send. High Command records no mod's sends, so these sends do not show in that agent's lane or in `/highcommand`. Messages to that agent still show in its lane. Its messages to your main conversation or to another local agent still show in the main inbox or in that agent's lane. - **Space for agent lanes**: High Command saves all agent lanes of a conversation as one value. Claude Code 2.1.296 refuses a saved value over 4,194,304 characters of JSON text. About nine full lanes of 2,000-character messages reach that size, so a lane can stop recording before it holds 200 messages. A new agent message then still reaches its recipient unchanged. High Command does not record it and writes `high-command: session.receive: could not record a delivery ...` or `high-command: session.send: could not record a message to ...`. Near that size, a lane button can also fail with a `high-command: ui.press: ...` toast. The main inbox is saved apart and keeps recording. Read the missed messages in that agent's transcript. `/clear`, `/resume`, or `/branch` starts a new capture window, which empties every lane and the main inbox. - **Local agent status**: the status under each agent button in the pane, and in `/highcommand`, names its source. A `roster snapshot` status comes from Claude Code's local agent list. High Command reads that list when you open the pane or run `/highcommand`, when an agent starts, and when an agent ends a turn. Between reads, the status can be old. When two reads overlap, the older one can bring back an agent that the newer one removed. Open the pane or run `/highcommand` again after agent activity settles. A `lifecycle observation` says how an agent's last turn ended, such as `turn answered` or `turn error`. When the list does not hold an agent that ended a turn, an open pane shows that observation as the agent's status until the next read of the list. It describes a finished turn, so it does not prove that the agent still runs. A teammate in its own split pane can keep its last status after it crashes. `/highcommand` lists up to 100 local agents. The pane pages through all of them, 10 at a time. - **Peer states**: a peer's roster row shows only states from teammates' notices and ListAgents results, never the local agent status. A state is the last one reported, with its time, so it does not prove that the peer is in that state now. When two rows of one ListAgents result have the same name, High Command cannot tell whose state is whose. When the result lists its rows as data, High Command takes no state for that name, so a state recorded earlier, such as `offline`, stays on that peer's row. When the result holds only the printed listing, that name gets the state of the last such row that has one. -- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Identity** in the thread shows the full name. A send that Claude addresses by a local agent ID files under `name [ID]`. That agent's messages to you file under its name alone, so the two take separate roster rows and counts. +- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Identity** in the thread shows the full name. A send that Claude addresses by the ID of a listed local agent files under that agent's name, on one row with the agent's messages to you. These sends count toward that name's 50 kept messages, so many sends can push out an unread message. The send files under `name [ID]`, on a row of its own, when another listed local agent has the same name or a peer of that name has another session's address. A name that holds only spacing and invisible characters files under `[ID]`. High Command reads the local agent list at each send. Claude Code drops an agent from that list when the agent ends or some time later. A send to the ID of an agent that is no longer listed files under that ID alone, on a row of its own, apart from the agent's messages to you. - **Send results**: a send shows the recipient and text that High Command observed, and the result reported back to High Command. **Send details** shows what Claude Code itself received, which another mod may have changed, and whether Claude Code queued the message. When a mod answers the send itself, Send details cannot show this. When a mod sends the message more than once, Send details covers only one of those sends. A refused send shows the first 120 characters of the reason, on one line, in its header. **Send details** keeps up to 2,000 characters of the reason, with its line breaks, and says when it was cut. A queued message is not proof that the peer read it. - **Sends changed by earlier mods**: a mod that runs before High Command can change a send's recipient and text. High Command then shows the changed recipient and text, and files the send under the changed recipient. It cannot show what Claude first wrote. Check the SendMessage call in the transcript. - **Structured sends**: Claude Code passes only plain-text SendMessage messages to High Command. A SendMessage whose message is a `shutdown_request`, `shutdown_response`, or `plan_approval_response` object does not reach it. Neither its attempt nor its result appears in the main inbox, an agent lane, or `/highcommand`. Check the transcript for these sends. -- **Subagent messages**: a named subagent's message to your main conversation is always `local sender`. This holds even when a mod started that subagent or wrote the message, because Claude Code does not pass that fact on. 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. +- **Subagent messages**: Claude Code does not say whether a mod started or wrote a named subagent's message to your main conversation. High Command reads the local agent list when such a message arrives. The message is `unverified sender` when a mod started every listed agent whose name or ID its frame gives. Otherwise it is `local sender`: also when a mod wrote it from an agent that the model started, when its frames name several senders, and when the list cannot be read. Agents that left the list do not count, so a message from a model-started agent that already ended is `unverified sender` while a mod-started agent of the same name is listed. High Command counts such a message only when the prompt that reaches it holds a complete `` frame and no frame of another kind. A mod that runs after High Command can remove that frame. High Command then files the changed text under the one name that the frames gave, or under `unnamed peer` when they gave none or several. A sender name in the changed text, such as a `from` field, does not change this. - **Damaged frames**: Claude Code can wrap a peer's message in a frame, such as ``, whose attributes name the sender. A malformed attribute, a frame with no closing tag, or text beside complete frames makes High Command write an `incomplete frame parsing` line. A complete frame with a malformed attribute keeps the attributes that High Command could read, such as the sender name and summary. The sender label rules still apply to that name. A frame with no closing tag, and text beside complete frames, become a message from the delivery's sender, or from `unnamed peer`, without frame attributes. Check the transcript for the original frame. A delivery that Claude Code could not place is recorded only when it holds a complete frame. - **Display order**: the pane and `/highcommand` sort messages by observation time, the time High Command first saw each one, before other mods and Claude Code handled it. That is not always the order in which messages reached Claude. Read marks, **Hide**, which messages drop first, and the label of a peer with nothing unread follow recording sequence, the order in which High Command finished recording messages. - **Narrow panes**: a roster row is one line, and a narrow pane cuts its end. The unread count, sender label, and `suspicious` mark come first, so the state and the latest message are cut before them. @@ -154,7 +154,7 @@ High Command shows only what it saw in the current conversation, within fixed bo ## 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, and status) to file sends 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. +- Besides the events in the table below, it reads the local agent list (each agent's ID, name, status, and the mod that started it) to file sends, label subagent messages, and show each local agent's status. It reads which surfaces show the session, so `/highcommand open` can say when no pane can draw. It also reads the clock and its own session state. - It keeps up to 200 messages in the main inbox and in each agent lane, and the states of up to 100 peers, in session memory. A message, a refusal reason, and the text in **Send details** keep at most 2,000 characters each, with control characters removed. `/clear`, `/resume`, `/branch`, and the end of the session drop them. - A pane that you open in the desktop app, the VS Code panel, or the mobile app is drawn there, as the transcript is. - `/highcommand` output reaches Claude as command output. It holds counts, times, states, and peer and agent names, never message text. Names show as code, so a name cannot add a link or formatting. @@ -169,7 +169,7 @@ The first eight hooks below pass their event on with `next(e)` and return the re | `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.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. It submits no prompt. | +| `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. | diff --git a/docs/usage.md b/docs/usage.md index f877e50..7b99b53 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,7 +3,7 @@ ## What High Command records - **Messages to your session**: deliveries to your main conversation from agent-team teammates, from subagents that message it by name, and from your other Claude Code sessions. A named subagent's message is recorded when its prompt holds a complete `` frame, both while the session is idle and during a turn (see [Known limits](#known-limits)). Task notifications, scheduled prompts, and your own Remote Control prompts are not recorded. Messages for local agents go to a separate lane for each agent. -- **Messages from your session**: each plain-text SendMessage attempt that a model makes, with its reported result and a separate lane for each local sending agent. A send addressed by a local agent ID keeps that ID alongside the agent's name, so it takes a thread apart from that agent's messages to you. A message to an agent that has no name, a message that another mod in your session sends, and a send by an agent that a mod started are not recorded (see [Known limits](#known-limits)). A structured message, such as a `shutdown_request`, `shutdown_response`, or `plan_approval_response` object, is not recorded either (see [Known limits](#known-limits)). A message to your session that a mod in the sender's session composed is recorded, as `unverified sender` unless the team harness wrote it to a verified mailbox. +- **Messages from your session**: each plain-text SendMessage attempt that a model makes, with its reported result and a separate lane for each local sending agent. A send addressed by the ID of a listed local agent files under that agent's name, in one thread with its messages to you, unless another agent or peer shares the name. A send to the ID of an agent that is no longer listed files under that ID alone, in a thread of its own (see [Known limits](#known-limits)). A message to an agent that has no name, a message that another mod in your session sends, and a send by an agent that a mod started are not recorded (see [Known limits](#known-limits)). A structured message, such as a `shutdown_request`, `shutdown_response`, or `plan_approval_response` object, is not recorded either (see [Known limits](#known-limits)). A message to your session that a mod in the sender's session composed is recorded, as `unverified sender` unless the team harness wrote it to a verified mailbox. - **Peer states**: `idle`, `interrupted`, `failed`, `ended`, or `shut down` from teammates' notices, and `busy`, `idle`, `offline`, or another state word from the sessions Claude lists. A listed peer gets its state on its roster row once it has messages. When two listed rows share a name, the state on that peer's row may not be current (see [Known limits](#known-limits)). A message that another mod consumes, or that you decline in an approval dialog, never reaches Claude, so High Command does not record it. @@ -24,7 +24,7 @@ Each message to you carries one sender label. Claude Code's own record of where | --- | --- | | `verified sender` | A teammate whose message the team harness wrote under that member's name. | | `local sender` | Another session or agent whose message Claude Code delivered. The label does not prove that the sender runs on this machine: Claude Code does not report the sender's host, so this can be a remote session. The name is that sender's claim. | -| `unverified sender` | Everything else: a teammate in its own split pane, a message that a mod in the sender's session wrote outside a verified team mailbox, a delivery Claude Code could not place, and a message that names a different teammate than the one that sent it. | +| `unverified sender` | Everything else: a teammate in its own split pane, a named subagent that a mod started (see [Known limits](#known-limits)), a message that a mod in the sender's session wrote outside a verified team mailbox, a delivery Claude Code could not place, and a message that names a different teammate than the one that sent it. | A peer's row, the header of its thread, and `/highcommand` show the weakest label among its unread messages, so a newer message cannot hide the label of one you have not read. When none is unread, they show the label of its newest message to you. A peer you only sent messages to has no label. @@ -57,7 +57,7 @@ A thread shows up to 20 messages per page with that peer, newest first. `←` ma A send shows `send attempted` with the recipient and text that High Command observed, and the reported send result. **Send details** shows the refusal reason, up to 2,000 characters. When available, it also shows the core lines: the recipient, text, and queue outcome that Claude Code itself received for the latest call. A core outcome covers that one call only. It does not prove that the recipient read the message. `/highcommand` labels sends as attempts. -Both ways to open the pane show the main inbox, even while an agent transcript is in view. Once opened, the pane keeps the lane you pick and does not follow the agent transcript you view. Press an agent's button in the agent selector to see its lane, and **Main inbox** to return to the main conversation. Agent lane counts and read actions are separate. The agent selector shows 10 agents per page: each local agent and each agent with a lane. Each agent's status shows under its button. +Both ways to open the pane show the main inbox, even while an agent transcript is in view. Once opened, the pane keeps the lane you pick and does not follow the agent transcript you view. Press an agent's button in the agent selector to see its lane, and **Main inbox** to return to the main conversation. Agent lane counts and read actions are separate, and each agent's button shows its lane's unread count. The agent selector shows 10 agents per page: each local agent and each agent with a lane. Each agent's status shows under its button. ## The band @@ -83,16 +83,16 @@ Letter and digit keys work only while the band or the pane has the keyboard. Foc ## Known limits - **Retained history**: the main inbox and each agent lane keep 200 messages in all and 50 per peer. A peer past 50 messages loses its own oldest ones first. Past 200 in all, the oldest messages drop. A notice counts the messages dropped for good, and unread counts cover kept messages only. A thread shows up to 20 messages per page, fewer when they are long. **Older** and **Newer** page through every kept message. Opening a thread marks all of that peer's kept messages read, also those on other pages. The roster shows 50 peers per page. Peer states are kept for the 100 peers reported most recently. A message keeps its first 2,000 characters, and its thread says how many more are in the transcript. -- **Agent lanes**: messages that local agents send and receive go to a separate lane for each agent. Each lane has its own counts and read marks, and `/highcommand` lists them apart from the main inbox. The band, the status line, and **Hide** cover the main inbox only, so an unread message in an agent lane does not show there. A message between your main conversation and a local agent can show twice, once in the main inbox and once in that agent's lane. High Command does not see messages between teammates that run in their own split panes. +- **Agent lanes**: messages that local agents send and receive go to a separate lane for each agent. Each lane has its own counts and read marks. The pane shows each lane's unread count on that agent's button, and `/highcommand` lists the counts apart from the main inbox. The band, the status line, and **Hide** cover the main inbox only, so an unread message in an agent lane does not show there. A message between your main conversation and a local agent can show twice, once in the main inbox and once in that agent's lane. The lane copy of a message from your main conversation never counts as unread. High Command does not see messages between teammates that run in their own split panes. - **Sends by mod-started agents**: Claude Code reports a send by an agent that a mod started as that mod's send. High Command records no mod's sends, so these sends do not show in that agent's lane or in `/highcommand`. Messages to that agent still show in its lane. Its messages to your main conversation or to another local agent still show in the main inbox or in that agent's lane. - **Space for agent lanes**: High Command saves all agent lanes of a conversation as one value. Claude Code 2.1.296 refuses a saved value over 4,194,304 characters of JSON text. About nine full lanes of 2,000-character messages reach that size, so a lane can stop recording before it holds 200 messages. A new agent message then still reaches its recipient unchanged. High Command does not record it and writes `high-command: session.receive: could not record a delivery ...` or `high-command: session.send: could not record a message to ...`. Near that size, a lane button can also fail with a `high-command: ui.press: ...` toast. The main inbox is saved apart and keeps recording. Read the missed messages in that agent's transcript. `/clear`, `/resume`, or `/branch` starts a new capture window, which empties every lane and the main inbox. - **Local agent status**: the status under each agent button in the pane, and in `/highcommand`, names its source. A `roster snapshot` status comes from Claude Code's local agent list. High Command reads that list when you open the pane or run `/highcommand`, when an agent starts, and when an agent ends a turn. Between reads, the status can be old. When two reads overlap, the older one can bring back an agent that the newer one removed. Open the pane or run `/highcommand` again after agent activity settles. A `lifecycle observation` says how an agent's last turn ended, such as `turn answered` or `turn error`. When the list does not hold an agent that ended a turn, an open pane shows that observation as the agent's status until the next read of the list. It describes a finished turn, so it does not prove that the agent still runs. A teammate in its own split pane can keep its last status after it crashes. `/highcommand` lists up to 100 local agents. The pane pages through all of them, 10 at a time. - **Peer states**: a peer's roster row shows only states from teammates' notices and ListAgents results, never the local agent status. A state is the last one reported, with its time, so it does not prove that the peer is in that state now. When two rows of one ListAgents result have the same name, High Command cannot tell whose state is whose. When the result lists its rows as data, High Command takes no state for that name, so a state recorded earlier, such as `offline`, stays on that peer's row. When the result holds only the printed listing, that name gets the state of the last such row that has one. -- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Identity** in the thread shows the full name. A send that Claude addresses by a local agent ID files under `name [ID]`. That agent's messages to you file under its name alone, so the two take separate roster rows and counts. +- **Peer names**: High Command tells peers apart by name. Two senders with the same name share one roster row. Names that differ only in spacing or in invisible characters count as one name. A name longer than 60 characters shows cut, with a short code that keeps cut names apart. **Identity** in the thread shows the full name. A send that Claude addresses by the ID of a listed local agent files under that agent's name, on one row with the agent's messages to you. These sends count toward that name's 50 kept messages, so many sends can push out an unread message. The send files under `name [ID]`, on a row of its own, when another listed local agent has the same name or a peer of that name has another session's address. A name that holds only spacing and invisible characters files under `[ID]`. High Command reads the local agent list at each send. Claude Code drops an agent from that list when the agent ends or some time later. A send to the ID of an agent that is no longer listed files under that ID alone, on a row of its own, apart from the agent's messages to you. - **Send results**: a send shows the recipient and text that High Command observed, and the result reported back to High Command. **Send details** shows what Claude Code itself received, which another mod may have changed, and whether Claude Code queued the message. When a mod answers the send itself, Send details cannot show this. When a mod sends the message more than once, Send details covers only one of those sends. A refused send shows the first 120 characters of the reason, on one line, in its header. **Send details** keeps up to 2,000 characters of the reason, with its line breaks, and says when it was cut. A queued message is not proof that the peer read it. - **Sends changed by earlier mods**: a mod that runs before High Command can change a send's recipient and text. High Command then shows the changed recipient and text, and files the send under the changed recipient. It cannot show what Claude first wrote. Check the SendMessage call in the transcript. - **Structured sends**: Claude Code passes only plain-text SendMessage messages to High Command. A SendMessage whose message is a `shutdown_request`, `shutdown_response`, or `plan_approval_response` object does not reach it. Neither its attempt nor its result appears in the main inbox, an agent lane, or `/highcommand`. Check the transcript for these sends. -- **Subagent messages**: a named subagent's message to your main conversation is always `local sender`. This holds even when a mod started that subagent or wrote the message, because Claude Code does not pass that fact on. 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. +- **Subagent messages**: Claude Code does not say whether a mod started or wrote a named subagent's message to your main conversation. High Command reads the local agent list when such a message arrives. The message is `unverified sender` when a mod started every listed agent whose name or ID its frame gives. Otherwise it is `local sender`: also when a mod wrote it from an agent that the model started, when its frames name several senders, and when the list cannot be read. Agents that left the list do not count, so a message from a model-started agent that already ended is `unverified sender` while a mod-started agent of the same name is listed. High Command counts such a message only when the prompt that reaches it holds a complete `` frame and no frame of another kind. A mod that runs after High Command can remove that frame. High Command then files the changed text under the one name that the frames gave, or under `unnamed peer` when they gave none or several. A sender name in the changed text, such as a `from` field, does not change this. - **Damaged frames**: Claude Code can wrap a peer's message in a frame, such as ``, whose attributes name the sender. A malformed attribute, a frame with no closing tag, or text beside complete frames makes High Command write an `incomplete frame parsing` line. A complete frame with a malformed attribute keeps the attributes that High Command could read, such as the sender name and summary. The sender label rules still apply to that name. A frame with no closing tag, and text beside complete frames, become a message from the delivery's sender, or from `unnamed peer`, without frame attributes. Check the transcript for the original frame. A delivery that Claude Code could not place is recorded only when it holds a complete frame. - **Display order**: the pane and `/highcommand` sort messages by observation time, the time High Command first saw each one, before other mods and Claude Code handled it. That is not always the order in which messages reached Claude. Read marks, **Hide**, which messages drop first, and the label of a peer with nothing unread follow recording sequence, the order in which High Command finished recording messages. - **Narrow panes**: a roster row is one line, and a narrow pane cuts its end. The unread count, sender label, and `suspicious` mark come first, so the state and the latest message are cut before them. diff --git a/hooks/envelope.ts b/hooks/envelope.ts index 097b18e..7e6e1c6 100644 --- a/hooks/envelope.ts +++ b/hooks/envelope.ts @@ -1,4 +1,4 @@ -import type { SessionReceiveOrigin } from 'claude-code' +import type { AgentInfo, SessionReceiveOrigin } from 'claude-code' import type { HighCommandEntry, HighCommandTrust } from '../types' import { capBody, isSuspicious, peerKey, sanitize, summaryOf } from './text' @@ -210,6 +210,16 @@ export function agentSenderOf(text: string, origin: SessionReceiveOrigin): strin return names.size === 1 && name !== UNNAMED ? name : undefined } +/** + * The mod that started the local agents whose id or name is `sender`, when a mod started every one of them; + * undefined when none is listed or the model or the person started one. Claude Code does not pass a mod on with a + * subagent's peer prompt, so this can only lower the label of a message from that sender. + */ +export function spawnerOf(agents: readonly AgentInfo[], sender: string): string | undefined { + const matches = agents.filter(agent => agent.id === sender || peerKey(agent.name ?? '') === sender) + return matches.length > 0 && matches.every(agent => agent.spawnedBy !== undefined) ? matches[0]!.spawnedBy : undefined +} + /** * Splits one queued delivery into entries and peer reports. When `sender` is given, every frame and text of * the delivery is filed under it, and the names that they claim are ignored. diff --git a/hooks/inbox.ts b/hooks/inbox.ts index 2378799..b894df7 100644 --- a/hooks/inbox.ts +++ b/hooks/inbox.ts @@ -107,8 +107,21 @@ export function applyReports(peers: Readonly, reports: readonly PeerRepor } /** - * The peer a send goes to: the peer whose newest address report matches `to`, else the - * name and ID of this session's agent whose id is `to`, else the name `to` gives; undefined when that is no name. + * The key a send to a listed agent files under: its name, so the send joins that agent's messages to the main + * conversation, or its name and ID when the name is empty after peerKey, another listed agent has that name, or a + * peer of that name has an address (another session). + */ +function agentRecipient(agent: AgentInfo, agents: readonly AgentInfo[], peers: Readonly): string { + if (agent.name === undefined) return '' + const name = peerKey(agent.name) + const isShared = name === '' || own(peers, name)?.address !== undefined || + agents.some(other => other.id !== agent.id && peerKey(other.name ?? '') === name) + return isShared ? peerKey(`${agent.name} [${agent.id}]`) : name +} + +/** + * The peer a send goes to: the peer whose newest address report matches `to`, else this session's agent whose + * id is `to` (agentRecipient), else the name `to` gives; undefined when that is no name. * Equal address timestamps use observation order. Known names keep their brackets; an unknown recipient * can carry one trailing listing reference. */ @@ -123,7 +136,7 @@ export function recipientOf(to: string, peers: Readonly, agents: readonly } if (addressed !== undefined) return addressed.name const agent = agents.find(candidate => candidate.id === to) - let name = peerKey(agent === undefined ? to : (agent.name === undefined ? '' : `${agent.name} [${agent.id}]`)) + let name = agent === undefined ? peerKey(to) : agentRecipient(agent, agents, peers) const isKnown = Object.hasOwn(peers, name) || entries.some(entry => entry.peer === name) || agents.some(candidate => peerKey(candidate.name ?? '') === name) if (agent === undefined && !isKnown) { @@ -146,9 +159,9 @@ export function outboundEntry(peer: string, text: string, sent: SessionSendResul } } -/** An inbound entry newer than `mark`. Outbound entries are never new. */ +/** An inbound entry newer than `mark`. Outbound entries and lane copies of the main conversation's sends are never new. */ export function isNewer(entry: HighCommandEntry, mark: number): boolean { - return entry.direction === 'in' && entry.seq > mark + return entry.direction === 'in' && entry.fromMain !== true && entry.seq > mark } /** The unread count of all rows. */ @@ -168,10 +181,13 @@ export function rosterOf(entries: readonly HighCommandEntry[], peers: Readonly

row.last.at || (entry.at === row.last.at && entry.seq > row.last.seq)) row.last = entry if (entry.direction === 'in') { const isUnread = isNewer(entry, markOf(marks, entry.peer)) - // A peer's read entries come before its unread ones, so its first unread entry starts the fold. - const isFolded = isUnread && row.unread > 0 - row.trust = isFolded ? LEVELS.find(level => level === row.trust || level === entry.trust) : entry.trust - row.isSuspicious = (isFolded && row.isSuspicious) || entry.isSuspicious === true + // A peer's read entries come before its unread ones, so its first unread entry starts the fold. A lane copy of + // the main conversation's send is never unread and can follow unread entries; it leaves their fold alone. + if (isUnread || row.unread === 0) { + const isFolded = isUnread && row.unread > 0 + row.trust = isFolded ? LEVELS.find(level => level === row.trust || level === entry.trust) : entry.trust + row.isSuspicious = (isFolded && row.isSuspicious) || entry.isSuspicious === true + } if (isUnread) row.unread += 1 } rows.set(entry.peer, row) diff --git a/hooks/register.tsx b/hooks/register.tsx index 5e31278..1d6b64d 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,8 +1,8 @@ import { atom, read, update } from 'claude-code' -import type { CommandRunResult, EngineInterface, Register, SessionReceiveOrigin, SessionSendResult, TraceEntry } from 'claude-code' +import type { AgentInfo, CommandRunResult, EngineInterface, Register, SessionReceiveOrigin, SessionSendResult, TraceEntry } from 'claude-code' import type { HighCommandEntry, HighCommandLane, HighCommandThread } from '../types' -import { agentSenderOf, isAgentMessage, parseDelivery, UNNAMED, type NewEntry, type PeerReport } from './envelope' +import { agentSenderOf, isAgentMessage, parseDelivery, spawnerOf, UNNAMED, type NewEntry, type PeerReport } from './envelope' import { appendEntries, coreEvidence, @@ -202,10 +202,22 @@ async function recordDelivery($: EngineInterface, event: string, text: string, o $.ui.log(`${event}: a ${origin.kind} delivery named no sender; it shows as "${UNNAMED}"`, { to: 'debug' }) } if (parsed.entries.length === 0 && parsed.reports.length === 0) return - if (parsed.entries.length > 0) await append($, parsed.entries, at, laneId) + // 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) } +/** The local agents for a subagent message's sender label; a failure keeps the label and goes to the debug log. */ +async function agentsForLabel($: EngineInterface): Promise { + try { + return await $.agent.list() + } catch (error) { + $.ui.log(`prompt.submit: could not read local agents: ${causeOf(error)}; the message keeps the local sender label`, { to: 'debug' }) + return [] + } +} + /** 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) @@ -356,22 +368,28 @@ export const register: Register = on => { // turn. A message from another session passed session.receive first, so only an agent message counts here. // A mod beneath can rewrite it without the frame; the text that entered is then filed under the one sender // that the frames named, or under `unnamed peer`, and a name that the rewritten text claims does not count. + // The prompt's origin does not say whether a mod started the sender, so the local agent list can lower its label; + // 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) 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)}`) } + const named = agentSenderOf(e.text, e.origin) + const listed = named === undefined ? undefined : agentsForLabel($) const entered = await next(e) if (entered.drop !== undefined) return entered try { if (at !== undefined) { let sender: string | undefined if (!isAgentMessage(entered.text)) { - sender = agentSenderOf(e.text, e.origin) + sender = named const filing = sender === undefined ? `its frames named no single sender, so it is filed under "${UNNAMED}"` : 'it is filed under the sender that the frame named' $.ui.log(`prompt.submit: a mod rewrote a subagent message without its frame; ${filing}`, { to: 'debug' }) sender ??= UNNAMED } - await recordDelivery($, 'prompt.submit', entered.text, e.origin, at, undefined, sender) + 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) } } catch (error) { diagnose($, `high-command: prompt.submit: could not record a delivery (${e.origin.kind}): ${causeOf(error)}`) @@ -465,8 +483,10 @@ export const register: Register = on => { const { list, rows } = await readRoster($, laneId) const thread = threadOf(list, rows, laneId === undefined ? await read($, openThread) : (await readLane($, laneId)).openThread) 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(await read($, lanes)), agents: Object.values(await read($, agents)), + laneIds: Object.keys(all), agents: Object.values(await read($, agents)), + laneUnread: new Map(Object.entries(all).map(([id, lane]) => [id, unreadTotal(rosterOf(lane.entries, {}, lane.readThrough))])), capture: await read($, capture), evicted: list.at(-1)?.evictedTotal ?? 0, }, paneActions($, laneId)) } catch (error) { diff --git a/hooks/views.tsx b/hooks/views.tsx index 7792a4a..25b96bb 100644 --- a/hooks/views.tsx +++ b/hooks/views.tsx @@ -17,6 +17,8 @@ type PaneModel = { rows: RosterRow[]; now: number; thread: Thread | undefined evicted?: number; capture?: HighCommandCapture | null rosterPage?: number; laneId?: string; laneIds?: string[]; lanePage?: number; agents?: HighCommandAgent[] + /** Each agent lane's unread count, by agent id. */ + laneUnread?: ReadonlyMap } /** @@ -199,9 +201,10 @@ export function paneTree(ui: HighCommandElements, model: PaneModel, actions: Pan