diff --git a/docs/attention.md b/docs/attention.md index 24d21cea..3787d516 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,44 @@ 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), an answered ask's card and its delivery line ("Queued for …", +"Waiting for … to read it"), and their notifications — names it the same way +(`mirror.Where`), so you can go straight to it in a terminal. The chief reads +the same text as `where` (`fleet_list`, `sessions_overview`, `fleet_report`, +a session question's block, its session hints) and is told to give it exactly +when it points you at a session: + +- `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; one whose host has just dropped (the hub keeps listing it briefly) is +followed by `(host offline)`. An agent's ask is named by the agent. + +The target is shown **verbatim**: a page or notification plains a title's +markdown, but only the codename around the target, never the +`tmux : @ ` part — a session named `_scratch_` is shown +as `_scratch_`, not `scratch`. 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 `…` @@ -643,7 +679,7 @@ you, your mirror hosts' busy and idle Claude windows, and your | `items[].key` | The item's id (`ask-`, `session-`, `draft-`, `task-`). A held question has its window's `session-…` key. | | `items[].kind` | `ask`, `session`, `question`, `plugin` (a reply draft) or `task`. | | `items[].host` | The mirror host it is on; `""` for an agent's ask, muster's own, a reply draft and a task. | -| `items[].title`, `items[].why` | What its notification says: the source as a title, and the first line of the notification body. Secrets are redacted, a waiting session is `Waiting: · @ `, a reply draft is who and where only. Never Claude's last words, an ask's options or its suggested replies. | +| `items[].title`, `items[].why` | What its notification says: the source as a title, and the first line of the notification body. Secrets are redacted, a waiting session is `Waiting: · ` (its exact tmux target, [How a window is named](#how-a-window-is-named), kept verbatim), a reply draft is who and where only. Never Claude's last words, an ask's options or its suggested replies. | | `items[].since`, `items[].age_secs` | When it started waiting (Unix seconds) and `ts` minus that; `0` and `0` when unknown. | | `items[].offline` | A session item whose host dropped within the last 5 minutes, shown as last seen. | | `items[].url` | Where to act on it, on `MUSTER_PUBLIC_URL`: the [tap route](push.md) `/attention/open/` for an ask or a session (resolved when opened), the item's own page for a draft or a task. **`""` when `MUSTER_PUBLIC_URL` is not set**: the summary names no origin it was not told. | diff --git a/docs/chief-nudges.md b/docs/chief-nudges.md index 138b0705..ba900075 100644 --- a/docs/chief-nudges.md +++ b/docs/chief-nudges.md @@ -376,7 +376,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/docs/focus.md b/docs/focus.md index 2229ba7c..e35cad78 100644 --- a/docs/focus.md +++ b/docs/focus.md @@ -171,10 +171,10 @@ the chief that focus is data, never instructions. Neither makes the text safe | tool | who | what | |---|---|---| -| `fleet_list` | chief | every session: id, kind, name, status, focus title, and `focus_problem` (the reason) when its focus is missing or stale | +| `fleet_list` | chief | every session: id, kind, name, status, a mirrored window's `where` (its exact tmux target, [attention.md](attention.md#how-a-window-is-named)), focus title, and `focus_problem` (the reason) when its focus is missing or stale | | `focus_search(query, limit, since_hours?)` | chief | full-text search over titles (weighted higher) and descriptions; id, kind, title and a 120-character snippet per hit. With `since_hours` (1–24) it searches every focus each session had in that window instead of its current one, and each hit carries when that focus was written | -| `sessions_overview(limit?)` | chief | every live session (running agents, online mirrored windows) active in the last 24 h, newest-active first: id, kind, name, host, status, current focus, the distinct focus titles of the last 6 h with their times, its pending attention item, and its last three user and assistant messages (300 characters each, secrets redacted, no tool output). A session whose transcript does not arrive within 5 s reads `"transcript": "unavailable (timed out)"`; all the reads share a 12 s budget, and a session it cuts or never reaches reads `"unavailable (overview budget)"`. The answer is capped at 32 KiB and says how many sessions it left out | -| `fleet_report(since, until?)` | chief | every session of the owner's — running agents and mirrored windows, **ended ones included** — with focus activity between `since` and `until` (RFC 3339; `until` defaults to now; at most 4 days apart), most recently active first: id, kind, name, host, repo, state (`waiting`: an attention item is pending; `active`: live now; `ended`), first and last seen, the distinct focus titles of the window with their times (at most 20 per session, the newest), and the pull requests it created or pushed. The chief itself is left out. Capped at 32 KiB; `coverage` says how many sessions were left out and what did not answer (an offline host, a store that could not be read). It shows the owner **everything**, hosts included and unfiltered, because its answer never leaves muster; the same report answering a [fleet-report request](plugins.md#a-fleet-report-request) is narrowed to `MUSTER_FLEET_REPORT_PATHS`, with no hosts and secret-shaped strings removed | +| `sessions_overview(limit?)` | chief | every live session (running agents, online mirrored windows) active in the last 24 h, newest-active first: id, kind, name, host, a mirrored window's `where`, status, current focus, the distinct focus titles of the last 6 h with their times, its pending attention item, and its last three user and assistant messages (300 characters each, secrets redacted, no tool output). A session whose transcript does not arrive within 5 s reads `"transcript": "unavailable (timed out)"`; all the reads share a 12 s budget, and a session it cuts or never reaches reads `"unavailable (overview budget)"`. The answer is capped at 32 KiB and says how many sessions it left out | +| `fleet_report(since, until?)` | chief | every session of the owner's — running agents and mirrored windows, **ended ones included** — with focus activity between `since` and `until` (RFC 3339; `until` defaults to now; at most 4 days apart), most recently active first: id, kind, name, host, a listed window's `where` (this door only: the fleet-report reply below never carries it), repo, state (`waiting`: an attention item is pending; `active`: live now; `ended`), first and last seen, the distinct focus titles of the window with their times (at most 20 per session, the newest), and the pull requests it created or pushed. The chief itself is left out. Capped at 32 KiB; `coverage` says how many sessions were left out and what did not answer (an offline host, a store that could not be read). It shows the owner **everything**, hosts included and unfiltered, because its answer never leaves muster; the same report answering a [fleet-report request](plugins.md#a-fleet-report-request) is narrowed to `MUSTER_FLEET_REPORT_PATHS`, with no hosts and secret-shaped strings removed | All four are on the agent gateway and on `/mcp`. A subject is spelled `agent:` or `mirror:`, as `fleet_list` and diff --git a/internal/agentspec/instructions.go b/internal/agentspec/instructions.go index af206a06..96b6749d 100644 --- a/internal/agentspec/instructions.go +++ b/internal/agentspec/instructions.go @@ -275,6 +275,11 @@ const chiefLiveReads = `This costs you less than it sounds. Everything you need every time you fetch it — so you do not need to remember the state of the work, you need to go and look at it. Answer from the read, every time. +When you point your owner at a mirrored session — in a question you ask them or +anywhere else — give its ` + "`where`" + ` exactly as your read of the fleet shows it: the +terminal session and window index on its host. A window's codename or title alone is +not a place they can find. + ` // chiefCairnSection is concatenated ONLY when the store credential is configured. diff --git a/internal/agentspec/instructions_test.go b/internal/agentspec/instructions_test.go index 47789806..15d746c5 100644 --- a/internal/agentspec/instructions_test.go +++ b/internal/agentspec/instructions_test.go @@ -352,7 +352,7 @@ func TestNoTextReachesTheSupervisorPromptWithoutAPin(t *testing.T) { { name: "chiefLiveReads", text: chiefLiveReads, - want: "84ea975f062c83be67aec7d3c1257c32ddc3bd87652ec95b54adbf96b0a2babb", + want: "783e3e0b5a8fef0abf423065d3d811b45e042ee5e41ba7ff560f02c986f6b30f", min: 150, }, { diff --git a/internal/api/attention.go b/internal/api/attention.go index 2e04feea..fd36034f 100644 --- a/internal/api/attention.go +++ b/internal/api/attention.go @@ -8,6 +8,7 @@ import ( "fmt" "html" "net/http" + "slices" "sort" "strconv" "strings" @@ -161,6 +162,11 @@ type attentionItem struct { // Source names who is waiting: the asking agent or session, the window, // the plugin. Source string `json:"source"` + // target is the part of Source that names a mirrored window's tmux target + // (mirror.WhereTarget), "" for none: every display plains Source with + // textplain.TitleKeeping(Source, target), so the target reaches the + // reader verbatim and only the label around it is plained. + target string // Text is the item's SUBSTANCE in one line or a few: the question; what a // waiting session waits on (its pending action, mirror.Pending.Summary — // sessionWaitingText when its host says nothing); a held AskUserQuestion's @@ -262,7 +268,9 @@ 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. +// Every surface shows askSource, which falls back to this. func askerLabel(a attention.Ask) string { if a.Asker.Label != "" { return a.Asker.Label @@ -273,8 +281,68 @@ 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)" + +// whereHostOffline follows a where whose host has dropped: the hub still +// lists its windows for a grace period, so the target is the last one known. +const whereHostOffline = " (host offline)" + +// 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 { + src, _ := askSourceParts(a, hosts) + return src +} + +// askSourceParts is askSource and, when it names a window, that window's +// target (mirror.WhereTarget) — the part a display keeps verbatim +// (textplain.TitleKeeping); "" otherwise. +func askSourceParts(a attention.Ask, hosts []mirror.HostView) (src, target 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 { + if !e.Online { + return e.Where() + whereHostOffline, e.WhereTarget() + } + return e.Where(), e.WhereTarget() + } + 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 +} + +// askHosts is ctx's principal's windows when a needs them (askNeedsHosts), +// else nil. +func (s *Server) askHosts(ctx context.Context, a attention.Ask) []mirror.HostView { + if askNeedsHosts(a) && s.ext.Mirror != nil { + return s.ext.Mirror.Hosts(ctx) + } + return nil +} + +// askItemNow is askItem over ctx's principal's windows, read only when a +// needs them (askHosts). +func (s *Server) askItemNow(ctx context.Context, a attention.Ask) attentionItem { + return askItem(a, s.askHosts(ctx, a)...) +} + +// 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) — none given, it +// is named as no window showing it. +func askItem(a attention.Ask, hosts ...mirror.HostView) attentionItem { + src, target := askSourceParts(a, hosts) + it := attentionItem{Key: attentionAskKey(a.ID), Kind: attentionKindAsk, Source: src, target: target, 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 @@ -399,7 +467,7 @@ func (s *Server) waitingSessionsIn(ctx context.Context, hs []mirror.HostView) [] 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(), target: e.WhereTarget(), 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) @@ -555,7 +623,9 @@ func (s *Server) attentionItemsIn(ctx context.Context, hs []mirror.HostView) ([] if a.ToChief { continue } - out = append(out, askItem(a)) + // A mirrored session's ask names its window from the SAME read of + // the hub as the waiting sessions below (hs): one read per build. + out = append(out, askItem(a, hs...)) } } out = append(out, s.waitingSessionsIn(ctx, hs)...) @@ -662,7 +732,7 @@ func (s *Server) attentionNotify(owner int64, it attentionItem) { // without alerting again. func attentionNotification(it attentionItem, update bool) RouterNotification { n := RouterNotification{Type: attentionNotificationType, ID: it.Key, Title: attentionPushTitle(it), Body: attentionPushBody(it), - Tag: attentionTag(it.Key), Data: map[string]string{"url": attentionOpenURL(it.Key)}, Update: update} + Tag: attentionTag(it.Key), Data: map[string]string{"url": attentionOpenURL(it.Key)}, Update: update, TitleKeep: it.target} if it.Kind == attentionKindAsk && it.AskID > 0 { n.Ask = &push.V2Ask{ID: it.AskID, Options: cleanChips(it.Options), Suggestions: cleanChips(it.suggestions)} // The signed ask actions are routed wherever the asks are @@ -738,9 +808,9 @@ func attentionPushBody(it attentionItem) string { } return DraftPushBody case attentionKindSession: - body := "Waiting: " + textplain.Title(it.Source) + body := "Waiting: " + textplain.TitleKeeping(it.Source, it.target) if tool := mirror.OneLine(it.tool, mirror.MaxPendingTool); tool != "" { - body = "Waiting: " + tool + " · " + textplain.Title(it.Source) + body = "Waiting: " + tool + " · " + textplain.TitleKeeping(it.Source, it.target) } return push.CutClusters(mirror.OneLine(body, 1<<20), attentionPushBodyMax) } @@ -1038,7 +1108,7 @@ func (s *Server) renderAsksPage(w http.ResponseWriter, r *http.Request, code int b.WriteString(attentionNotice(r)) now := time.Now() askList := `
    ` + - askTabListHTML(items, answeredAsksHTML(s.recentAnswers(r.Context()), now), now) + `
