From ec1c7a8266df8696dbc60c50f1a592c5a1d22af3 Mon Sep 17 00:00:00 2001 From: Zachary Lowden Date: Sat, 31 Oct 2026 23:32:03 -0500 Subject: [PATCH 1/7] fix(attention): asks and waiting sessions name the exact tmux target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ask, a waiting session and their notifications named a mirrored window by its host's codename ("mango:1 @ ws", which is no tmux name) or by its tmux session alone with no window ("build @ ws"), so the operator could not find it. Every attention surface that names a mirrored window now takes its words from one helper, mirror.Where: "tmux : @ ", led by the codename when the host has one ("mango:1 · tmux scratch3:5 @ ws"). - chiefnudge.Session carries the window's Index (filled by stallInputs), and nudgeAskQuestion builds its "where" from mirror.Where; agents keep "(agent )". - A waiting session / held question / Hermes item's source is e.Where(). - A mirrored session's ask resolves the window running its Claude session NOW (claudeSessionEntry, the rule claudeSessionWindow already used) for its source on the queue, its page header, its session page's asks block and its notification; when no window runs it, its stored name is followed by "(not shown in any mirrored window now)" instead of an invented target. - Docs: attention.md "How a window is named", chief-nudges.md ask text. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/attention.md | 32 ++- docs/chief-nudges.md | 8 +- internal/api/attention.go | 57 +++++- internal/api/attention_ask_boxes_test.go | 6 +- internal/api/attention_ask_page.go | 2 +- internal/api/attention_asker.go | 2 +- internal/api/attention_push_target.go | 2 +- internal/api/attention_push_target_test.go | 10 +- internal/api/attention_suggest.go | 5 +- internal/api/attention_test.go | 4 +- internal/api/attention_ux_pg_test.go | 4 +- internal/api/attention_where_test.go | 219 +++++++++++++++++++++ internal/api/cards_attention.go | 14 +- internal/api/chief_nudge.go | 8 +- internal/api/chief_nudge_pg_test.go | 2 +- internal/api/unrouted_push_test.go | 6 +- internal/chiefnudge/chiefnudge.go | 4 + internal/mirror/order.go | 51 ++++- internal/mirror/where_test.go | 31 +++ 19 files changed, 423 insertions(+), 44 deletions(-) create mode 100644 internal/api/attention_where_test.go create mode 100644 internal/mirror/where_test.go diff --git a/docs/attention.md b/docs/attention.md index 5738ff13..19086677 100644 --- a/docs/attention.md +++ b/docs/attention.md @@ -14,7 +14,7 @@ creation), ties broken by the item's key: | kind | what | where it comes from | how you act on it | |---|---|---|---| | Question | An agent called `attention_ask`, or muster raised one itself, shown as asked by `muster`: a plugin's mention of an in-progress task when you have no chief ([task routing](plugins.md#routing-a-task-to-the-chief)), or an agent-mail message your chief did not act on ([plugins](plugins.md#a-message-the-chief-does-not-act-on)); nothing waits on its answer. | `attention_asks` (migration 0025; muster's own asker, 0028; the message link, 0051) | Tap one of the agent's options, or type an answer. **Dismiss without answering** withdraws it. | -| Session waiting | A mirrored Claude Code **or Hermes** window whose session reports `waiting` (a permission prompt, a question; for Hermes an approval, a clarify question or a sudo password). Shown with **what it waits on** (below), its focus title, its window @ host and its age. | The mirror hub's live window list; nothing is stored. | **Open the session** (the rendered view). A Hermes **approval** on a host with `-hermes-answer` also offers **Allow once** / **Deny**, answered through Hermes's gateway ([mirroring.md](mirroring.md)). It leaves the list when the session stops waiting. | +| Session waiting | A mirrored Claude Code **or Hermes** window whose session reports `waiting` (a permission prompt, a question; for Hermes an approval, a clarify question or a sudo password). Shown with **what it waits on** (below), its focus title, its exact tmux target ([``](#how-a-window-is-named)) and its age. | The mirror hub's live window list; nothing is stored. | **Open the session** (the rendered view). A Hermes **approval** on a host with `-hermes-answer` also offers **Allow once** / **Deny**, answered through Hermes's gateway ([mirroring.md](mirroring.md)). It leaves the list when the session stops waiting. | | Session question | A mirrored session's `AskUserQuestion`, registered by the `muster-mirror ask` hook and shown in the terminal and the rendered view at once. | The open question (`internal/askq`), live; it leaves the queue once answered in either place, dismissed, replaced by a newer question, or its session is gone. Such a window is this ONE item — its question, or, when it asks several, each one's first line, numbered — never also "Session waiting". | **Answer in the session**: the rendered view, where the question card is. | | Task to route | A plugin handed a task to your chief ([task routing](plugins.md#routing-a-task-to-the-chief)) and the chief has not routed it: there is no chief, it is not running or busy, or it took the event and did not act. Shown as the task's number and title. | `plugin_messages` task events with no routing or start recorded for their task at or after the event's latest delivery attempt, or its creation if it has had none (audit rows), whose task is not complete or dismissed; nothing is copied. | **Open the task**. It leaves the list when the chief routes the task or starts an agent on it, or the task is complete or dismissed. No notification of its own: the task's "New task" push already went. | | Reply draft | Your chief drafted a reply to a plugin message. Shown as the message's sender and its first 160 characters, and the draft reply's first 160. | `plugin_reply_drafts`, pending ones; nothing is copied. | Not on the Ask tab: with [plugins](plugins.md) on, the page has a second tab, **Reply drafts** (below), whose label carries the count ("Reply drafts (2)"). The draft is still in the badge's count, its push and `attention_list`. | @@ -108,8 +108,34 @@ command, path or URL, never what Claude said. | item | title | body | |---|---|---| | a question (an agent's, the chief's, muster's own) | ` is asking` | the question, on one line, then `Options: a · b · c` when it offered any — except muster's own ask about a [plugin message nobody acted on](plugins.md#a-message-the-chief-does-not-act-on), whose long fixed sentence would fill the body: its body is `From: · Subject: `, then the option (the page shows the whole question) | -| a held question (a session's `AskUserQuestion`) | ` @ is asking` | the question (each one's first line, numbered, when it asks several), then one question's choices (in the push only: `attention_list` carries no `options` for it, as it is answered on its question card, not with `attention_answer`) | -| a waiting session | `A session is waiting on you` | `Waiting: · @ `, or `Waiting: @ ` when its host names no tool | +| a held question (a session's `AskUserQuestion`) | ` is asking` | the question (each one's first line, numbered, when it asks several), then one question's choices (in the push only: `attention_list` carries no `options` for it, as it is answered on its question card, not with `attention_answer`) | +| a waiting session | `A session is waiting on you` | `Waiting: · `, or `Waiting: ` when its host names no tool | + +`` is the window's exact tmux target — see [How a window is named](#how-a-window-is-named). + +### How a window is named + +Every attention text that names a mirrored window — a chief nudge's ask, a +waiting session, a session question, a mirrored session's own ask (its source +on the queue, its page's "** asks" header and the asks block of its +session page), and their notifications — names it the same way +(`mirror.Where`), so you can go straight to it in a terminal: + +- `tmux : @ ` — the tmux session name and window + index, exactly as tmux's own target syntax takes them + (`tmux build:4 @ laptop`); +- led by the host's codename for the window when it has one + (`mango:1 · tmux scratch3:5 @ laptop`). The codename is a display name, + not a tmux name, so it is never the only one. + +The index is a snapshot: tmux renumbers windows, so the text says where the +window was when it was written (a chief nudge's ask, when it was raised; the +queue, its page and a notification, when they were drawn or sent). The **View +in session** link finds the window by its Claude session id when you follow +it, and is the source of truth. A mirrored session's ask whose Claude session +no window shows now reads as its stored name and host followed by +`(not shown in any mirrored window now)` — muster names no target it cannot +see. An agent's ask is named by the agent. Each line is folded to one line, anything shaped like a secret is redacted, and the body is cut to 200 characters on a character boundary with a visible `…` diff --git a/docs/chief-nudges.md b/docs/chief-nudges.md index ef148600..5605589a 100644 --- a/docs/chief-nudges.md +++ b/docs/chief-nudges.md @@ -362,7 +362,13 @@ chief judged; if it cannot read the session's state, the nudge stays queued and is tried again. **An ask** is an attention ask on your queue, asked by *your chief*, naming the -session's focus title and where it runs, the chief's question and its options. +session's focus title and where it runs, the chief's question and its options: +"** (**) stopped and is waiting on you: …", where ** +is a mirrored window's exact tmux target as of when the ask was raised +(`Fix CI (tmux build:4 @ laptop)`, or +`(mango:1 · tmux scratch3:5 @ laptop)` when its host gives the window a +codename — [attention.md](attention.md#how-a-window-is-named)), and an agent's +`(agent )`. It is pushed like any ask, and its page and its card on the Asks list link the session it is about (**View in session**: the window now running that Claude session, or the agent's page at the conversation that stalled), found through diff --git a/internal/api/attention.go b/internal/api/attention.go index d7512e75..54e38d50 100644 --- a/internal/api/attention.go +++ b/internal/api/attention.go @@ -261,7 +261,10 @@ func sessionKey(host, session, window string) string { return "session-" + hex.EncodeToString(sum[:8]) } -// askerLabel is what the view shows as an ask's source. +// askerLabel is an ask's source by what it stored: its label (for a mirrored +// session, its name and host when it asked — no tmux target), else its ids. +// What an OPEN ask shows as its source is askSource; this is the answered +// list's and its delivery line's, which send the operator nowhere. func askerLabel(a attention.Ask) string { if a.Asker.Label != "" { return a.Asker.Label @@ -272,8 +275,46 @@ func askerLabel(a attention.Ask) string { return a.Asker.MirrorSession + " @ " + a.Asker.MirrorHost } -func askItem(a attention.Ask) attentionItem { - it := attentionItem{Key: attentionAskKey(a.ID), Kind: attentionKindAsk, Source: askerLabel(a), Text: a.Question, +// askNotShown follows an asking mirrored session's stored name when no +// window lists its Claude session now: muster says so rather than name a tmux +// target it cannot vouch for. +const askNotShown = " (not shown in any mirrored window now)" + +// askSource is what an ask names as its source: for a mirrored session, the +// window running its Claude session NOW, by its exact tmux target +// (mirror.Where, resolved through claudeSessionEntry over hosts) — or its +// stored name and askNotShown when no window does; for every other asker (an +// agent, muster itself) askerLabel. +func askSource(a attention.Ask, hosts []mirror.HostView) string { + if a.Asker.MirrorSession == "" || a.Asker.AgentID > 0 || a.Asker.System { + return askerLabel(a) + } + if e, ok := claudeSessionEntry(hosts, a.Asker.MirrorHost, a.Asker.MirrorSession); ok { + return e.Where() + } + return askerLabel(a) + askNotShown +} + +// askNeedsHosts reports whether askItem needs the owner's windows for a +// (askSource): only a mirrored session's ask does. +func askNeedsHosts(a attention.Ask) bool { + return a.Asker.MirrorSession != "" && a.Asker.AgentID == 0 && !a.Asker.System +} + +// askItemNow is askItem over ctx's principal's windows, read only when a +// needs them (askNeedsHosts). +func (s *Server) askItemNow(ctx context.Context, a attention.Ask) attentionItem { + var hosts []mirror.HostView + if askNeedsHosts(a) && s.ext.Mirror != nil { + hosts = s.ext.Mirror.Hosts(ctx) + } + return askItem(a, hosts) +} + +// askItem is ask a as an attention item; hosts is its owner's windows, which +// a mirrored session's ask names its source from (askSource). +func askItem(a attention.Ask, hosts []mirror.HostView) attentionItem { + it := attentionItem{Key: attentionAskKey(a.ID), Kind: attentionKindAsk, Source: askSource(a, hosts), Text: a.Question, Options: a.Options, Since: a.CreatedAt, Link: attentionPath + "#" + attentionAskKey(a.ID), AskID: a.ID, suggestions: a.Suggestions} if a.Asker.System { it.systemAsk = true @@ -388,7 +429,7 @@ func (s *Server) waitingSessions(ctx context.Context) []attentionItem { continue } ref := ui.MirrorRef{Host: e.Host, Session: e.Session, Window: e.Window} - it := attentionItem{Key: k, Kind: attentionKindSession, Source: e.DisplayTitle() + " @ " + e.Host, Text: sessionWaitingText, host: e.Host, + it := attentionItem{Key: k, Kind: attentionKindSession, Source: e.Where(), Text: sessionWaitingText, host: e.Host, claudeSession: e.ClaudeSession, Offline: wt.offline, Link: ref.ViewPath(), card: attentionCard{ref: ref}} if e.ClaudeSession != "" { it.subject = focus.MirrorSubject(e.ClaudeSession) @@ -528,13 +569,19 @@ func (s *Server) attentionItems(ctx context.Context) ([]attentionItem, error) { if err != nil { errs = append(errs, fmt.Errorf("asks: %w", err)) } + // The owner's windows, read once and only when a mirrored ask needs them. + var hosts []mirror.HostView + listed := false for _, a := range asks { // A question to the chief is the chief's (chief_ask_tool.go), not // the owner's: the chief asks the owner with attention_ask if it must. if a.ToChief { continue } - out = append(out, askItem(a)) + if askNeedsHosts(a) && !listed && s.ext.Mirror != nil { + hosts, listed = s.ext.Mirror.Hosts(ctx), true + } + out = append(out, askItem(a, hosts)) } } out = append(out, s.waitingSessions(ctx)...) diff --git a/internal/api/attention_ask_boxes_test.go b/internal/api/attention_ask_boxes_test.go index b5cae29e..0ebf8d3c 100644 --- a/internal/api/attention_ask_boxes_test.go +++ b/internal/api/attention_ask_boxes_test.go @@ -76,7 +76,7 @@ func boxServer(t *testing.T, dictate bool) *Server { } func systemAsk(id int64) attentionItem { - return askItem(attention.Ask{ID: id, Owner: 1, Asker: attention.Asker{System: true, Label: chiefNudgeAskLabel}, Question: "q", CreatedAt: time.Now()}) + return askItem(attention.Ask{ID: id, Owner: 1, Asker: attention.Asker{System: true, Label: chiefNudgeAskLabel}, Question: "q", CreatedAt: time.Now()}, nil) } // TestAChiefNudgesAskLinksTheSessionItIsAbout: a nudge's ask is muster's own @@ -148,8 +148,8 @@ var dictateTag = regexp.MustCompile(`