From 5a36482e4189fb3be9a4ff57d4e1d867b564e535 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 18 Jun 2026 17:08:09 +0800 Subject: [PATCH] feat(agents): rich multi-agent status in GUI, island, and Mac menu bar (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1 of the agent control plane (plan: iridescent-marinating-torvalds). The observers already capture rich progress (turns/tokens/tools/files/branch/ pendingPermission); this surfaces it everywhere instead of pip+name+time. - agent-roster.ts: new pure, source-injected `formatActivity(session)` — one-line "⚠pending | ✗err · turn N · Mk tok · $cmd · Tool file". Replaces the island's local copy (+ its basename helper) so island + sidebar share one tested impl (injection-safety test + unit tests cover it). - island: inject ${formatActivity}; header "claude code"→"agents"; cap 5→8. - GUI sidebar (lisa-client/lisa-css/lisa-html): a second per-row activity line (inline sbActivity mirrors the shared fn — this client script is a no-interpolation template literal), "agents" header, cap 5→8. - Mac menu bar (MenuBarController): poll /api/agents/sessions (was claude-only), aggregate state counts across ALL agents, and the popover now lists per-agent rows (state dot + kind + branch label + activity line). New DTOs + Swift agentLabel/agentActivity mirroring the web helpers. 700 tests pass (+5); typecheck + build clean; swift build clean; dist island verified (formatActivity injected, no __name, basename removed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/Lisa/MenuBarController.swift | 120 ++++++++++++++++-- src/web/agent-roster.test.ts | 34 ++++- src/web/agent-roster.ts | 49 +++++++ src/web/island.ts | 32 +---- src/web/lisa-client.ts | 36 +++++- src/web/lisa-css.ts | 11 ++ src/web/lisa-html-snapshot.test.ts | 6 +- src/web/lisa-html.ts | 2 +- 8 files changed, 250 insertions(+), 40 deletions(-) diff --git a/packaging/mac-client/Sources/Lisa/MenuBarController.swift b/packaging/mac-client/Sources/Lisa/MenuBarController.swift index 6ac58b9..3c4df5d 100644 --- a/packaging/mac-client/Sources/Lisa/MenuBarController.swift +++ b/packaging/mac-client/Sources/Lisa/MenuBarController.swift @@ -93,9 +93,9 @@ final class MenuBarController: NSObject, NSPopoverDelegate { private func refreshOnce() async { // Two parallel requests — ping (mood + desire + idle message) and - // sessions (Claude Code active). Either failing means "offline". + // sessions (ALL observed agents). Either failing means "offline". let pingURL = URL(string: "http://localhost:5757/api/island/ping")! - let sessURL = URL(string: "http://localhost:5757/api/claude/sessions")! + let sessURL = URL(string: "http://localhost:5757/api/agents/sessions")! let cfg = URLRequest.CachePolicy.reloadIgnoringLocalCacheData async let pingResult: PingDTO? = await Self.fetch(PingDTO.self, url: pingURL, cachePolicy: cfg) @@ -379,7 +379,7 @@ final class MenuBarController: NSObject, NSPopoverDelegate { v.alignment = .leading v.spacing = 6 v.translatesAutoresizingMaskIntoConstraints = false - let t = NSTextField(labelWithString: "CLAUDE CODE · \(sessions.count) ACTIVE") + let t = NSTextField(labelWithString: "AGENTS · \(sessions.count) ACTIVE") t.font = .systemFont(ofSize: 10, weight: .semibold) t.textColor = .tertiaryLabelColor v.addArrangedSubview(t) @@ -387,18 +387,105 @@ final class MenuBarController: NSObject, NSPopoverDelegate { let row = NSStackView() row.orientation = .horizontal row.spacing = 6 - let orange = NSColor(srgbRed: 1.0, green: 0.55, blue: 0.26, alpha: 1) - let red = NSColor(srgbRed: 1.0, green: 0.33, blue: 0.47, alpha: 1) - if waiting > 0 { row.addArrangedSubview(chip("\(waiting) waiting", color: orange)) } - if working > 0 { row.addArrangedSubview(chip("\(working) working", color: orange.withAlphaComponent(0.72))) } - if errors > 0 { row.addArrangedSubview(chip("\(errors) errored", color: red)) } + if waiting > 0 { row.addArrangedSubview(chip("\(waiting) waiting", color: Self.stateOrange)) } + if working > 0 { row.addArrangedSubview(chip("\(working) working", color: Self.stateOrange.withAlphaComponent(0.72))) } + if errors > 0 { row.addArrangedSubview(chip("\(errors) errored", color: Self.stateRed)) } if waiting == 0 && working == 0 && errors == 0 { row.addArrangedSubview(chip("idle", color: .systemGray)) } v.addArrangedSubview(row) + + // Per-agent rows, attention-sorted (error > waiting > working), capped. + let rank: [String: Int] = ["error": 0, "waiting": 1, "working": 2] + let sorted = sessions.sorted { (rank[$0.state] ?? 9) < (rank[$1.state] ?? 9) } + let cap = 6 + for s in sorted.prefix(cap) { v.addArrangedSubview(agentRow(s, inner: inner)) } + if sorted.count > cap { + let more = NSTextField(labelWithString: "+\(sorted.count - cap) more") + more.font = .systemFont(ofSize: 10) + more.textColor = .tertiaryLabelColor + v.addArrangedSubview(more) + } return v } + private static let stateOrange = NSColor(srgbRed: 1.0, green: 0.55, blue: 0.26, alpha: 1) + private static let stateRed = NSColor(srgbRed: 1.0, green: 0.33, blue: 0.47, alpha: 1) + + /// One agent's row: ● state-dot · kind · branch/project label, with the + /// structural activity line beneath. Mirrors the GUI roster row. + private func agentRow(_ s: SessionDTO, inner: CGFloat) -> NSView { + let dotColor: NSColor = s.state == "error" ? Self.stateRed + : (s.state == "waiting" || s.state == "working") ? Self.stateOrange : .systemGray + + let dot = NSView() + dot.wantsLayer = true + dot.layer?.backgroundColor = dotColor.cgColor + dot.layer?.cornerRadius = 3.5 + dot.translatesAutoresizingMaskIntoConstraints = false + dot.widthAnchor.constraint(equalToConstant: 7).isActive = true + dot.heightAnchor.constraint(equalToConstant: 7).isActive = true + + let kind = NSTextField(labelWithString: (s.agent ?? "agent")) + kind.font = .systemFont(ofSize: 9, weight: .semibold) + kind.textColor = .tertiaryLabelColor + + let label = NSTextField(labelWithString: oneLine(agentLabel(s), 40)) + label.font = .systemFont(ofSize: 12, weight: .medium) + label.textColor = .labelColor + label.lineBreakMode = .byTruncatingTail + + let top = NSStackView(views: [dot, kind, label]) + top.orientation = .horizontal + top.spacing = 6 + top.alignment = .centerY + + let col = NSStackView(views: [top]) + col.orientation = .vertical + col.alignment = .leading + col.spacing = 1 + + let act = agentActivity(s) + if !act.isEmpty { + let actLabel = NSTextField(labelWithString: oneLine(act, 52)) + actLabel.font = .systemFont(ofSize: 10) + actLabel.textColor = .secondaryLabelColor + actLabel.lineBreakMode = .byTruncatingTail + col.addArrangedSubview(actLabel) + } + return col + } + + /// Branch label (strip "claude/"), else project. Mirrors rosterLabel. + private func agentLabel(_ s: SessionDTO) -> String { + if let b = s.activity?.gitBranch, !b.isEmpty { + return b.hasPrefix("claude/") ? String(b.dropFirst("claude/".count)) : b + } + return s.project ?? "agent" + } + + /// One-line structural activity. Mirrors agent-roster.ts formatActivity. + private func agentActivity(_ s: SessionDTO) -> String { + guard let a = s.activity else { return "" } + if let p = a.pendingPermission, !p.isEmpty { return "⚠ wants to run " + p } + var bits: [String] = [] + if let e = a.lastError, !e.isEmpty { bits.append("✗ " + e) } + var prog: [String] = [] + if let n = a.turnCount, n > 0 { prog.append("turn \(n)") } + if let tk = a.tokens { + let tot = (tk.input ?? 0) + (tk.output ?? 0) + if tot > 0 { prog.append(tot >= 1000 ? "\(Int((Double(tot) / 1000).rounded()))k tok" : "\(tot) tok") } + } + if !prog.isEmpty { bits.append(prog.joined(separator: " ")) } + if let c = a.lastCommandName, !c.isEmpty { bits.append("$ " + c) } + let tool = a.lastTools?.last ?? "" + let file = (a.filesTouched?.last).map { String($0.split(separator: "/").last ?? "") } ?? "" + if !tool.isEmpty && !file.isEmpty { bits.append(tool + " " + file) } + else if !tool.isEmpty { bits.append(tool) } + else if !file.isEmpty { bits.append(file) } + return bits.joined(separator: " · ") + } + /// A small rounded status chip (tinted background + colored label). private func chip(_ text: String, color: NSColor) -> NSView { let label = NSTextField(labelWithString: text) @@ -499,6 +586,23 @@ final class MenuBarController: NSObject, NSPopoverDelegate { } private struct SessionDTO: Decodable { let state: String + var agent: String? + var project: String? + var activity: ActivityDTO? + } + private struct ActivityDTO: Decodable { + var gitBranch: String? + var turnCount: Int? + var tokens: TokensDTO? + var lastTools: [String]? + var filesTouched: [String]? + var lastCommandName: String? + var lastError: String? + var pendingPermission: String? + } + private struct TokensDTO: Decodable { + var input: Int? + var output: Int? } private struct PingDTO: Decodable { let online: Bool? diff --git a/src/web/agent-roster.test.ts b/src/web/agent-roster.test.ts index 1881833..8645919 100644 --- a/src/web/agent-roster.test.ts +++ b/src/web/agent-roster.test.ts @@ -1,6 +1,6 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { mergeAgentSession, aggregateAgentState, rosterLabel, type RosterSession } from "./agent-roster.js"; +import { mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity, type RosterSession } from "./agent-roster.js"; const NOW = 1_700_000_000_000; const WINDOW = 30 * 60_000; @@ -57,7 +57,7 @@ describe("source-injection safety (island injects these verbatim)", () => { // island.ts embeds `${mergeAgentSession}` into the page; the browser eval's // the function source. Assert each is self-contained: its source eval's to a // working function with no external references. - for (const fn of [mergeAgentSession, aggregateAgentState, rosterLabel]) { + for (const fn of [mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity]) { test(`${fn.name} source eval's to a working function`, () => { // eslint-disable-next-line @typescript-eslint/no-implied-eval const rebuilt = new Function(`return (${fn.toString()})`)() as (...a: unknown[]) => unknown; @@ -66,6 +66,8 @@ describe("source-injection safety (island injects these verbatim)", () => { assert.equal(rebuilt([s({ state: "error" })], NOW, WINDOW), "error"); } else if (fn === rosterLabel) { assert.equal(rebuilt(s({ project: "p" })), "p"); + } else if (fn === formatActivity) { + assert.equal(rebuilt(s({ activity: { pendingPermission: "bash" } })), "⚠ wants to run bash"); } else { assert.equal((rebuilt([], s(), NOW, WINDOW) as RosterSession[]).length, 1); } @@ -73,6 +75,34 @@ describe("source-injection safety (island injects these verbatim)", () => { } }); +describe("formatActivity", () => { + test("no activity → empty", () => { + assert.equal(formatActivity(s({ activity: undefined })), ""); + }); + test("pendingPermission wins over everything", () => { + assert.equal( + formatActivity(s({ activity: { pendingPermission: "Bash", lastError: "x", turnCount: 5 } })), + "⚠ wants to run Bash", + ); + }); + test("error · progress · cmd · tool file, in order", () => { + const out = formatActivity(s({ activity: { + lastError: "ENOENT", + turnCount: 12, + tokens: { input: 1200, output: 800 }, + lastCommandName: "npm", + lastTools: ["Read", "Edit"], + filesTouched: ["/a/b/foo.ts"], + } })); + assert.equal(out, "✗ ENOENT · turn 12 2k tok · $ npm · Edit foo.ts"); + }); + test("tokens under 1000 shown raw; only-tools / only-files handled", () => { + assert.equal(formatActivity(s({ activity: { turnCount: 1, tokens: { input: 300, output: 100 } } })), "turn 1 400 tok"); + assert.equal(formatActivity(s({ activity: { lastTools: ["Grep"] } })), "Grep"); + assert.equal(formatActivity(s({ activity: { filesTouched: ["/x/y/bar.py"] } })), "bar.py"); + }); +}); + describe("rosterLabel", () => { test("prefers the git branch, stripping the claude/ prefix", () => { assert.equal(rosterLabel(s({ activity: { gitBranch: "claude/fix-sentry-build-upload" } })), "fix-sentry-build-upload"); diff --git a/src/web/agent-roster.ts b/src/web/agent-roster.ts index 7bbc6dd..e0839fd 100644 --- a/src/web/agent-roster.ts +++ b/src/web/agent-roster.ts @@ -83,3 +83,52 @@ export function rosterLabel(s: RosterSession): string { } return s.project; } + +/** + * One-line structural summary of a session's Tier-2 activity — what it's *doing* + * and how far along, never conversation content: a pending-permission warning, + * else "✗ error · turn N · Mk tok · $ cmd · Tool file". Returns "" when there's + * no activity. Pure + self-contained (island injects this source; the + * injection-safety test guards it — no imports, no named const-arrows). + */ +export function formatActivity(s: RosterSession): string { + const a = s.activity as + | { + pendingPermission?: unknown; + lastError?: unknown; + turnCount?: unknown; + tokens?: { input?: unknown; output?: unknown }; + lastCommandName?: unknown; + lastTools?: unknown; + filesTouched?: unknown; + } + | undefined; + if (!a || typeof a !== "object") return ""; + if (typeof a.pendingPermission === "string" && a.pendingPermission) { + return "⚠ wants to run " + a.pendingPermission; + } + const bits: string[] = []; + if (typeof a.lastError === "string" && a.lastError) bits.push("✗ " + a.lastError); + + const prog: string[] = []; + if (typeof a.turnCount === "number" && a.turnCount > 0) prog.push("turn " + a.turnCount); + if (a.tokens && typeof a.tokens === "object") { + const tin = typeof a.tokens.input === "number" ? a.tokens.input : 0; + const tout = typeof a.tokens.output === "number" ? a.tokens.output : 0; + const tot = tin + tout; + if (tot > 0) prog.push(tot >= 1000 ? Math.round(tot / 1000) + "k tok" : tot + " tok"); + } + if (prog.length) bits.push(prog.join(" ")); + + if (typeof a.lastCommandName === "string" && a.lastCommandName) bits.push("$ " + a.lastCommandName); + + const tools = Array.isArray(a.lastTools) ? (a.lastTools as unknown[]) : []; + const tool = tools.length ? String(tools[tools.length - 1]) : ""; + const files = Array.isArray(a.filesTouched) ? (a.filesTouched as unknown[]) : []; + const file = files.length ? String(files[files.length - 1]).split("/").pop() || "" : ""; + if (tool && file) bits.push(tool + " " + file); + else if (tool) bits.push(tool); + else if (file) bits.push(file); + + return bits.join(" · "); +} diff --git a/src/web/island.ts b/src/web/island.ts index 7085a4c..9477b87 100644 --- a/src/web/island.ts +++ b/src/web/island.ts @@ -13,7 +13,7 @@ * so the browser runs the exact unit-tested code instead of a drifting copy. */ -import { mergeAgentSession, aggregateAgentState, rosterLabel } from "./agent-roster.js"; +import { mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity } from "./agent-roster.js"; export const ISLAND_HTML = ` @@ -562,7 +562,7 @@ export const ISLAND_HTML = `
- +
    🔔 Notify me when Claude is waiting
    @@ -588,6 +588,7 @@ export const ISLAND_HTML = ` ${mergeAgentSession} ${aggregateAgentState} ${rosterLabel} + ${formatActivity} // Composite identity for a roster row: the same sessionId seen under two // agents (e.g. a git + a shell observer) are DISTINCT rows. Mirrors the // key mergeAgentSession uses; the UI keys per-row history, open-state, and @@ -884,28 +885,9 @@ export const ISLAND_HTML = ` return Math.round(ms / 3600_000) + 'h ago'; } - // O2 — compact one-line summary of a session's Tier-2 activity. Structural - // only (tool names, last command, basename of a touched file). Returns '' - // when there's no activity (e.g. visibility=metadata). - function basename(p) { - if (!p) return ''; - const parts = String(p).split('/'); - return parts[parts.length - 1] || p; - } - function formatActivity(s) { - const a = s.activity; - if (!a) return ''; - if (a.pendingPermission) return '⚠ wants to run ' + a.pendingPermission; - const bits = []; - if (a.lastError) bits.push('✗ ' + a.lastError); - if (a.lastCommandName) bits.push('$ ' + a.lastCommandName); - const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : ''; - const file = a.filesTouched && a.filesTouched.length ? basename(a.filesTouched[a.filesTouched.length - 1]) : ''; - if (tool && file) bits.push(tool + ' ' + file); - else if (tool) bits.push(tool); - else if (file) bits.push(file); - return bits.join(' · '); - } + // O2 — compact one-line Tier-2 activity summary is the source-injected + // formatActivity (from agent-roster.ts, above) — structural only, now also + // surfacing turn count + tokens. Shared with the GUI sidebar. function renderClaudeList() { const recent = recentSessions(); @@ -925,7 +907,7 @@ export const ISLAND_HTML = ` const rb = stateRank[b.state] ?? 9; if (ra !== rb) return ra - rb; return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime(); - }).slice(0, 5); + }).slice(0, 8); for (const s of rows) { const li = document.createElement('li'); if (rowOpen.has(agentKey(s))) li.classList.add('row-open'); diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts index 49a8f3c..245941e 100644 --- a/src/web/lisa-client.ts +++ b/src/web/lisa-client.ts @@ -1014,6 +1014,31 @@ if ('serviceWorker' in navigator) { sbReflectionBody.textContent = '"' + text.replace(/^["“”]+|["“”]+$/g, '').trim() + '"'; }; + // Compact one-line activity summary — mirrors agent-roster.ts formatActivity + // (kept inline because this client script is a no-interpolation template + // literal; the island uses the source-injected shared version). + function sbActivity(s) { + const a = s.activity; + if (!a || typeof a !== 'object') return ''; + if (a.pendingPermission) return '⚠ wants to run ' + a.pendingPermission; + const bits = []; + if (a.lastError) bits.push('✗ ' + a.lastError); + const prog = []; + if (typeof a.turnCount === 'number' && a.turnCount > 0) prog.push('turn ' + a.turnCount); + if (a.tokens && (a.tokens.input || a.tokens.output)) { + const tot = (a.tokens.input || 0) + (a.tokens.output || 0); + prog.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok'); + } + if (prog.length) bits.push(prog.join(' ')); + if (a.lastCommandName) bits.push('$ ' + a.lastCommandName); + const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : ''; + const file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : ''; + if (tool && file) bits.push(tool + ' ' + file); + else if (tool) bits.push(tool); + else if (file) bits.push(file); + return bits.join(' · '); + } + function setClaudeSessions(sessions) { const cutoff = Date.now() - ACTIVE_WINDOW_MS; const recent = sessions.filter(s => new Date(s.lastMtime).getTime() >= cutoff); @@ -1025,7 +1050,7 @@ if ('serviceWorker' in navigator) { const rb = rank[b.state] ?? 9; if (ra !== rb) return ra - rb; return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime(); - }).slice(0, 5); + }).slice(0, 8); while (sbClaudeRows.firstChild) sbClaudeRows.removeChild(sbClaudeRows.firstChild); if (rows.length === 0) { const empty = document.createElement('div'); @@ -1066,6 +1091,15 @@ if ('serviceWorker' in navigator) { row.appendChild(pip); row.appendChild(name); row.appendChild(when); + // Second line: structural activity (turns/tokens/tool·file, ⚠pending, ✗err). + const actText = sbActivity(s); + if (actText) { + const act = document.createElement('div'); + act.className = 'session-act'; + act.textContent = actText; + act.title = actText; + row.appendChild(act); + } row.title = (s.stateReason ? s.state + ' · ' + s.stateReason : s.state) + ' · ' + s.project + ' · ' + s.sessionId; sbClaudeRows.appendChild(row); } diff --git a/src/web/lisa-css.ts b/src/web/lisa-css.ts index 351f9a5..f29880e 100644 --- a/src/web/lisa-css.ts +++ b/src/web/lisa-css.ts @@ -322,6 +322,17 @@ export const MAIN_CSS = ` :root { font-variant-numeric: tabular-nums; font-size: 10.5px; } + /* Second line under name/when: structural activity (turns/tokens/tool·file). */ + .session-row .session-act { + grid-column: 2 / -1; + margin-top: 2px; + font-size: 10px; + color: var(--fg-3); + font-family: ui-monospace, "SF Mono", Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .session-empty { color: var(--fg-faint); font-size: 11.5px; diff --git a/src/web/lisa-html-snapshot.test.ts b/src/web/lisa-html-snapshot.test.ts index 1e8ac35..28bedae 100644 --- a/src/web/lisa-html-snapshot.test.ts +++ b/src/web/lisa-html-snapshot.test.ts @@ -16,11 +16,11 @@ import { MAIN_HTML } from "./lisa-html.js"; * change the GUI markup/CSS/JS, recompute them: * node --import tsx -e 'import("./src/web/lisa-html.ts").then(async m=>{const {createHash}=await import("node:crypto");console.log(m.MAIN_HTML.length, createHash("sha256").update(m.MAIN_HTML).digest("hex"))})' * - * Last updated: sidebar rows labelled by git branch (rosterLabel) + project in tooltip. + * Last updated: rich multi-agent sidebar (agents header, per-row activity line via sbActivity). */ -const EXPECTED_LENGTH = 77677; +const EXPECTED_LENGTH = 79655; const EXPECTED_SHA256 = - "2c806033b5bf4ec839088a35e220d1214a2b328785f406197646042132f88195"; + "ab91c1b4fbb085b2dbdea9c389cfd1a4780613226921ccfdf6f70d2d77d5948a"; test("MAIN_HTML length is byte-identical to the pre-split snapshot", () => { assert.equal(MAIN_HTML.length, EXPECTED_LENGTH); diff --git a/src/web/lisa-html.ts b/src/web/lisa-html.ts index ecb5308..9925677 100644 --- a/src/web/lisa-html.ts +++ b/src/web/lisa-html.ts @@ -77,7 +77,7 @@ ${MAIN_CSS}
    -
    claude code
    +
    agents
    ▶︎ 0