` + askTabListHTML(items, s.answeredAsksNow(r.Context(), now), now) + `` if s.ext.Plugins == nil && !s.chiefNudgesOn() { b.WriteString(askList) } else { @@ -1089,7 +1159,7 @@ func (s *Server) handleAttentionItems(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") now := time.Now() - _, _ = w.Write([]byte(askTabListHTML(items, answeredAsksHTML(s.recentAnswers(r.Context()), now), now))) + _, _ = w.Write([]byte(askTabListHTML(items, s.answeredAsksNow(r.Context(), now), now))) } func (s *Server) handleAttentionBadge(w http.ResponseWriter, r *http.Request) { @@ -1142,7 +1212,7 @@ func attentionItemsHTMLTo(items []attentionItem, now time.Time, returnTo string) offline = ` · host offline` } b.WriteString(`
  • -` + esc(kind) + ` · ` + esc(textplain.Title(it.Source)) + `` + age + offline + ` +` + esc(kind) + ` · ` + esc(textplain.TitleKeeping(it.Source, it.target)) + `` + age + offline + ` `) // Source (an agent's or window's name) and Focus (an LLM summary) are // titles, so they read as their plain text (textplain). @@ -1394,9 +1464,11 @@ func (s *Server) recentAnswers(ctx context.Context) []attention.Ask { } // answerDeliveryText is where an answer went, as the owner reads it, and -// whether it offers Retry. -func answerDeliveryText(a attention.Ask) (string, bool) { - who := askerLabel(a) +// whether it offers Retry. who is the asker as askSource names it — for a +// mirrored session the exact tmux target of the window running it now, or +// "not shown" — since a waiting, queued or failed delivery sends the owner to +// that session. +func answerDeliveryText(a attention.Ask, who string) (string, bool) { switch a.Delivery { case attention.DeliveryQueued: if a.DeliveryDetail != "" { @@ -1420,6 +1492,17 @@ func answerDeliveryText(a attention.Ask) (string, bool) { return "Not delivered.", false } +// answeredAsksNow is answeredAsksHTML over ctx's principal's recent answers +// and, when one of them is a mirrored session's, its windows (askSource). +func (s *Server) answeredAsksNow(ctx context.Context, now time.Time) string { + asks := s.recentAnswers(ctx) + var hosts []mirror.HostView + if s.ext.Mirror != nil && slices.ContainsFunc(asks, askNeedsHosts) { + hosts = s.ext.Mirror.Hosts(ctx) + } + return answeredAsksHTML(asks, hosts, now) +} + // answeredAsksHTML is the "Answered" part of the list: each recent answer and // where it went, with Retry on a failed delivery. // answerLabel names who gave an answer on the Answered list: the owner, or — @@ -1431,7 +1514,7 @@ func answerLabel(a attention.Ask) string { return "Your answer: " } -func answeredAsksHTML(asks []attention.Ask, now time.Time) string { +func answeredAsksHTML(asks []attention.Ask, hosts []mirror.HostView, now time.Time) string { if len(asks) == 0 { return "" } @@ -1440,13 +1523,14 @@ func answeredAsksHTML(asks []attention.Ask, now time.Time) string { b.WriteString(`
  • Answered
  • ` + "\n") for _, a := range asks { id := strconv.FormatInt(a.ID, 10) - text, retry := answerDeliveryText(a) + who := textplain.TitleKeeping(askSourceParts(a, hosts)) + text, retry := answerDeliveryText(a, who) age := "" if !a.ResolvedAt.IsZero() { age = ` · ` } b.WriteString(`
  • -Answered · ` + esc(askerLabel(a)) + `` + age + ` +Answered · ` + esc(who) + `` + age + `

    ` + esc(mirror.OneLine(a.Question, 200)) + `

    ` + answerLabel(a) + esc(a.Answer) + `

    ` + answeredViaHTML(a) + `

    ` + esc(text) + `

    diff --git a/internal/api/attention_ask_page.go b/internal/api/attention_ask_page.go index 77ff0df2..45881ba7 100644 --- a/internal/api/attention_ask_page.go +++ b/internal/api/attention_ask_page.go @@ -78,7 +78,7 @@ func (s *Server) askShowsSuggestions(ctx context.Context, a attention.Ask) bool // renderAskPage writes ask a's page (or, for ?part=live, its live region). func (s *Server) renderAskPage(w http.ResponseWriter, r *http.Request, a attention.Ask) { - it := askItem(a) + it := s.askItemNow(r.Context(), a) if !s.askShowsSuggestions(r.Context(), a) { it.suggestions = nil } @@ -91,7 +91,7 @@ func (s *Server) renderAskPage(w http.ResponseWriter, r *http.Request, a attenti return } esc := html.EscapeString - source := textplain.Title(it.Source) + source := textplain.TitleKeeping(it.Source, it.target) var b strings.Builder b.WriteString(`
    @@ -169,7 +169,7 @@ func askLiveHTML(a attention.Ask, it attentionItem) string { `) } if a.Status == attention.Answered { - text, _ := answerDeliveryText(a) + text, _ := answerDeliveryText(a, textplain.TitleKeeping(it.Source, it.target)) b.WriteString(`

    ` + esc(text) + `

    `) } diff --git a/internal/api/attention_asker.go b/internal/api/attention_asker.go index c8f2341a..49b8aa71 100644 --- a/internal/api/attention_asker.go +++ b/internal/api/attention_asker.go @@ -116,7 +116,11 @@ func (s *Server) askerAsks(ctx context.Context, r askerRef) []attentionItem { } var out []attentionItem for _, a := range asks { - it := askItem(a) + var hs []mirror.HostView + if askNeedsHosts(a) { + hs = hostViews() + } + it := askItem(a, hs...) switch { case agentID > 0: if it.card.agentID != agentID { diff --git a/internal/api/attention_push_target.go b/internal/api/attention_push_target.go index e7a2a0f7..36293746 100644 --- a/internal/api/attention_push_target.go +++ b/internal/api/attention_push_target.go @@ -95,7 +95,7 @@ func (s *Server) openTarget(ctx context.Context, key string) string { a, err := s.ext.Attention.Get(ctx, id) switch { case err == nil && a.Owner == owner && a.Status == attention.Open: - it, found = askItem(a), true + it, found = s.askItemNow(ctx, a), true case err != nil && !errors.Is(err, attention.ErrNotFound): s.logger.Printf("attention: the tap target of %s: %v (opening the queue instead)", key, err) } diff --git a/internal/api/attention_suggest.go b/internal/api/attention_suggest.go index aba7f7fc..ed627ea0 100644 --- a/internal/api/attention_suggest.go +++ b/internal/api/attention_suggest.go @@ -48,7 +48,7 @@ func askSuggestSubject(id int64) string { return "ask:" + strconv.FormatInt(id, // once, without suggestions), the views, and — in the background — its // suggested replies. func (s *Server) askFiled(a attention.Ask) { - s.attentionNotify(a.Owner, askItem(a)) + s.attentionNotify(a.Owner, s.askItemNow(s.attentionPrincipal(context.Background(), a.Owner), a)) s.attentionChanged(a.Owner, attentionAskKey(a.ID)) s.startAskSuggestions(a) } @@ -122,7 +122,8 @@ func (s *Server) suggestAsk(ctx context.Context, a attention.Ask) { // again). Once delivered, the ask is read again: one settled meanwhile — its // clear may have overtaken this push — is withdrawn again. func (s *Server) repushAsk(a attention.Ask) { - s.goNotify(a.Owner, push.EventAttention, attentionNotification(askItem(a), true), func(int) { + it := s.askItemNow(s.attentionPrincipal(context.Background(), a.Owner), a) + s.goNotify(a.Owner, push.EventAttention, attentionNotification(it, true), func(int) { ctx, cancel := context.WithTimeout(s.attentionPrincipal(context.Background(), a.Owner), pushTargetLookupTimeout) defer cancel() cur, err := s.ext.Attention.Get(ctx, a.ID) diff --git a/internal/api/attention_summary.go b/internal/api/attention_summary.go index 6b6072af..bb9d42f6 100644 --- a/internal/api/attention_summary.go +++ b/internal/api/attention_summary.go @@ -156,7 +156,7 @@ func attentionSummaryHostOf(h mirror.HostView) attentionSummaryHost { // attentionSummaryItemOf is it as the summary lists it. base is the public // origin without a trailing slash, "" for none. func attentionSummaryItemOf(it attentionItem, base string, now time.Time) attentionSummaryItem { - out := attentionSummaryItem{Key: it.Key, Kind: it.Kind, Host: it.host, Title: textplain.Title(it.Source), + out := attentionSummaryItem{Key: it.Key, Kind: it.Kind, Host: it.host, Title: textplain.TitleKeeping(it.Source, it.target), Why: firstLine(attentionPushBody(it)), Offline: it.Offline} if out.Host == "" { out.Host = it.card.ref.Host // an ask from a mirrored session diff --git a/internal/api/attention_summary_pg_test.go b/internal/api/attention_summary_pg_test.go index daf6e8c4..b395bb1c 100644 --- a/internal/api/attention_summary_pg_test.go +++ b/internal/api/attention_summary_pg_test.go @@ -158,9 +158,9 @@ func TestTheSummaryIsTheOwnersQueueAndHosts(t *testing.T) { } sess := sessionKey("ws", "main", "@1") for key, want := range map[string]struct{ kind, host, title, why, url string }{ - attentionAskKey(mirrored.ID): {"ask", "ws", "Gold:1", "Use «redacted» for the mirror?", base + attentionOpenPath + attentionAskKey(mirrored.ID)}, + attentionAskKey(mirrored.ID): {"ask", "ws", "tmux main:1 @ ws", "Use «redacted» for the mirror?", base + attentionOpenPath + attentionAskKey(mirrored.ID)}, attentionAskKey(agentAsk): {"ask", "", f.op.Name, "Which bucket?", base + attentionOpenPath + attentionAskKey(agentAsk)}, - sess: {"session", "ws", "main:1 @ ws", "Waiting: main:1 @ ws", base + attentionOpenPath + sess}, + sess: {"session", "ws", "tmux main:1 @ ws", "Waiting: tmux main:1 @ ws", base + attentionOpenPath + sess}, draftKey(d.ID): {"plugin", "", "Attention inbox", "Reply draft: Dana (Attention inbox)", base + draftLink(d.ID)}, } { it, ok := byKey[key] @@ -205,7 +205,12 @@ func TestTheSummaryIsTheOwnersQueueAndHosts(t *testing.T) { if got := strings.Join(summaryKeys(msum), ","); got != attentionAskKey(memberAsk) || len(msum.Hosts) != 0 { t.Fatalf("the member's summary: keys %s hosts %+v; want only %s and no hosts", got, msum.Hosts, attentionAskKey(memberAsk)) } - for _, leak := range []string{"Which bucket?", "Gold:1", "Dana", `"ws"`} { + // "tmux main:1": the operator's windows' targets, which its own summary + // carries (its titles above) and the member's must not. + if !strings.Contains(raw, "tmux main:1 @ ws") { + t.Fatalf("control: the operator's summary does not name its window's target: %s", raw) + } + for _, leak := range []string{"Which bucket?", "Gold:1", "tmux main:1", "Dana", `"ws"`} { if strings.Contains(mraw, leak) { t.Errorf("the member's summary carries the operator's %q", leak) } diff --git a/internal/api/attention_summary_test.go b/internal/api/attention_summary_test.go index a2968c70..d0b9fd88 100644 --- a/internal/api/attention_summary_test.go +++ b/internal/api/attention_summary_test.go @@ -34,8 +34,13 @@ var summaryNow = time.Unix(1_800_000_000, 0) // summary says what the lock screen says and no more. func TestTheSummaryItemSaysWhatItsPushSays(t *testing.T) { sessKey := sessionKey("host-b", "main", "@3") + // The window running the asking session (its codename's index is NOT its + // tmux index): the title names it as askSource does, mirror.Where. + mirrorHosts := []mirror.HostView{{Host: "ws", Online: true, Windows: []mirror.Window{ + {Session: "main", ID: "@7", Index: 4, Label: "Gold:1", Claude: true, ClaudeSession: focusSID}}}} mirrorAsk := askItem(attention.Ask{ID: 41, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: focusSID, Label: "Gold:1"}, - Question: "Use ghp_0123abcd9 for the mirror?\n\nIt is the only key.", Options: []string{"yes", "no"}, CreatedAt: summaryNow.Add(-90 * time.Second)}) + Question: "Use ghp_0123abcd9 for the mirror?\n\nIt is the only key.", Options: []string{"yes", "no"}, CreatedAt: summaryNow.Add(-90 * time.Second)}, + mirrorHosts...) agentAsk := askItem(attention.Ask{ID: 42, Asker: attention.Asker{AgentID: 7, Label: "deploy-bot"}, Question: "Deploy the importer?", Options: []string{"deploy now", "wait"}, Suggestions: []string{"go ahead"}, CreatedAt: summaryNow.Add(-2 * time.Hour)}) draft := attentionItem{Key: draftKey(3), Kind: attentionKindPlugin, Source: "Discord DMs", @@ -48,7 +53,7 @@ func TestTheSummaryItemSaysWhatItsPushSays(t *testing.T) { want attentionSummaryItem }{ {"an ask from a mirrored session: its host, the question redacted, never its options", mirrorAsk, attentionSummaryItem{ - Key: "ask-41", Kind: "ask", Host: "ws", Title: "Gold:1", Why: "Use «redacted» for the mirror? It is the only key.", + Key: "ask-41", Kind: "ask", Host: "ws", Title: "Gold:1 · tmux main:4 @ ws", Why: "Use «redacted» for the mirror? It is the only key.", Since: 1_799_999_910, AgeSecs: 90, URL: "https://muster.example.test/attention/open/ask-41"}}, {"an agent's ask: no host, never its options or suggestions", agentAsk, attentionSummaryItem{ Key: "ask-42", Kind: "ask", Title: "deploy-bot", Why: "Deploy the importer?", diff --git a/internal/api/attention_ux_pg_test.go b/internal/api/attention_ux_pg_test.go index 7c490941..f2dec579 100644 --- a/internal/api/attention_ux_pg_test.go +++ b/internal/api/attention_ux_pg_test.go @@ -166,7 +166,7 @@ func TestAWaitingSessionShowsWhatItWaitsOn(t *testing.T) { } page := f.page(t, "/attention/items") for _, want := range []string{"Bash: npm publish --tag next", `data-attention-detail>Tests pass. Publishing [31mnow [0m.

    `, - `data-attention-focus>Publish the CLI release`, `data-attention-source>main:1 @ ws`} { + `data-attention-focus>Publish the CLI release`, `data-attention-source>tmux main:1 @ ws`} { if !strings.Contains(page, want) { t.Fatalf("the page lacks %q: %s", want, page) } @@ -180,7 +180,7 @@ func TestAWaitingSessionShowsWhatItWaitsOn(t *testing.T) { // The notification says who and where, and the tool's NAME — never the // command or Claude's words (operator decision: a waiting session stays // summary only; the page has them). - if body != "Waiting: Bash · main:1 @ ws" { + if body != "Waiting: Bash · tmux main:1 @ ws" { t.Fatalf("the push body: %q", body) } } diff --git a/internal/api/attention_where_chief_test.go b/internal/api/attention_where_chief_test.go new file mode 100644 index 00000000..7c285b32 --- /dev/null +++ b/internal/api/attention_where_chief_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/ZacxDev/muster/internal/agents" + "github.com/ZacxDev/muster/internal/attention" + "github.com/ZacxDev/muster/internal/focus" +) + +// TestASessionQuestionToTheChiefSaysWhereTheWindowIs: the block the chief +// reads names the asking window by askSource — its exact tmux target, or that +// no window shows it (never an invented one). +func TestASessionQuestionToTheChiefSaysWhereTheWindowIs(t *testing.T) { + s := whereServer(t) + hosts := s.ext.Mirror.Hosts(s.attentionPrincipal(t.Context(), 1)) + for _, c := range []struct { + sid, label, want string + }{ + {whereLabelledSID, "mango:1 @ ws", "\n| where: mango:1 · tmux scratch3:5 @ ws\n"}, + {whereGoneSID, "pear:2 @ ws", "\n| where: pear:2 @ ws (not shown in any mirrored window now)\n"}, + } { + q := attention.Ask{ID: 7, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: c.sid, Label: c.label}, Question: "Which branch?"} + if got := renderChiefQuestion(q, askSource(q, hosts), nil, hintTestNonce); !strings.Contains(got, c.want) { + t.Errorf("%s: block lacks %q:\n%s", c.sid, c.want, got) + } + } +} + +// TestTheOverviewAndTheRouteCandidatesCarryEachWindowsWhere: the live +// sessions sessions_overview (and the session hints) is built from, and the +// candidates a refused route subject lists, carry each window's where. +func TestTheOverviewAndTheRouteCandidatesCarryEachWindowsWhere(t *testing.T) { + s := whereServer(t) + ctx := s.attentionPrincipal(t.Context(), 1) + want := map[string]string{ + "mirror:" + whereLabelledSID: "mango:1 · tmux scratch3:5 @ ws", + "mirror:" + whereUnlabelledSID: "tmux build:7 @ ws", + } + live := s.liveSessions(ctx, 0) + if len(live) != len(want) { + t.Fatalf("%d live sessions, want %d", len(live), len(want)) + } + for _, ls := range live { + if ls.where != want[ls.sub.String()] { + t.Errorf("%s: where %q, want %q", ls.sub, ls.where, want[ls.sub.String()]) + } + } + s.ext.Focus = &memFocus{rows: map[focus.Subject]focus.Focus{}} + var ov struct { + Sessions []struct { + ID string `json:"id"` + Where string `json:"where"` + } `json:"sessions"` + } + if err := json.Unmarshal([]byte(s.toolSessionsOverview(ctx, agents.Agent{ID: 999}, `{}`)), &ov); err != nil || len(ov.Sessions) != len(want) { + t.Fatalf("sessions_overview: %+v %v", ov, err) + } + for _, row := range ov.Sessions { + if row.Where != want[row.ID] { + t.Errorf("sessions_overview %s: where %q, want %q", row.ID, row.Where, want[row.ID]) + } + } + _, refusal := s.routeSubjectFor(ctx, "nope") + var r struct { + Candidates []routeCandidate `json:"candidates"` + } + if err := json.Unmarshal([]byte(strings.TrimPrefix(refusal, "error: ")), &r); err != nil || len(r.Candidates) != len(want) { + t.Fatalf("refusal %q: %v", refusal, err) + } + for _, c := range r.Candidates { + if c.Where != want[c.Subject] { + t.Errorf("%s: where %q, want %q", c.Subject, c.Where, want[c.Subject]) + } + } +} + +const hintTestNonce = "0123456789abcdef" diff --git a/internal/api/attention_where_delivery_test.go b/internal/api/attention_where_delivery_test.go new file mode 100644 index 00000000..9a4cff2a --- /dev/null +++ b/internal/api/attention_where_delivery_test.go @@ -0,0 +1,127 @@ +package api + +import ( + "context" + "encoding/json" + "html" + "net/http/httptest" + "regexp" + "testing" + "time" + + "github.com/ZacxDev/muster/internal/attention" +) + +// WHERE AN ANSWER'S DELIVERY SENDS THE OPERATOR, AND WHERE THE CHIEF SEES A +// SESSION: an answered ask's card and its delivery line ("Queued for X…", +// "Waiting for X to read it…") name the asking session's window by its exact +// tmux target (askSource), as an open ask does — or say no window shows it; +// and the chief's fleet_list carries the same `where` for each window, so an +// ask the chief writes can name it. Fixture: whereServer (attention_where_test.go). + +// whereAnswered is an attention store whose recent answers are answered. +type whereAnswered struct { + openAsks + answered []attention.Ask +} + +func (w whereAnswered) Answered(context.Context, time.Time, int) ([]attention.Ask, error) { + return w.answered, nil +} + +func whereAnsweredAsks() []attention.Ask { + at := time.Now().Add(-time.Minute) + ans := func(id int64, sid, label string, d attention.Delivery, detail string) attention.Ask { + return attention.Ask{ID: id, Owner: 1, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: sid, Label: label}, + Question: "Which branch?", Status: attention.Answered, Answer: "main", CreatedAt: at, ResolvedAt: at, + Delivery: d, DeliveryDetail: detail} + } + return []attention.Ask{ + ans(101, whereLabelledSID, "mango:1 @ ws", attention.DeliveryQueued, "the session is busy"), + ans(102, whereGoneSID, "pear:2 @ ws", attention.DeliveryQueued, ""), + ans(103, whereUnlabelledSID, "build:7 @ ws", attention.DeliveryPull, ""), + ans(104, whereLabelledSID, "mango:1 @ ws", attention.DeliveryUnconfirmed, "the paste was not seen"), + } +} + +// whereDeliveries are whereAnsweredAsks' card source and delivery line, by id. +var whereDeliveries = map[int64][2]string{ + 101: {"mango:1 · tmux scratch3:5 @ ws", "Queued for mango:1 · tmux scratch3:5 @ ws — waiting: the session is busy. muster keeps trying."}, + 102: {"pear:2 @ ws (not shown in any mirrored window now)", "Queued for pear:2 @ ws (not shown in any mirrored window now): delivering it as a new message in its session."}, + 103: {"tmux build:7 @ ws", "Waiting for tmux build:7 @ ws to read it: a mirrored session reads its answer itself (attention_wait); muster does not type into it."}, + 104: {"mango:1 · tmux scratch3:5 @ ws", "Delivery failed: the paste was not seen."}, +} + +var answeredCard = regexp.MustCompile(`(?s)
  • ([^<]*).*?data-attention-delivery>([^<]*)

    `) + +// TestAnAnswersDeliveryLineNamesTheExactTmuxTarget: the Answered list on the +// queue (GET /attention/items), each card's source and delivery line whole. +func TestAnAnswersDeliveryLineNamesTheExactTmuxTarget(t *testing.T) { + s := whereServer(t) + s.ext.Attention = whereAnswered{answered: whereAnsweredAsks()} + rec := httptest.NewRecorder() + s.handleAttentionItems(rec, httptest.NewRequest("GET", "/attention/items", nil).WithContext(s.attentionPrincipal(context.Background(), 1))) + cards := answeredCard.FindAllStringSubmatch(rec.Body.String(), -1) + if len(cards) != len(whereDeliveries) { + t.Fatalf("%d answered cards, want %d:\n%s", len(cards), len(whereDeliveries), rec.Body.String()) + } + for _, m := range cards { + var id int64 + _ = json.Unmarshal([]byte(m[1]), &id) + want := whereDeliveries[id] + if got := html.UnescapeString(m[2]); got != want[0] { + t.Errorf("answer %d: source %q, want %q", id, got, want[0]) + } + if got := html.UnescapeString(m[3]); got != want[1] { + t.Errorf("answer %d: delivery %q, want %q", id, got, want[1]) + } + } +} + +var askPageDelivery = regexp.MustCompile(`data-ask-delivery>([^<]*)

    `) + +// TestAnAnsweredAskPagesDeliveryLineNamesTheExactTmuxTarget: the same line on +// the ask's own page once it is answered. +func TestAnAnsweredAskPagesDeliveryLineNamesTheExactTmuxTarget(t *testing.T) { + s := whereServer(t) + for _, a := range whereAnsweredAsks() { + rec := httptest.NewRecorder() + s.renderAskPage(rec, httptest.NewRequest("GET", askPagePath(a.ID), nil).WithContext(s.attentionPrincipal(context.Background(), 1)), a) + m := askPageDelivery.FindStringSubmatch(rec.Body.String()) + if m == nil { + t.Fatalf("ask %d: no delivery line:\n%s", a.ID, rec.Body.String()) + } + if got, want := html.UnescapeString(m[1]), whereDeliveries[a.ID][1]; got != want { + t.Errorf("ask %d: delivery %q, want %q", a.ID, got, want) + } + } +} + +// TestTheChiefsFleetListCarriesEachWindowsWhere: fleet_list, as the chief +// reads it, gives each mirrored window's where (and keeps its name and host). +func TestTheChiefsFleetListCarriesEachWindowsWhere(t *testing.T) { + s := whereServer(t) + var got struct { + Sessions []map[string]any `json:"sessions"` + } + if err := json.Unmarshal([]byte(s.toolFleetList(s.attentionPrincipal(context.Background(), 1))), &got); err != nil { + t.Fatal(err) + } + want := map[string][3]string{ + "mirror:" + whereLabelledSID: {"mango:1 @ ws", "ws", "mango:1 · tmux scratch3:5 @ ws"}, + "mirror:" + whereUnlabelledSID: {"build:7 @ ws", "ws", "tmux build:7 @ ws"}, + } + if len(got.Sessions) != len(want) { + t.Fatalf("%d rows, want %d: %+v", len(got.Sessions), len(want), got.Sessions) + } + for _, row := range got.Sessions { + id, _ := row["id"].(string) + w, ok := want[id] + if !ok { + t.Fatalf("unexpected row %+v", row) + } + if row["name"] != w[0] || row["host"] != w[1] || row["where"] != w[2] { + t.Errorf("%s: name %v host %v where %v, want %q %q %q", id, row["name"], row["host"], row["where"], w[0], w[1], w[2]) + } + } +} diff --git a/internal/api/attention_where_summary_test.go b/internal/api/attention_where_summary_test.go new file mode 100644 index 00000000..fb9c68ba --- /dev/null +++ b/internal/api/attention_where_summary_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ZacxDev/muster/internal/attention" + "github.com/ZacxDev/muster/internal/mirror" +) + +// TestTheSummaryNamesAMirroredAsksWindowFromItsOneHubRead: the seam between +// the summary's ONE hub read (TestTheSummaryReadsTheHubOnce) and an ask that +// names its window (askSource). With a mirrored session's ask open, the +// summary still reads the hub once, and the ask's and the waiting window's +// titles and whys are the exact tmux targets the bar shows. +func TestTheSummaryNamesAMirroredAsksWindowFromItsOneHubRead(t *testing.T) { + f := newFakeAPITokens() + tok := f.add(t, 1) + m := &hostReadCounter{fakeMirror: &fakeMirror{online: true, statusReport: true, windows: []mirror.Window{ + {Session: "scratch3", ID: "@37", Index: 5, Active: true, Label: "mango:1", Claude: true, ClaudeSession: whereLabelledSID, Status: mirror.StatusIdle, StatusSince: attnSince}, + {Session: "build", ID: "@4", Index: 7, Active: true, Claude: true, ClaudeSession: whereUnlabelledSID, Status: mirror.StatusWaiting, StatusSince: attnSince}, + }}} + s := New(nil, AuthConfig{HookToken: tokTestHook}, log.New(io.Discard, "", 0)) + s.UseExtensions(Extensions{APITokens: f, Users: stubUsers{}, Mirror: m, Attention: openAsks{asks: []attention.Ask{ + {ID: 61, Owner: 1, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: whereLabelledSID, Label: "mango:1 @ ws"}, Question: "Which branch?", CreatedAt: time.Now()}, + }}}) + mux := http.NewServeMux() + s.registerAll(mux) + r := httptest.NewRequest(http.MethodGet, attentionSummaryPath, nil) + r.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, r) + var body attentionSummary + if err := json.Unmarshal(rec.Body.Bytes(), &body); rec.Code != 200 || err != nil { + t.Fatalf("the summary %d %s (%v)", rec.Code, rec.Body, err) + } + want := map[string][2]string{ + "ask-61": {"mango:1 · tmux scratch3:5 @ ws", "Which branch?"}, + sessionKey("ws", "build", "@4"): {"tmux build:7 @ ws", "Waiting: tmux build:7 @ ws"}, + } + if len(body.Items) != len(want) { + t.Fatalf("%d items, want %d: %+v", len(body.Items), len(want), body.Items) + } + for _, it := range body.Items { + if w := want[it.Key]; it.Title != w[0] || it.Why != w[1] { + t.Errorf("%s: title %q why %q, want %q %q", it.Key, it.Title, it.Why, w[0], w[1]) + } + } + if m.reads != 1 { + t.Fatalf("one summary with a mirrored ask read the hub %d times; want 1", m.reads) + } +} diff --git a/internal/api/attention_where_test.go b/internal/api/attention_where_test.go new file mode 100644 index 00000000..c54d7fe7 --- /dev/null +++ b/internal/api/attention_where_test.go @@ -0,0 +1,219 @@ +package api + +import ( + "context" + "html" + "io" + "log" + "net/http/httptest" + "regexp" + "testing" + "time" + + "github.com/ZacxDev/muster/internal/attention" + "github.com/ZacxDev/muster/internal/chiefnudge" + "github.com/ZacxDev/muster/internal/mirror" + "github.com/ZacxDev/muster/internal/push" + "github.com/ZacxDev/muster/internal/ui" +) + +// WHERE AN ATTENTION TEXT SENDS THE OPERATOR: every surface that names a +// mirrored window names its exact tmux target, ":" on its +// host, led by the host's codename when it has one (mirror.Where). Before, +// they named the codename alone ("mango:1 @ ws", which is no tmux name) or the +// session alone with no window ("build @ ws"). +// +// The fixture's fields are pairwise distinct so a target built from the wrong +// one cannot pass: the labelled window is tmux session "scratch3", window id +// "@37", index 5, codename "mango:1" (its codename's index is NOT its tmux +// index); the unlabelled one is session "build", id "@4", index 7. +const ( + whereLabelledSID = "5a1b2c3d-1111-4111-8111-000000000037" + whereUnlabelledSID = "5a1b2c3d-2222-4222-8222-000000000004" + whereGoneSID = "5a1b2c3d-3333-4333-8333-000000000099" +) + +func whereServer(t *testing.T, asks ...attention.Ask) *Server { + t.Helper() + s := New(nil, AuthConfig{}, log.New(io.Discard, "", 0)) + fm := &fakeMirror{online: true, interactive: true, statusReport: true, v2: &fakeMirrorV2{}, windows: []mirror.Window{ + {Session: "scratch3", ID: "@37", Index: 5, Active: true, Label: "mango:1", Claude: true, ClaudeSession: whereLabelledSID, Status: mirror.StatusWaiting, StatusSince: attnSince}, + {Session: "build", ID: "@4", Index: 7, Active: true, Claude: true, ClaudeSession: whereUnlabelledSID, Status: mirror.StatusWaiting, StatusSince: attnSince}, + }} + ext := Extensions{Users: stubUsers{}, Mirror: fm} + if asks != nil { + ext.Attention = openAsks{asks: asks} + } + s.UseExtensions(ext) + return s +} + +// whereSessionItem is the waiting-session item of window id. +func whereSessionItem(t *testing.T, s *Server, session, window string) attentionItem { + t.Helper() + for _, it := range s.waitingSessions(s.attentionPrincipal(context.Background(), 1)) { + if it.Key == sessionKey("ws", session, window) { + return it + } + } + t.Fatalf("window %s:%s is not waiting", session, window) + return attentionItem{} +} + +// TestANudgeAskNamesItsWindowsExactTmuxTarget: a chief nudge's ask, built from +// the sweep's own candidate (stallInputs carries the window), opens with the +// tmux session and index — the codename first when the host gave one. +// nudgeAskPlace is the whole of what nudgeAskQuestion says of where the +// session is (after its focus title), pinned here whole; the live-ask PG test +// (TestChiefNudgeLiveAskIsAnsweredAndRelayedVerbatim) pins the whole question. +func TestANudgeAskNamesItsWindowsExactTmuxTarget(t *testing.T) { + s := whereServer(t) + byKey := map[string]chiefnudge.Session{} + for _, c := range s.stallInputs(s.attentionPrincipal(context.Background(), 1), nil) { + byKey[c.Session.Key] = c.Session + } + if len(byKey) != 2 { + t.Fatalf("fixture: %d candidates, want 2", len(byKey)) + } + for _, c := range []struct { + name, sid, want string + }{ + {"a window with a codename", whereLabelledSID, " (mango:1 · tmux scratch3:5 @ ws)"}, + {"a window without a codename", whereUnlabelledSID, " (tmux build:7 @ ws)"}, + } { + t.Run(c.name, func(t *testing.T) { + if got := nudgeAskPlace(byKey[c.sid]); got != c.want { + t.Fatalf("ask place:\n%q\nwant:\n%q", got, c.want) + } + }) + } +} + +// TestAnAgentNudgeAskKeepsItsAgentName: an agent has no tmux window; its ask +// still names it "(agent )". An INVARIANT guard (it held before too). +func TestAnAgentNudgeAskKeepsItsAgentName(t *testing.T) { + c := chiefnudge.Session{Kind: chiefnudge.KindAgent, Label: "Worker Seven", Title: "Fix CI"} + if got, want := nudgeAskPlace(c), " (agent Worker Seven)"; got != want { + t.Fatalf("ask place %q, want %q", got, want) + } +} + +// TestAWaitingSessionAndItsPushNameTheExactTmuxTarget: the queue's waiting +// session item and its notification body. +func TestAWaitingSessionAndItsPushNameTheExactTmuxTarget(t *testing.T) { + s := whereServer(t) + for _, c := range []struct { + session, window, source, body string + }{ + {"scratch3", "@37", "mango:1 · tmux scratch3:5 @ ws", "Waiting: mango:1 · tmux scratch3:5 @ ws"}, + {"build", "@4", "tmux build:7 @ ws", "Waiting: tmux build:7 @ ws"}, + } { + it := whereSessionItem(t, s, c.session, c.window) + if it.Source != c.source { + t.Errorf("%s: source %q, want %q", c.window, it.Source, c.source) + } + if got := attentionPushBody(it); got != c.body { + t.Errorf("%s: push body %q, want %q", c.window, got, c.body) + } + } +} + +// whereAsks are a mirrored session's asks: one whose Claude session runs in +// the labelled window, one in the unlabelled window, one no window runs, and +// an agent's. +func whereAsks() []attention.Ask { + now := time.Now() + return []attention.Ask{ + {ID: 91, Owner: 1, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: whereLabelledSID, Label: "mango:1 @ ws"}, Question: "Which branch?", CreatedAt: now}, + {ID: 92, Owner: 1, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: whereUnlabelledSID, Label: "build:7 @ ws"}, Question: "Which branch?", CreatedAt: now}, + {ID: 93, Owner: 1, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: whereGoneSID, Label: "pear:2 @ ws"}, Question: "Which branch?", CreatedAt: now}, + {ID: 94, Owner: 1, Asker: attention.Asker{AgentID: 7, Label: "Worker Seven"}, Question: "Which branch?", CreatedAt: now}, + } +} + +// whereAskSources are whereAsks' sources, by id. 93's window is gone: its +// stored name, and that no window shows it — never a fabricated target. +var whereAskSources = map[int64]string{ + 91: "mango:1 · tmux scratch3:5 @ ws", + 92: "tmux build:7 @ ws", + 93: "pear:2 @ ws (not shown in any mirrored window now)", + 94: "Worker Seven", +} + +// TestAMirroredSessionsAskNamesTheWindowRunningItNow: on the queue (and so its +// push title), an ask by a mirrored session names the window running its +// Claude session now; one no window runs says so. +func TestAMirroredSessionsAskNamesTheWindowRunningItNow(t *testing.T) { + s := whereServer(t, whereAsks()...) + items, err := s.attentionItems(s.attentionPrincipal(context.Background(), 1)) + if err != nil { + t.Fatal(err) + } + seen := 0 + for _, it := range items { + if it.Kind != attentionKindAsk { + continue + } + seen++ + if want := whereAskSources[it.AskID]; it.Source != want { + t.Errorf("ask %d: source %q, want %q", it.AskID, it.Source, want) + } + if want := whereAskSources[it.AskID] + " is asking"; attentionPushTitle(it) != want { + t.Errorf("ask %d: push title %q, want %q", it.AskID, attentionPushTitle(it), want) + } + } + if seen != len(whereAskSources) { + t.Fatalf("%d asks on the queue, want %d", seen, len(whereAskSources)) + } +} + +var askPageSource = regexp.MustCompile(`data-ask-source>([^<]*) asks`) + +// TestTheAskPageHeaderNamesTheExactTmuxTarget: " asks" on an ask's +// own page. +func TestTheAskPageHeaderNamesTheExactTmuxTarget(t *testing.T) { + asks := whereAsks() + s := whereServer(t, asks...) + for _, a := range asks { + rec := httptest.NewRecorder() + r := httptest.NewRequest("GET", askPagePath(a.ID), nil).WithContext(s.attentionPrincipal(context.Background(), 1)) + s.renderAskPage(rec, r, a) + m := askPageSource.FindStringSubmatch(rec.Body.String()) + if m == nil { + t.Fatalf("ask %d: no header on the page:\n%s", a.ID, rec.Body.String()) + } + if got, want := html.UnescapeString(m[1]), whereAskSources[a.ID]; got != want { + t.Errorf("ask %d: header %q asks, want %q asks", a.ID, got, want) + } + } +} + +// TestASessionPagesAsksBlockNamesTheExactTmuxTarget: the asks block on the +// labelled window's own page. +func TestASessionPagesAsksBlockNamesTheExactTmuxTarget(t *testing.T) { + s := whereServer(t, whereAsks()...) + items := s.askerAsks(s.attentionPrincipal(context.Background(), 1), askerRef{mirror: ui.MirrorRef{Host: "ws", Session: "scratch3", Window: "@37"}}) + if len(items) != 1 || items[0].AskID != 91 { + t.Fatalf("block items %+v, want ask 91 alone", items) + } + if got, want := items[0].Source, whereAskSources[91]; got != want { + t.Fatalf("source %q, want %q", got, want) + } +} + +// TestAFiledAsksNotificationNamesTheExactTmuxTarget: the notification sent when +// a mirrored session's ask is filed (askFiled) — the path that builds its item +// with no request of its own — names the window running it now. Its stored +// name is from before the window was renumbered (main:9): the push says main:2. +func TestAFiledAsksNotificationNamesTheExactTmuxTarget(t *testing.T) { + store, pusher := &memPush{}, &rawPusher{} + claim(t, store, 1, ep("where-filed")) + s := pushTargetServer(t, store, pusher) + s.askFiled(attention.Ask{ID: 95, Owner: 1, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: ptOtherSID, Label: "main:9 @ ws"}, Question: "Which branch?"}) + s.WaitForPushes() + var b push.Payload + pusher.one(t, ep("where-filed"), &b) + if want := "tmux main:2 @ ws is asking"; b.Title != want { + t.Fatalf("title %q, want %q", b.Title, want) + } +} diff --git a/internal/api/attention_where_verbatim_test.go b/internal/api/attention_where_verbatim_test.go new file mode 100644 index 00000000..6e374686 --- /dev/null +++ b/internal/api/attention_where_verbatim_test.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "html" + "io" + "log" + "regexp" + "testing" + "time" + + "github.com/ZacxDev/muster/internal/attention" + "github.com/ZacxDev/muster/internal/mirror" + "github.com/ZacxDev/muster/internal/push" +) + +// THE TARGET IS SHOWN VERBATIM. tmux session names are free text: "_scratch_", +// "*dev*" and "a`b`c" are real names an operator types. The title rule +// (textplain) plains markdown out of what a page or a notification shows; it +// must plain the codename around a window's target and never the target +// itself (textplain.TitleKeeping with mirror.WhereTarget). +const ( + verbatimSIDScratch = "6b1c2d3e-1111-4111-8111-00000000a001" + verbatimSIDDev = "6b1c2d3e-2222-4222-8222-00000000a002" + verbatimSIDTicks = "6b1c2d3e-3333-4333-8333-00000000a003" +) + +func verbatimServer(t *testing.T, store push.Store, pusher push.Pusher) *Server { + t.Helper() + s := New(nil, AuthConfig{}, log.New(io.Discard, "", 0)) + fm := &fakeMirror{online: true, interactive: true, statusReport: true, v2: &fakeMirrorV2{}, windows: []mirror.Window{ + // A markdown-shaped codename (plained) on a markdown-shaped session (kept). + {Session: "_scratch_", ID: "@11", Index: 2, Active: true, Label: "*mango*:1", Claude: true, ClaudeSession: verbatimSIDScratch, Status: mirror.StatusWaiting, StatusSince: attnSince}, + {Session: "*dev*", ID: "@12", Index: 3, Active: true, Claude: true, ClaudeSession: verbatimSIDDev, Status: mirror.StatusWaiting, StatusSince: attnSince}, + {Session: "a`b`c", ID: "@13", Index: 4, Active: true, Claude: true, ClaudeSession: verbatimSIDTicks, Status: mirror.StatusWaiting, StatusSince: attnSince}, + }} + ext := Extensions{Users: stubUsers{}, Mirror: fm} + if store != nil { + ext.Push = push.NewService(store, pusher, push.VAPIDConfig{PublicKey: "k"}, log.New(io.Discard, "", 0)) + } + s.UseExtensions(ext) + return s +} + +// verbatimWhere is each window's where as every surface must show it. +var verbatimWhere = map[string]string{ + sessionKey("ws", "_scratch_", "@11"): "mango:1 · tmux _scratch_:2 @ ws", + sessionKey("ws", "*dev*", "@12"): "tmux *dev*:3 @ ws", + sessionKey("ws", "a`b`c", "@13"): "tmux a`b`c:4 @ ws", +} + +var verbatimCardSource = regexp.MustCompile(`
  • ([^<]*)`) + +// TestAWindowsTargetIsShownVerbatimOnThePushTheSummaryAndTheCard: the waiting +// session's notification body, its summary title and why, and its card on the +// queue, whole. +func TestAWindowsTargetIsShownVerbatimOnThePushTheSummaryAndTheCard(t *testing.T) { + s := verbatimServer(t, nil, nil) + items := s.waitingSessions(s.attentionPrincipal(context.Background(), 1)) + if len(items) != len(verbatimWhere) { + t.Fatalf("%d waiting items, want %d", len(items), len(verbatimWhere)) + } + for _, it := range items { + want, ok := verbatimWhere[it.Key] + if !ok { + t.Fatalf("unexpected item %s", it.Key) + } + if got := attentionPushBody(it); got != "Waiting: "+want { + t.Errorf("%s: push body %q, want %q", it.Key, got, "Waiting: "+want) + } + sum := attentionSummaryItemOf(it, "", time.Now()) + if sum.Title != want || sum.Why != "Waiting: "+want { + t.Errorf("%s: summary title %q why %q, want %q / %q", it.Key, sum.Title, sum.Why, want, "Waiting: "+want) + } + } + cards := verbatimCardSource.FindAllStringSubmatch(attentionItemsHTML(items, time.Now()), -1) + if len(cards) != len(verbatimWhere) { + t.Fatalf("%d cards, want %d", len(cards), len(verbatimWhere)) + } + for _, m := range cards { + if got, want := html.UnescapeString(m[2]), verbatimWhere[m[1]]; got != want { + t.Errorf("%s: card source %q, want %q", m[1], got, want) + } + } +} + +// TestAnAsksPushTitleKeepsItsTargetVerbatim: the push sink (goNotify) plains +// a notification's title — but not the asking window's target in it. +func TestAnAsksPushTitleKeepsItsTargetVerbatim(t *testing.T) { + store, pusher := &memPush{}, &rawPusher{} + claim(t, store, 1, ep("verbatim-ask")) + s := verbatimServer(t, store, pusher) + s.askFiled(attention.Ask{ID: 96, Owner: 1, Asker: attention.Asker{MirrorHost: "ws", MirrorSession: verbatimSIDDev, Label: "dev:3 @ ws"}, Question: "Which branch?"}) + s.WaitForPushes() + var b push.Payload + pusher.one(t, ep("verbatim-ask"), &b) + if want := "tmux *dev*:3 @ ws is asking"; b.Title != want { + t.Fatalf("title %q, want %q", b.Title, want) + } +} + +// TestAWhereOnAnOfflineHostSaysSo: the hub keeps a dropped host's windows +// listed for a grace period; an ask's where from one is its last-known window +// and says so. +func TestAWhereOnAnOfflineHostSaysSo(t *testing.T) { + hosts := []mirror.HostView{{Host: "ws", Online: false, Windows: []mirror.Window{ + {Session: "build", ID: "@4", Index: 7, Claude: true, ClaudeSession: verbatimSIDDev}}}} + a := attention.Ask{Asker: attention.Asker{MirrorHost: "ws", MirrorSession: verbatimSIDDev, Label: "build:7 @ ws"}} + if got, want := askSource(a, hosts), "tmux build:7 @ ws (host offline)"; got != want { + t.Fatalf("where %q, want %q", got, want) + } + hosts[0].Online = true + if got, want := askSource(a, hosts), "tmux build:7 @ ws"; got != want { + t.Fatalf("online: where %q, want %q", got, want) + } +} diff --git a/internal/api/cards_attention.go b/internal/api/cards_attention.go index 18ba7ed5..d46db245 100644 --- a/internal/api/cards_attention.go +++ b/internal/api/cards_attention.go @@ -105,17 +105,27 @@ func (s *Server) cardAttention(ctx context.Context) cardAttentionIndex { // claudeSessionWindow is the address of host's window running Claude session // sid, in its active pane or a split (the MCP door's rule, mirrorSessionOf). func claudeSessionWindow(hosts []mirror.HostView, host, sid string) (ui.MirrorRef, bool) { + e, ok := claudeSessionEntry(hosts, host, sid) + if !ok { + return ui.MirrorRef{}, false + } + return ui.MirrorRef{Host: e.Host, Session: e.Session, Window: e.Window}, true +} + +// claudeSessionEntry is claudeSessionWindow's window with what names it +// (Index, Label: mirror.Where), by the same rule. +func claudeSessionEntry(hosts []mirror.HostView, host, sid string) (mirror.SessionView, bool) { for _, h := range hosts { if h.Host != host { continue } for _, w := range h.Windows { if (w.Claude && w.ClaudeSession == sid) || slices.Contains(w.OtherClaude, sid) { - return ui.MirrorRef{Host: h.Host, Session: w.Session, Window: w.ID}, true + return mirror.SessionView{Host: h.Host, Session: w.Session, Window: w.ID, Index: w.Index, Label: w.Label, Online: h.Online}, true } } } - return ui.MirrorRef{}, false + return mirror.SessionView{}, false } // withCardAttention sets every entry's count (in place). diff --git a/internal/api/chief_ask_pg_test.go b/internal/api/chief_ask_pg_test.go index 4184aae5..33806ff3 100644 --- a/internal/api/chief_ask_pg_test.go +++ b/internal/api/chief_ask_pg_test.go @@ -223,7 +223,7 @@ func TestChiefAskPutsTheQuestionToTheChiefWithItsSessionAndRoute(t *testing.T) { } turn := f.turnFor(t, f.def, "session question #"+strconv.FormatInt(id, 10)) for _, want := range []string{"host ws", "Claude session " + focusSID, "about route " + r.RouteID, "<<)" (mirror.Where), or " (agent )". +func nudgeAskPlace(c chiefnudge.Session) string { + if c.Kind == chiefnudge.KindMirror { + return " (" + mirror.Where(c.Host, c.TmuxSession, c.Window, c.Index, c.Label) + ")" + } + return " (agent " + c.Label + ")" +} + // nudgeTitle is c's focus title, "" for none. func (s *Server) nudgeTitle(ctx context.Context, c chiefnudge.Session) string { if s.ext.Focus == nil { @@ -1217,15 +1227,7 @@ func nudgeAskQuestion(c chiefnudge.Session, q, read string, generic bool) string if where == "" { where = "A session" } - if c.Kind == chiefnudge.KindMirror { - label := c.Label - if label == "" { - label = c.TmuxSession - } - where += " (" + label + " @ " + c.Host + ")" - } else { - where += " (agent " + c.Label + ")" - } + where += nudgeAskPlace(c) text := where + " stopped and is waiting on you:\n\n" + strings.TrimSpace(q) + "\n\nYour answer is sent into that session word for word." if generic { diff --git a/internal/api/chief_nudge_generic_read_pg_test.go b/internal/api/chief_nudge_generic_read_pg_test.go index 2d895917..092ac118 100644 --- a/internal/api/chief_nudge_generic_read_pg_test.go +++ b/internal/api/chief_nudge_generic_read_pg_test.go @@ -19,7 +19,7 @@ import ( // two. With no reason it is the generic ask exactly as before. const ( - genericAskHead = "A session (Gold:1 @ ws) is waiting on you. Its last turn looks like it asked you something." + genericAskHead = "A session (Gold:1 · tmux main:1 @ ws) is waiting on you. Its last turn looks like it asked you something." genericAskTail = "\n\nChoosing an option sends nothing into the session; an answer you type is sent into it word for word." ) diff --git a/internal/api/chief_nudge_pg_test.go b/internal/api/chief_nudge_pg_test.go index a531655b..d58ed3aa 100644 --- a/internal/api/chief_nudge_pg_test.go +++ b/internal/api/chief_nudge_pg_test.go @@ -632,7 +632,7 @@ func TestChiefNudgeLiveAskIsAnsweredAndRelayedVerbatim(t *testing.T) { } a, err := f.attn.Get(asUser(authz.OperatorUserID), d.AskID) if err != nil || !a.Asker.System || a.Asker.Label != chiefNudgeAskLabel || - !strings.Contains(a.Question, "Merge into main or into release?") || !strings.Contains(a.Question, "(Gold:1 @ ws)") || + !strings.Contains(a.Question, "Merge into main or into release?") || a.Question != "A session (Gold:1 · tmux main:1 @ ws) stopped and is waiting on you:\n\nMerge into main or into release?\n\nYour answer is sent into that session word for word." || strings.Join(a.Options, "|") != "main|release" { t.Fatalf("the ask %+v %v", a, err) } diff --git a/internal/api/fleet_report.go b/internal/api/fleet_report.go index 18ae97bf..fffcbcba 100644 --- a/internal/api/fleet_report.go +++ b/internal/api/fleet_report.go @@ -129,10 +129,13 @@ type fleetFocus struct { } type fleetSession struct { - ID string `json:"id"` - Kind string `json:"kind"` - Name string `json:"name"` - Host string `json:"host"` + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Host string `json:"host"` + // Where is a listed window's exact tmux target (mirror.Where) — the + // chief's door only: it names a host, so the mail door never carries it. + Where string `json:"where,omitempty"` Repo *string `json:"repo"` State string `json:"state"` FirstSeen string `json:"first_seen"` @@ -210,6 +213,7 @@ type fleetFact struct { // the session. Name, Title string Host string // a mirrored window's host; "" for an agent or an unknown window + Where string // a listed window's mirror.Where; "" otherwise Repo string // the agent's Repo or the window's repo chip; "" none State string // "waiting", "active" or "ended" FirstAt time.Time @@ -358,7 +362,10 @@ func (s *Server) collectFleet(ctx context.Context, exclude int64, since, until t } case focus.KindMirror: if e, ok := byKey[w.Subject.Key]; ok { - f.Name, f.Title, f.Host, f.Repo = e.DisplayTitle(), e.Title(), e.Host, e.Repo + f.Name, f.Title, f.Host, f.Repo, f.Where = e.DisplayTitle(), e.Title(), e.Host, e.Repo, e.Where() + if !e.Online { + f.Where += whereHostOffline // its last-known window + } if e.Online { f.State = "active" } @@ -472,12 +479,12 @@ func encodeFleetReport(c fleetCollected, window fleetWindow, requestID *string, } row := fleetSession{ID: f.Subject.String(), Kind: string(f.Subject.Kind), State: f.State, FirstSeen: f.FirstAt.UTC().Format(time.RFC3339), LastSeen: f.LastAt.UTC().Format(time.RFC3339), - Name: f.Name, Host: f.Host, Focus: []fleetFocus{}, PRs: []string{}} + Name: f.Name, Host: f.Host, Where: f.Where, Focus: []fleetFocus{}, PRs: []string{}} if mail != nil { // 🔴 THE MAIL DOOR NAMES A SESSION BY ITS UNQUALIFIED TITLE. // DisplayTitle appends " · " when two listed entries share // a title (mirror's qualify) — a host label inside the name. - row.Name, row.Host = f.Title, "" + row.Name, row.Host, row.Where = f.Title, "", "" } if f.PRs != nil { row.PRs = f.PRs @@ -487,6 +494,7 @@ func encodeFleetReport(c fleetCollected, window fleetWindow, requestID *string, } } row.Name = field(row.Name) + row.Where = field(row.Where) if repo := field(f.Repo); repo != "" { row.Repo = &repo } diff --git a/internal/api/focus_tools.go b/internal/api/focus_tools.go index 8d44a45b..f6cfc878 100644 --- a/internal/api/focus_tools.go +++ b/internal/api/focus_tools.go @@ -58,7 +58,12 @@ type fleetListRow struct { Kind string `json:"kind"` Name string `json:"name"` // Host is a mirrored session's host ("" for a dispatched agent). - Host string `json:"host,omitempty"` + Host string `json:"host,omitempty"` + // Where is a mirrored session's window as the owner finds it in a + // terminal (mirror.Where: "tmux : @ ", its codename + // first when it has one); "" for an agent. What the chief gives its owner + // when it points them at the session. + Where string `json:"where,omitempty"` Status string `json:"status"` Focus string `json:"focus,omitempty"` // FocusProblem is why the session has no focus, or a stale one: the @@ -95,7 +100,7 @@ func (s *Server) toolFleetList(ctx context.Context) string { status = "unknown" } rows = append(rows, fleetListRow{ID: sub.String(), Kind: "mirror", Name: e.DisplayTitle() + " @ " + e.Host, Host: e.Host, - Status: status, Focus: fi[sub].Title, FocusProblem: fi[sub].Problem()}) + Where: e.Where(), Status: status, Focus: fi[sub].Title, FocusProblem: fi[sub].Problem()}) } return toolJSON(map[string]any{"untrusted": focus.UntrustedNote, "sessions": rows}) } diff --git a/internal/api/plugin_message_route_tools.go b/internal/api/plugin_message_route_tools.go index e2aca87e..863ad26d 100644 --- a/internal/api/plugin_message_route_tools.go +++ b/internal/api/plugin_message_route_tools.go @@ -262,6 +262,8 @@ func (s *Server) toolPluginMessageRoute(ctx context.Context, c toolCaller, argum type routeCandidate struct { Subject string `json:"subject"` Session string `json:"session"` + // Where is the session's window (mirror.Where). + Where string `json:"where"` } // routeSubjectFor is the subject raw names, which must be route_to_session's @@ -287,7 +289,7 @@ func (s *Server) routeSubjectFor(ctx context.Context, raw string) (focus.Subject continue // the most recently active window holding it (mirrorEntryForSession's) } seen[e.ClaudeSession] = true - cands = append(cands, routeCandidate{Subject: string(focus.KindMirror) + ":" + e.ClaudeSession, Session: e.DisplayTitle() + " @ " + e.Host}) + cands = append(cands, routeCandidate{Subject: string(focus.KindMirror) + ":" + e.ClaudeSession, Session: e.DisplayTitle() + " @ " + e.Host, Where: e.Where()}) } return focus.Subject{}, subjectRefusal(RefusedSubjectNotFound, "subject "+strconv.Quote(raw)+ " is not \"mirror:\" of a session an online mirrored window shows; name one of the candidates by its subject", cands) diff --git a/internal/api/pr_routes.go b/internal/api/pr_routes.go index 77d3b396..68aefab3 100644 --- a/internal/api/pr_routes.go +++ b/internal/api/pr_routes.go @@ -133,7 +133,7 @@ func (s *Server) prAuthorHint(ctx context.Context, m plugins.Message) *plugins.A a := &plugins.AuthorHint{ID: focus.MirrorSubject(hit.ClaudeSession).String(), Host: hit.Host, Kind: string(hit.Kind), Repo: hit.Repo, PR: hit.PR, HeadRef: hit.HeadRef, LastSeen: hit.LastSeen} if e, ok := s.mirrorEntryForSession(ctx, hit.ClaudeSession); ok { - a.Online, a.Host, a.Name = true, e.Host, e.DisplayTitle() + a.Online, a.Host, a.Name, a.Where = true, e.Host, e.DisplayTitle(), e.Where() } if s.ext.Focus != nil { if f, has, err := s.ext.Focus.Get(ctx, focus.MirrorSubject(hit.ClaudeSession)); err == nil && has && f.Has() { diff --git a/internal/api/server.go b/internal/api/server.go index 09e5ef80..799b74ac 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -387,6 +387,9 @@ type RouterNotification struct { Update bool // AskActions: payload v2 offers Ask's signed actions (device_actions.go). AskActions bool + // TitleKeep is a part of Title the title rule keeps verbatim + // (textplain.TitleKeeping): a mirrored window's tmux target. "" for none. + TitleKeep string } // rateLimiter is a fixed-window counter. A token bucket would be smoother; a @@ -1284,7 +1287,7 @@ func (s *Server) goNotify(owner int64, event string, n RouterNotification, onDel // not titles and are sent as written. TestTheTitleSinkLedger // (internal/textplain) pins both halves and that sendPush and router.Notify // are reached from nowhere else. - n.Title = textplain.Title(n.Title) + n.Title = textplain.TitleKeeping(n.Title, n.TitleKeep) // 🔴 THE Add IS ON THE CALLER'S GOROUTINE AND THE Done IS fn's FIRST DEFER, // AND safeGo DOES NOT DISTURB EITHER. safeGo's recover is registered OUTSIDE // fn, so on a panic fn's own defers unwind first — Done runs, then the diff --git a/internal/api/sessions_overview.go b/internal/api/sessions_overview.go index e2d9f136..dd6e6d06 100644 --- a/internal/api/sessions_overview.go +++ b/internal/api/sessions_overview.go @@ -100,8 +100,10 @@ func sessionsOverviewToolDef() agents.ToolDef { type liveSession struct { sub focus.Subject kind, name, host string - status string - activeAt time.Time + // where is a mirrored session's window (mirror.Where), "" for an agent. + where string + status string + activeAt time.Time } // liveSessions is ctx's principal's live sessions, newest-active first, minus @@ -145,7 +147,7 @@ func (s *Server) liveSessions(ctx context.Context, exclude int64) []liveSession status = "unknown" } out = append(out, liveSession{sub: focus.MirrorSubject(e.ClaudeSession), kind: "mirror", name: e.DisplayTitle(), - host: e.Host, status: status, activeAt: at}) + host: e.Host, where: e.Where(), status: status, activeAt: at}) } sort.SliceStable(out, func(i, j int) bool { return out[i].activeAt.After(out[j].activeAt) }) return out @@ -194,7 +196,7 @@ func (s *Server) pluginSessionHints(ctx context.Context, chief agents.Agent, m p if !ok { continue // the store filtered on liveness already; belt and braces } - sh := plugins.SessionHint{ID: mt.Subject.String(), Name: ls.name, Host: ls.host} + sh := plugins.SessionHint{ID: mt.Subject.String(), Name: ls.name, Host: ls.host, Where: ls.where} for _, t := range mt.Titles { sh.Titles = append(sh.Titles, plugins.HintTitle{Title: t.Title, At: t.At}) } @@ -235,6 +237,7 @@ type overviewSession struct { Kind string `json:"kind"` Name string `json:"name"` Host string `json:"host,omitempty"` + Where string `json:"where,omitempty"` // fleetListRow.Where Status string `json:"status"` ActiveAt string `json:"active_at,omitempty"` Focus string `json:"focus,omitempty"` @@ -276,7 +279,7 @@ func (s *Server) toolSessionsOverview(ctx context.Context, caller agents.Agent, size := len(toolJSON(res)) + len(`,"sessions":[]`) out := []overviewSession{} for i, ls := range live { - row := overviewSession{ID: ls.sub.String(), Kind: ls.kind, Name: ls.name, Host: ls.host, Status: ls.status, + row := overviewSession{ID: ls.sub.String(), Kind: ls.kind, Name: ls.name, Host: ls.host, Where: ls.where, Status: ls.status, Focus: fi[ls.sub].Title, Attention: pending[ls.sub], Transcript: tails[i]} if !ls.activeAt.IsZero() { row.ActiveAt = ls.activeAt.UTC().Format(time.RFC3339) diff --git a/internal/api/testdata/fleet_report/byte-capped.tool.json b/internal/api/testdata/fleet_report/byte-capped.tool.json index 92a179ff..576cfa94 100644 --- a/internal/api/testdata/fleet_report/byte-capped.tool.json +++ b/internal/api/testdata/fleet_report/byte-capped.tool.json @@ -1 +1 @@ -{"type":"fleet_report","v":1,"generated_at":"