From bc3e3d604957a91ed786e5ed86ac5b271c0bf792 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 30 Aug 2026 13:00:14 -0400 Subject: [PATCH] Expose theme-aware status widget integration --- README.md | 25 +- ...-lease-the-status-widget-to-compositors.md | 5 + pi-extension/subagents/index.ts | 571 ++++++++++++++---- test/test.ts | 416 ++++++++++++- 4 files changed, 871 insertions(+), 146 deletions(-) create mode 100644 docs/adr/0001-lease-the-status-widget-to-compositors.md diff --git a/README.md b/README.md index 0867567..af459cb 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,9 @@ The backend is selected automatically: Herdr is used when Pi runs in a Herdr-man `subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In tmux, it runs in a right split off the parent Pi pane. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn. ``` -╭─ Subagents ──────────────────────────── 2 running ─╮ -│ 00:23 scout active · bash 7m │ -│ 00:45 scout-2 waiting 2m │ -╰────────────────────────────────────────────────────╯ +Subagents 2 running + ⟳ 00:23 scout active · bash 7m + ○ 00:45 scout-2 waiting 2m ``` Spawn several in parallel — they run concurrently and steer results back independently as each finishes. @@ -148,7 +147,23 @@ Access is **whitelist-only**. Every sub-agent process is launched with `--no-ext Spawns must name a known agent at **every** depth. A top-level session may spawn anything discoverable; a sub-agent may only spawn the agents in its `subagent_agents` list (enforced via `PI_SUBAGENT_ALLOWED`). There is no agentless spawn route, so a child can never escalate to a full-toolset profile by omitting its agent. -Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global. +Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global. Hosts that compose widgets can optionally use its reload-safe `statusWidget` surface: + +```typescript +const { statusWidget } = globalThis.__pi_interactive_subagents; +const onChange = () => rerender(); +const release = statusWidget.claim(onChange); + +statusWidget.hasContent(); // whether any subagent rows exist +statusWidget.getBodyRowCount(); // uncapped number of running agents +statusWidget.render(width, maxBodyRows, ctx.ui.theme); // compact unboxed lines; cap includes an overflow row +statusWidget.isClaimed(onChange); // true only for the current provider claim +release(); // restore the standalone subagent-status widget +``` + +`render(width, maxBodyRows?, theme?)` accepts Pi's current theme as an optional structural value. The standalone widget receives and uses its own Pi theme automatically. When no theme is passed, a legacy ANSI fallback keeps status colors usable. The header is a compact unboxed `Subagents running` row; body rows remain width-safe, and `maxBodyRows` counts body rows including the overflow summary, not the header. Semantic row colors match Pi's Todo conventions: accent header, muted count, warning active/running, error stalled, dim starting/provider tags/elapsed time, and accent waiting. + +`claim()` replaces any prior claim, and an old release function cannot release a newer claim. Standalone-widget clearing/restoration is deferred to a microtask, so hosts may release and immediately re-claim without a transient fallback widget. `isClaimed(onChange)` checks both callback identity and the current runtime ownership, so hosts can detect invalidation after reload/session rebind. Claims are cleared during session shutdown, including `/reload`, so a fresh host runtime must claim again rather than retaining a stale callback. Calling `render()` directly does not claim or change the standalone widget. ## Role folders diff --git a/docs/adr/0001-lease-the-status-widget-to-compositors.md b/docs/adr/0001-lease-the-status-widget-to-compositors.md new file mode 100644 index 0000000..c198231 --- /dev/null +++ b/docs/adr/0001-lease-the-status-widget-to-compositors.md @@ -0,0 +1,5 @@ +# Lease the status widget to optional compositors + +Subagents exposes its running-status panel through a structural, process-local interface while retaining ownership of the standalone widget. A host claims a callback-identified lease and must verify it before rendering; runtime replacement invalidates the lease and stale releases cannot affect a newer owner. This avoids a package dependency or shared TUI class identity while preserving running subagents across reload and restoring the standalone panel whenever no valid host owns it. + +The composable surface is `statusWidget.render(width, maxBodyRows?, theme?)`. The optional third argument accepts the small structural `{ fg(color, text) }` theme contract, allowing hosted output to use the current Pi theme without importing a shared Theme class. Standalone rendering passes its factory-provided Pi theme. Calls without a theme use a legacy ANSI fallback. Both hosted and standalone output use a compact unboxed `Subagents running` header followed by capped, width-safe body rows; the cap includes an overflow row but not the header. Status colors remain semantic: active/running warning, stalled error, starting dim, waiting accent, with provider tags and elapsed times dimmed. diff --git a/pi-extension/subagents/index.ts b/pi-extension/subagents/index.ts index 3cf85a1..5f61da2 100644 --- a/pi-extension/subagents/index.ts +++ b/pi-extension/subagents/index.ts @@ -55,6 +55,7 @@ import { formatTransitionLine, observeStatus, loadStatusConfig, + normalizeStatusName, } from "./status.ts"; import { getSubagentActivityFile, @@ -97,6 +98,30 @@ const SubagentParams = Type.Object({ type SubagentSessionMode = "standalone" | "lineage-only" | "fork"; +/** + * Optional host integration for composing the running-subagents widget into a + * larger widget. The rendered body is capped by `maxBodyRows`, including the + * overflow summary row when one is needed. The unboxed header is not included + * in that cap. + */ +export type StatusWidgetColor = "accent" | "muted" | "warning" | "error" | "dim"; + +/** + * The small structural theme surface used by the status widget. Keeping this + * local avoids coupling hosts to a particular Theme class/module instance. + */ +export interface StatusWidgetTheme { + fg(color: StatusWidgetColor, text: string): string; +} + +export interface StatusWidget { + hasContent(): boolean; + getBodyRowCount(): number; + render(width: number, maxBodyRows?: number, theme?: StatusWidgetTheme): string[]; + claim(onChange: () => void): () => void; + isClaimed(onChange: () => void): boolean; +} + interface AgentDefaults { model?: string; tools?: string; @@ -178,11 +203,22 @@ export function registerToolExtension(name: string, extensionPath: string): void } // Expose registration on a process-global so project-local extensions loaded -// via jiti (separate module instances) can reach this shared map. Set at module -// load so it's available before any `session_start` listener runs. -(globalThis as any).__pi_interactive_subagents = { - registerToolExtension, +// via jiti (separate module instances) can reach this shared map. Keep the +// object itself stable across /reload so an optional host integration can keep +// its reference while this extension is re-imported. +const existingInteractiveSubagentsApi = (globalThis as any).__pi_interactive_subagents; +const interactiveSubagentsApi = ( + existingInteractiveSubagentsApi != null + && (typeof existingInteractiveSubagentsApi === "object" || typeof existingInteractiveSubagentsApi === "function") + && !Array.isArray(existingInteractiveSubagentsApi) + ? existingInteractiveSubagentsApi + : {} +) as { + registerToolExtension: typeof registerToolExtension; + statusWidget?: StatusWidget; }; +(globalThis as any).__pi_interactive_subagents = interactiveSubagentsApi; +interactiveSubagentsApi.registerToolExtension = registerToolExtension; /** * Map a custom (non-built-in) tool name to the pi-extension file that @@ -450,27 +486,54 @@ function formatUsageSegments(stats: SessionStats): string[] { return segs; } -/** ANSI colors for widget status icons (raw, since the widget bypasses theme). */ -const ICON_GREEN = "\x1b[38;2;126;186;103m"; -const ICON_YELLOW = "\x1b[38;2;214;181;94m"; -const ICON_RED = "\x1b[38;2;224;108;117m"; -const ICON_DIM = "\x1b[38;2;128;128;128m"; +/** + * ANSI fallback for hosts that still call render() without passing Pi's theme. + * The widget remains useful in older hosts while themed hosts get the same + * semantic colors as the rest of Pi's UI. + */ +const FALLBACK_STATUS_ANSI: Record = { + accent: "\x1b[38;2;77;163;255m", + muted: "\x1b[38;2;128;128;128m", + warning: "\x1b[38;2;214;181;94m", + error: "\x1b[38;2;224;108;117m", + dim: "\x1b[38;2;128;128;128m", +}; +const ANSI_RESET = "\x1b[0m"; -/** Map a live status kind to a colored single-char icon for the widget. */ -function widgetIcon(kind: StatusSnapshot["kind"]): string { +const FALLBACK_STATUS_THEME: StatusWidgetTheme = { + fg(color, text) { + return `${FALLBACK_STATUS_ANSI[color]}${text}${ANSI_RESET}`; + }, +}; + +function resolveStatusWidgetTheme(theme?: StatusWidgetTheme): StatusWidgetTheme { + return theme && typeof theme.fg === "function" ? theme : FALLBACK_STATUS_THEME; +} + +function statusWidgetColor(kind: StatusSnapshot["kind"]): StatusWidgetColor { switch (kind) { case "active": case "running": - return `${ICON_YELLOW}⟳${RST}`; + return "warning"; case "stalled": - return `${ICON_RED}⟳${RST}`; + return "error"; case "waiting": + return "accent"; case "starting": default: - return `${ICON_DIM}○${RST}`; + return "dim"; } } +/** Map a live status kind to a themed single-char icon for the widget. */ +function widgetIcon( + kind: StatusSnapshot["kind"], + theme: StatusWidgetTheme = FALLBACK_STATUS_THEME, +): string { + const icon = kind === "active" || kind === "running" || kind === "stalled" ? "⟳" : "○"; + return theme.fg(statusWidgetColor(kind), icon); +} + /** * Wait long enough for a freshly created pane to finish shell startup. * @@ -513,23 +576,40 @@ function getArtifactDir(sessionDir: string, sessionId: string): string { const statusConfig = loadStatusConfig(); -function formatWidgetRightLabel(snapshot: StatusSnapshot): string { - if (snapshot.kind === "starting") return " starting… "; - if (snapshot.kind === "running") return ` running ${snapshot.elapsedText} `; +function formatWidgetRightLabel( + snapshot: StatusSnapshot, + theme: StatusWidgetTheme = FALLBACK_STATUS_THEME, +): string { + const color = statusWidgetColor(snapshot.kind); + const status = (text: string) => theme.fg(color, text); + const elapsed = (text: string) => theme.fg("dim", text); + + if (snapshot.kind === "starting") return status(" starting… "); + if (snapshot.kind === "running") { + return status(" running ") + elapsed(snapshot.elapsedText) + status(" "); + } if (snapshot.kind === "active") { const label = snapshot.activityLabel ?? snapshot.activeScope; - const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : ""; - return label ? ` active · ${label}${duration} ` : " active "; + const duration = snapshot.activeDurationText + ? ` ${elapsed(snapshot.activeDurationText)}` + : ""; + return label + ? status(" active · ") + status(label) + duration + status(" ") + : status(" active ") + duration; } if (snapshot.kind === "waiting") { - const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : ""; - const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : ""; - return ` waiting${duration}${detail} `; + const duration = snapshot.waitingDurationText + ? ` ${elapsed(snapshot.waitingDurationText)}` + : ""; + const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : ""; + return status(" waiting") + duration + detail + status(" "); } - const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : ""; - const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : ""; - return ` stalled${detail}${duration} `; + const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : ""; + const duration = snapshot.snapshotProblemText + ? status(` ${snapshot.snapshotProblemText}`) + : ""; + return status(" stalled") + detail + duration + status(" "); } function resolveResultPresentation( @@ -619,15 +699,48 @@ interface RunningSubagent { type PendingDelivery = (pi: ExtensionAPI) => void; +interface StatusWidgetClaim { + ownerToken: symbol; + runtimeToken: symbol | null; + onChange: () => void; +} + +type StatusWidgetRenderer = ( + width: number, + maxBodyRows?: number, + theme?: StatusWidgetTheme, +) => string[]; + +const STATUS_WIDGET_MARKER = Symbol.for("pi-subagents/status-widget"); +const STATUS_WIDGET_CLAIM_MARKER = Symbol.for("pi-subagents/status-widget-claim"); + +function isStatusWidgetClaim(value: unknown): value is StatusWidgetClaim { + try { + if (value == null || typeof value !== "object") return false; + const claim = value as Record; + return claim[STATUS_WIDGET_CLAIM_MARKER] === true + && typeof claim.ownerToken === "symbol" + && (claim.runtimeToken === null || typeof claim.runtimeToken === "symbol") + && typeof claim.onChange === "function"; + } catch { + return false; + } +} + interface SharedSubagentRuntime { runningSubagents: Map; latestCtx: ExtensionContext | null; latestPi: ExtensionAPI | null; widgetInterval: ReturnType | null; statusInterval: ReturnType | null; + standaloneWidgetSyncScheduled: boolean; pendingDeliveries: PendingDelivery[]; deliveryFlushScheduled: boolean; activeRuntimeToken: symbol | null; + registeredRuntimeToken: symbol | null; + statusWidgetClaim: StatusWidgetClaim | null; + statusWidgetRenderer: StatusWidgetRenderer; + statusWidget?: StatusWidget; } function createSharedRuntime(): SharedSubagentRuntime { @@ -637,15 +750,38 @@ function createSharedRuntime(): SharedSubagentRuntime { latestPi: null, widgetInterval: null, statusInterval: null, + standaloneWidgetSyncScheduled: false, pendingDeliveries: [], deliveryFlushScheduled: false, activeRuntimeToken: null, + registeredRuntimeToken: null, + statusWidgetClaim: null, + statusWidgetRenderer: () => [], }; } const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??= createSharedRuntime()) as SharedSubagentRuntime; +// Runtimes created by older extension versions do not have the optional widget +// fields yet. Initialize them in place so a parent /reload can upgrade without +// replacing the process-global runtime or its running children. +if (!isStatusWidgetClaim(sharedRuntime.statusWidgetClaim)) { + sharedRuntime.statusWidgetClaim = null; +} +if (typeof sharedRuntime.statusWidgetRenderer !== "function") { + sharedRuntime.statusWidgetRenderer = () => []; +} +if (sharedRuntime.registeredRuntimeToken !== null && typeof sharedRuntime.registeredRuntimeToken !== "symbol") { + sharedRuntime.registeredRuntimeToken = null; +} +if (sharedRuntime.activeRuntimeToken !== null && typeof sharedRuntime.activeRuntimeToken !== "symbol") { + sharedRuntime.activeRuntimeToken = null; +} +if (typeof sharedRuntime.standaloneWidgetSyncScheduled !== "boolean") { + sharedRuntime.standaloneWidgetSyncScheduled = false; +} + /** All currently running subagents, preserved across /reload. */ const runningSubagents = sharedRuntime.runningSubagents; @@ -684,15 +820,42 @@ function schedulePendingDeliveryFlush(): void { setImmediate(flushPendingDeliveries); } -function clearRefreshIntervals(): void { - if (sharedRuntime.widgetInterval) { - clearInterval(sharedRuntime.widgetInterval); +type RefreshIntervalKind = "widget" | "status"; + +function getRefreshInterval(kind: RefreshIntervalKind): ReturnType | null { + return kind === "widget" ? sharedRuntime.widgetInterval : sharedRuntime.statusInterval; +} + +function clearRefreshInterval(kind: RefreshIntervalKind): void { + if (kind === "widget") { + if (sharedRuntime.widgetInterval) clearInterval(sharedRuntime.widgetInterval); sharedRuntime.widgetInterval = null; + return; } - if (sharedRuntime.statusInterval) { - clearInterval(sharedRuntime.statusInterval); - sharedRuntime.statusInterval = null; + + if (sharedRuntime.statusInterval) clearInterval(sharedRuntime.statusInterval); + sharedRuntime.statusInterval = null; +} + +function clearRefreshIntervals(): void { + clearRefreshInterval("widget"); + clearRefreshInterval("status"); +} + +function ownsRefreshInterval( + kind: RefreshIntervalKind, + interval: ReturnType, + runtimeToken: symbol, +): boolean { + if (getRefreshInterval(kind) !== interval) { + clearInterval(interval); + return false; } + if (sharedRuntime.activeRuntimeToken !== runtimeToken) { + clearRefreshInterval(kind); + return false; + } + return true; } function formatElapsedMMSS(startTime: number): string { @@ -702,117 +865,262 @@ function formatElapsedMMSS(startTime: number): string { return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } -const ACCENT = "\x1b[38;2;77;163;255m"; -const RST = "\x1b[0m"; - /** - * Build a bordered content line: │left right│ - * Left content is truncated if needed, right is preserved, padded to fill width. + * Build an unboxed content line with a right-aligned status. The helper keeps + * its historical name for structural compatibility with existing consumers. + * Left and right values may contain ANSI styling, so all fitting decisions use + * visible cell width rather than string length. */ function borderLine(left: string, right: string, width: number): string { if (width <= 0) return ""; - if (width === 1) return `${ACCENT}│${RST}`; - // width = total visible chars for the whole line including │ and │ - const contentWidth = Math.max(0, width - 2); // space inside the two │ chars const rightVis = visibleWidth(right); - - // If the status chunk alone is too wide, prefer preserving it in compact form - // rather than overflowing the terminal. - if (rightVis >= contentWidth) { - const truncRight = truncateToWidth(right, contentWidth); - const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight)); - return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`; + // If the status chunk alone is too wide, preserve it rather than overflowing + // the terminal. The left side is intentionally dropped in this case. + if (rightVis >= width) { + const truncRight = truncateToWidth(right, width); + return `${truncRight}${" ".repeat(Math.max(0, width - visibleWidth(truncRight)))}`; } - const maxLeft = Math.max(0, contentWidth - rightVis); + const maxLeft = Math.max(0, width - rightVis); const truncLeft = truncateToWidth(left, maxLeft); - const leftVis = visibleWidth(truncLeft); - const pad = Math.max(0, contentWidth - leftVis - rightVis); - return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`; + const pad = Math.max(0, width - visibleWidth(truncLeft) - rightVis); + return `${truncLeft}${" ".repeat(pad)}${right}`; } -/** - * Build the bordered top line: ╭─ Title ──── info ─╮ - * All chars are accounted for within `width`. - */ -function borderTop(title: string, info: string, width: number): string { +/** Build the compact, unboxed header row for the status panel. */ +function renderWidgetHeader(count: number, width: number, theme: StatusWidgetTheme): string { if (width <= 0) return ""; - if (width === 1) return `${ACCENT}╭${RST}`; - - // ╭─ Title ───...─── info ─╮ - // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify - const inner = Math.max(0, width - 2); // inside ╭ and ╮ - const titlePart = `─ ${title} `; - const infoPart = ` ${info} ─`; - const fillLen = Math.max(0, inner - titlePart.length - infoPart.length); - const fill = "─".repeat(fillLen); - const content = `${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─"); - return `${ACCENT}╭${content}╮${RST}`; + const title = theme.fg("accent", "Subagents"); + const info = theme.fg("muted", `${count} running`); + return truncateToWidth(`${title} ${info}`, width); } -/** - * Build the bordered bottom line: ╰──────────────────╯ - */ -function borderBottom(width: number): string { - if (width <= 0) return ""; - if (width === 1) return `${ACCENT}╰${RST}`; - - const inner = Math.max(0, width - 2); - return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`; -} - -function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] { +function renderSubagentWidgetLines( + agents: RunningSubagent[], + width: number, + maxBodyRows?: number, + theme?: StatusWidgetTheme, +): string[] { const count = agents.length; - const title = "Subagents"; - const info = `${count} running`; + const renderTheme = resolveStatusWidgetTheme(theme); + const bodyLimit = maxBodyRows == null || !Number.isFinite(maxBodyRows) + ? null + : Math.max(0, Math.floor(maxBodyRows)); - const lines: string[] = [borderTop(title, info, width)]; + // The body limit includes the overflow summary. This lets a host reserve a + // fixed number of rows without the summary unexpectedly pushing the + // composite widget over its available height. + const visibleAgentCount = bodyLimit == null || agents.length <= bodyLimit + ? agents.length + : Math.max(0, bodyLimit - 1); + const overflow = agents.length - visibleAgentCount; - for (const agent of agents) { - const elapsed = formatElapsedMMSS(agent.startTime); - const agentTag = agent.agent ? ` (${agent.agent})` : ""; + const lines: string[] = [renderWidgetHeader(count, width, renderTheme)]; + + for (const agent of agents.slice(0, visibleAgentCount)) { + const elapsed = renderTheme.fg("dim", formatElapsedMMSS(agent.startTime)); const snapshot = classifyStatus(agent.statusState, Date.now()); - const icon = widgetIcon(snapshot.kind); - const left = ` ${icon} ${elapsed} ${agent.name}${agentTag} `; + const statusColor = statusWidgetColor(snapshot.kind); + const icon = widgetIcon(snapshot.kind, renderTheme); + const name = renderTheme.fg(statusColor, normalizeStatusName(agent.name)); + const agentTag = agent.agent + ? renderTheme.fg("dim", ` (${normalizeStatusName(agent.agent)})`) + : ""; + const left = ` ${icon} ${elapsed} ${name}${agentTag} `; const right = statusConfig.enabled - ? formatWidgetRightLabel(snapshot) - : agent.cli === "claude" - ? " running… " - : " starting… "; + ? formatWidgetRightLabel(snapshot, renderTheme) + : renderTheme.fg( + statusColor, + agent.cli === "claude" ? " running… " : " starting… ", + ); lines.push(borderLine(left, right, width)); } - lines.push(borderBottom(width)); + if (overflow > 0 && bodyLimit !== 0) { + lines.push(borderLine(renderTheme.fg("dim", `+${overflow} more running.`), "", width)); + } + return lines; } -function updateWidget() { +function clearStandaloneWidget(): void { + const ctx = sharedRuntime.latestCtx; + if (!ctx?.hasUI) return; + try { + ctx.ui.setWidget("subagent-status", undefined); + } catch { + // Pi may be tearing down the UI while a host releases its claim. + } +} + +function setStandaloneWidget(): void { const ctx = sharedRuntime.latestCtx; if (!ctx?.hasUI) return; - if (runningSubagents.size === 0) { - ctx.ui.setWidget("subagent-status", undefined); - if (sharedRuntime.widgetInterval) { - clearInterval(sharedRuntime.widgetInterval); - sharedRuntime.widgetInterval = null; + try { + ctx.ui.setWidget( + "subagent-status", + (_tui: any, theme?: StatusWidgetTheme) => { + return { + invalidate() {}, + render(width: number) { + return renderSubagentWidgetLines( + Array.from(runningSubagents.values()), + width, + undefined, + theme, + ); + }, + }; + }, + { placement: "aboveEditor" }, + ); + } catch { + // Pi may be tearing down the UI while a claim is being released. + } +} + +function getCurrentStatusWidgetClaim(): StatusWidgetClaim | null { + const claim = sharedRuntime.statusWidgetClaim; + return isStatusWidgetClaim(claim) + && claim.runtimeToken === sharedRuntime.activeRuntimeToken + ? claim + : null; +} + +function synchronizeStandaloneWidget(): void { + const claim = sharedRuntime.statusWidgetClaim; + if (getCurrentStatusWidgetClaim()) { + clearStandaloneWidget(); + return; + } + + // Drop malformed or stale ownership before restoring the fallback widget. + if (claim !== null) sharedRuntime.statusWidgetClaim = null; + if (runningSubagents.size > 0) setStandaloneWidget(); + else clearStandaloneWidget(); +} + +function scheduleStandaloneWidgetSync(): void { + if (sharedRuntime.standaloneWidgetSyncScheduled) return; + sharedRuntime.standaloneWidgetSyncScheduled = true; + queueMicrotask(() => { + sharedRuntime.standaloneWidgetSyncScheduled = false; + synchronizeStandaloneWidget(); + }); +} + +function notifyStatusWidgetClaim(): void { + const claim = sharedRuntime.statusWidgetClaim; + if (!claim) return; + + const currentClaim = getCurrentStatusWidgetClaim(); + if (!currentClaim) { + if (sharedRuntime.statusWidgetClaim === claim) { + sharedRuntime.statusWidgetClaim = null; + scheduleStandaloneWidgetSync(); } return; } - ctx.ui.setWidget( - "subagent-status", - (_tui: any, _theme: any) => { - return { - invalidate() {}, - render(width: number) { - return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width); - }, + try { + currentClaim.onChange(); + } catch { + // A host can be destroyed independently of this extension. Drop a claim + // whose callback is no longer usable, then restore the standalone widget. + if (sharedRuntime.statusWidgetClaim === claim) { + sharedRuntime.statusWidgetClaim = null; + scheduleStandaloneWidgetSync(); + } + } +} + +function isCurrentStatusWidgetClaim(onChange: () => void): boolean { + return typeof onChange === "function" && getCurrentStatusWidgetClaim()?.onChange === onChange; +} + +function releaseStatusWidgetClaim(ownerToken: symbol): void { + const claim = sharedRuntime.statusWidgetClaim; + if (!isStatusWidgetClaim(claim) || claim.ownerToken !== ownerToken) return; + sharedRuntime.statusWidgetClaim = null; + if (runningSubagents.size === 0) clearRefreshIntervals(); + scheduleStandaloneWidgetSync(); +} + +function isStatusWidget(value: unknown): value is StatusWidget { + try { + if (value == null || typeof value !== "object") return false; + const candidate = value as Record; + return candidate[STATUS_WIDGET_MARKER] === true + && typeof candidate.hasContent === "function" + && typeof candidate.getBodyRowCount === "function" + && typeof candidate.render === "function" + && typeof candidate.claim === "function" + && typeof candidate.isClaimed === "function"; + } catch { + return false; + } +} + +function createStatusWidget(): StatusWidget { + const widget: StatusWidget = { + hasContent: () => runningSubagents.size > 0, + getBodyRowCount: () => runningSubagents.size, + render: (width, maxBodyRows, theme) => runningSubagents.size === 0 + ? [] + : sharedRuntime.statusWidgetRenderer(width, maxBodyRows, theme), + isClaimed: (onChange) => isCurrentStatusWidgetClaim(onChange), + claim(onChange) { + if (typeof onChange !== "function") { + throw new TypeError("statusWidget.claim requires an onChange callback"); + } + + const ownerToken = Symbol("pi-subagents/status-widget-claim"); + const claim: StatusWidgetClaim = { + ownerToken, + runtimeToken: sharedRuntime.activeRuntimeToken, + onChange, }; + Object.defineProperty(claim, STATUS_WIDGET_CLAIM_MARKER, { value: true }); + sharedRuntime.statusWidgetClaim = claim; + scheduleStandaloneWidgetSync(); + + return () => releaseStatusWidgetClaim(ownerToken); }, - { placement: "aboveEditor" }, - ); + }; + Object.defineProperty(widget, STATUS_WIDGET_MARKER, { value: true }); + return widget; +} + +// Keep the integration object stable across reload while rebinding its +// renderer to this module instance's current status configuration. A widget +// from an older/malformed runtime is replaced rather than exposing an +// untrusted contract to the host. +sharedRuntime.statusWidgetRenderer = (width, maxBodyRows, theme) => + renderSubagentWidgetLines(Array.from(runningSubagents.values()), width, maxBodyRows, theme); +const statusWidget = isStatusWidget(sharedRuntime.statusWidget) + ? sharedRuntime.statusWidget + : createStatusWidget(); +sharedRuntime.statusWidget = statusWidget; +interactiveSubagentsApi.statusWidget = statusWidget; + +function updateWidget() { + if (runningSubagents.size === 0) { + clearStandaloneWidget(); + clearRefreshIntervals(); + notifyStatusWidgetClaim(); + return; + } + + if (sharedRuntime.statusWidgetClaim) { + clearStandaloneWidget(); + notifyStatusWidgetClaim(); + return; + } + + setStandaloneWidget(); } /** @@ -1101,14 +1409,15 @@ function handleSubagentSteer( } function startStatusRefresh() { - if (!statusConfig.enabled || sharedRuntime.statusInterval) return; + if (!statusConfig.enabled) return; + const runtimeToken = sharedRuntime.activeRuntimeToken; + if (!runtimeToken) return; + if (getRefreshInterval("status")) return; - sharedRuntime.statusInterval = setInterval(() => { + const interval = setInterval(() => { + if (!ownsRefreshInterval("status", interval, runtimeToken)) return; if (runningSubagents.size === 0) { - if (sharedRuntime.statusInterval) { - clearInterval(sharedRuntime.statusInterval); - sharedRuntime.statusInterval = null; - } + clearRefreshInterval("status"); return; } @@ -1150,6 +1459,7 @@ function startStatusRefresh() { }); } }, 1000); + sharedRuntime.statusInterval = interval; } // Resuming a finished session is always autonomous: the relaunched agent runs @@ -1165,6 +1475,8 @@ export const __test__ = { defaultShellReadyDelayMs, getShellReadyDelayMs, renderSubagentWidgetLines, + statusWidget, + updateWidget, loadAgentDefaults, discoverAgentDefinitions, resolveEffectiveSessionMode, @@ -1197,11 +1509,18 @@ export const __test__ = { }; function startWidgetRefresh() { - if (sharedRuntime.widgetInterval) return; + const runtimeToken = sharedRuntime.activeRuntimeToken; + if (!runtimeToken) return; + if (getRefreshInterval("widget")) return; + updateWidget(); // immediate first render - sharedRuntime.widgetInterval = setInterval(() => { + if (runningSubagents.size === 0) return; + + const interval = setInterval(() => { + if (!ownsRefreshInterval("widget", interval, runtimeToken)) return; updateWidget(); }, 1000); + sharedRuntime.widgetInterval = interval; } /** @@ -1706,11 +2025,25 @@ async function watchSubagent( export default function subagentsExtension(pi: ExtensionAPI) { const runtimeToken = Symbol("pi-subagents/runtime"); + // The newest registered extension instance is the only one allowed to bind + // a session. This prevents a delayed old session_start from rebinding stale + // callbacks after a replacement runtime has already started. + sharedRuntime.registeredRuntimeToken = runtimeToken; // Bind only after Pi has installed the fresh runtime. During /reload the old // session_shutdown handler clears these references first, so watcher results // cannot enqueue a turn through stale extension APIs. pi.on("session_start", (_event, ctx) => { + if (sharedRuntime.registeredRuntimeToken !== runtimeToken) return; + + // A fresh runtime may start before the old runtime emits shutdown. Drop + // old UI ownership and intervals before binding the fresh context. + if (sharedRuntime.activeRuntimeToken !== runtimeToken) { + sharedRuntime.statusWidgetClaim = null; + clearRefreshIntervals(); + scheduleStandaloneWidgetSync(); + } + sharedRuntime.activeRuntimeToken = runtimeToken; sharedRuntime.latestPi = pi; sharedRuntime.latestCtx = ctx; @@ -1729,6 +2062,14 @@ export default function subagentsExtension(pi: ExtensionAPI) { // state already rebound to the replacement runtime. if (sharedRuntime.activeRuntimeToken !== runtimeToken) return; + // The callback belongs to the runtime/UI that is shutting down. A fresh + // runtime must explicitly claim again rather than inheriting a stale host + // callback from the old session. + clearStandaloneWidget(); + sharedRuntime.statusWidgetClaim = null; + if (sharedRuntime.registeredRuntimeToken === runtimeToken) { + sharedRuntime.registeredRuntimeToken = null; + } sharedRuntime.activeRuntimeToken = null; sharedRuntime.latestPi = null; sharedRuntime.latestCtx = null; diff --git a/test/test.ts b/test/test.ts index 9ceb4fd..9428475 100644 --- a/test/test.ts +++ b/test/test.ts @@ -141,6 +141,37 @@ async function emitMockEvent( } } +type SharedRuntimeTestState = { + runningSubagents: Map; + latestCtx: unknown; + latestPi: unknown; + widgetInterval: ReturnType | null; + statusInterval: ReturnType | null; + standaloneWidgetSyncScheduled: boolean; + pendingDeliveries: { length: number }; + deliveryFlushScheduled: boolean; + activeRuntimeToken: symbol | null; + registeredRuntimeToken: symbol | null; + statusWidgetClaim: unknown; + statusWidget?: unknown; +}; + +function resetSharedRuntimeForTest(shared: SharedRuntimeTestState): void { + if (shared.widgetInterval) clearInterval(shared.widgetInterval); + if (shared.statusInterval) clearInterval(shared.statusInterval); + shared.widgetInterval = null; + shared.statusInterval = null; + shared.standaloneWidgetSyncScheduled = false; + shared.runningSubagents.clear(); + shared.latestCtx = null; + shared.latestPi = null; + shared.statusWidgetClaim = null; + shared.pendingDeliveries.length = 0; + shared.deliveryFlushScheduled = false; + shared.activeRuntimeToken = null; + shared.registeredRuntimeToken = null; +} + function restoreEnvVar(name: string, value: string | undefined) { if (value === undefined) { delete process.env[name]; @@ -1860,33 +1891,11 @@ describe("commands", () => { describe("reload lifecycle", () => { const testApi = (subagentsModule as any).__test__; - const shared = testApi.sharedRuntime as { - runningSubagents: Map; - latestCtx: any; - latestPi: any; - widgetInterval: ReturnType | null; - statusInterval: ReturnType | null; - pendingDeliveries: Array<(pi: any) => void>; - deliveryFlushScheduled: boolean; - activeRuntimeToken: symbol | null; - }; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; const ctx = { hasUI: false, ui: { setWidget() {} } }; - function resetSharedRuntime() { - if (shared.widgetInterval) clearInterval(shared.widgetInterval); - if (shared.statusInterval) clearInterval(shared.statusInterval); - shared.widgetInterval = null; - shared.statusInterval = null; - shared.runningSubagents.clear(); - shared.latestCtx = null; - shared.latestPi = null; - shared.pendingDeliveries.length = 0; - shared.deliveryFlushScheduled = false; - shared.activeRuntimeToken = null; - } - - beforeEach(resetSharedRuntime); - after(resetSharedRuntime); + beforeEach(() => resetSharedRuntimeForTest(shared)); + after(() => resetSharedRuntimeForTest(shared)); it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => { const oldRuntime = createMockExtensionApi(); @@ -2623,6 +2632,71 @@ describe("subagent startup delay", () => { }); }); describe("subagents widget rendering", () => { + function createThemeSpy() { + const calls: Array<{ color: string; text: string }> = []; + return { + calls, + theme: { + fg(color: string, text: string) { + calls.push({ color, text }); + return `\x1b[38;5;${calls.length}m${text}\x1b[0m`; + }, + }, + }; + } + + function createWidgetAgent( + id: string, + kind: "active" | "running" | "stalled" | "starting" | "waiting", + ) { + const startTime = kind === "starting" ? 1_061_000 - 5_000 : 1_000_000 - 5_000; + let statusState = createStatusState({ + source: kind === "running" ? "claude" : "pi", + startTimeMs: startTime, + }); + if (kind === "active" || kind === "waiting") { + statusState = observeStatus( + statusState, + { + snapshot: "present", + updatedAt: startTime + 1_000, + sequence: 1, + phase: kind, + ...(kind === "active" + ? { + active: true, + activeScope: "tool", + activeSince: startTime + 1_000, + activityLabel: "bash", + } + : { waitingSince: startTime + 1_000 }), + }, + startTime + 1_000, + ); + } else if (kind === "stalled") { + statusState = observeStatus( + statusState, + { snapshot: "missing" }, + startTime + 1_000, + ); + } + + return { + id, + name: id, + agent: "worker", + task: "", + surface: "surface", + startTime, + sessionFile: `${id}.jsonl`, + statusState, + }; + } + + function createActiveWidgetAgent(id: string) { + return createWidgetAgent(id, "active"); + } + it("keeps every rendered line within a very narrow width", () => { const testApi = (subagentsModule as any).__test__; assert.ok(testApi, "expected subagents test helpers to be exported"); @@ -2663,13 +2737,33 @@ describe("subagents widget rendering", () => { assert.deepEqual( lines.map((line: string) => visibleWidth(line)), - [16, 16, 16, 16, 16], + [16, 16, 16, 16], ); + assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line))); } finally { Date.now = originalNow; } }); + it("sanitizes whitespace in agent names before rendering rows", () => { + const testApi = (subagentsModule as any).__test__; + const startTime = Date.now() - 5_000; + const lines = testApi.renderSubagentWidgetLines([ + { + id: "unsafe", + name: "worker\nwith\ttabs", + task: "", + surface: "s1", + startTime, + sessionFile: "sess1", + statusState: createStatusState({ source: "pi", startTimeMs: startTime }), + }, + ], 80); + + assert.ok(lines.every((line: string) => !/[\r\n]/.test(line))); + assert.match(lines[1] ?? "", /worker with tabs/); + }); + it("truncates the right-hand status instead of overflowing when it alone is too wide", () => { const testApi = (subagentsModule as any).__test__; assert.ok(testApi, "expected subagents test helpers to be exported"); @@ -2707,6 +2801,276 @@ describe("subagents widget rendering", () => { } } }); + + it("uses the standalone widget factory theme for semantic status colors", () => { + const testApi = (subagentsModule as any).__test__; + const widgetCalls: any[] = []; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + shared.latestCtx = { + hasUI: true, + ui: { setWidget: (...args: any[]) => widgetCalls.push(args) }, + }; + shared.runningSubagents.set("active", createActiveWidgetAgent("active")); + + testApi.updateWidget(); + const { theme, calls } = createThemeSpy(); + const component = widgetCalls[0][1](undefined, theme); + const lines = withMockedNow(1_000_000, () => component.render(80)); + + assert.equal(lines.length, 2, "header plus one body row"); + assert.equal(calls.find((call) => call.text === "Subagents")?.color, "accent"); + assert.equal(calls.find((call) => call.text === "1 running")?.color, "muted"); + assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active"))); + assert.equal(calls.find((call) => call.text === " (worker)")?.color, "dim"); + assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05")); + assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line))); + }); + + it("uses the optional theme passed to hosted rendering", () => { + const testApi = (subagentsModule as any).__test__; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + shared.runningSubagents.set("active", createActiveWidgetAgent("active")); + const { theme, calls } = createThemeSpy(); + + const lines = withMockedNow(1_000_000, () => testApi.statusWidget.render(80, undefined, theme)); + + assert.equal(lines.length, 2, "header plus one body row"); + assert.ok(calls.some((call) => call.color === "accent" && call.text === "Subagents")); + assert.ok(calls.some((call) => call.color === "muted" && call.text === "1 running")); + assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active"))); + assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05")); + assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line))); + }); + + it("maps every status kind to a semantic row color", () => { + const testApi = (subagentsModule as any).__test__; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + for (const [id, kind] of [ + ["active", "active"], + ["running", "running"], + ["stalled", "stalled"], + ["starting", "starting"], + ["waiting", "waiting"], + ] as const) { + shared.runningSubagents.set(id, createWidgetAgent(id, kind)); + } + const { theme, calls } = createThemeSpy(); + + withMockedNow(1_061_000, () => testApi.statusWidget.render(120, undefined, theme)); + + const expected = { + active: "warning", + running: "warning", + stalled: "error", + starting: "dim", + waiting: "accent", + } as const; + for (const [id, kind] of [ + ["active", "active"], + ["running", "running"], + ["stalled", "stalled"], + ["starting", "starting"], + ["waiting", "waiting"], + ] as const) { + assert.ok( + calls.some((call) => call.text === id && call.color === expected[kind]), + `${id} should use ${expected[kind]}`, + ); + } + }); +}); + +describe("status widget host integration", () => { + const testApi = (subagentsModule as any).__test__; + const statusWidget = (globalThis as any).__pi_interactive_subagents.statusWidget; + const shared = testApi.sharedRuntime as SharedRuntimeTestState; + + function addRunning(id: string) { + const startTime = Date.now(); + shared.runningSubagents.set(id, { + id, + name: id, + task: "", + surface: "surface", + startTime, + sessionFile: `${id}.jsonl`, + statusState: createStatusState({ source: "pi", startTimeMs: startTime }), + }); + } + + beforeEach(() => resetSharedRuntimeForTest(shared)); + after(() => resetSharedRuntimeForTest(shared)); + + it("falls back to the standalone widget when unclaimed", () => { + const widgetCalls: any[] = []; + shared.latestCtx = { + hasUI: true, + ui: { setWidget: (...args: any[]) => widgetCalls.push(args) }, + }; + addRunning("worker"); + + testApi.updateWidget(); + + assert.equal(widgetCalls.length, 1); + assert.equal(widgetCalls[0][0], "subagent-status"); + assert.equal(typeof widgetCalls[0][1], "function"); + }); + + it("suppresses, notifies, and restores the standalone widget around a claim", async () => { + const widgetCalls: any[] = []; + shared.latestCtx = { + hasUI: true, + ui: { setWidget: (...args: any[]) => widgetCalls.push(args) }, + }; + addRunning("worker"); + testApi.updateWidget(); + + let changes = 0; + const onChange = () => changes++; + const release = statusWidget.claim(onChange); + assert.equal(statusWidget.isClaimed(onChange), true); + assert.equal(statusWidget.isClaimed(() => {}), false); + assert.equal(typeof widgetCalls.at(-1)[1], "function", "claim sync is deferred"); + + await Promise.resolve(); + assert.equal(widgetCalls.at(-1)[1], undefined); + + testApi.updateWidget(); + assert.equal(changes, 1); + + release(); + assert.equal(statusWidget.isClaimed(onChange), false); + assert.equal(widgetCalls.at(-1)[1], undefined, "release sync is deferred"); + await Promise.resolve(); + assert.equal(typeof widgetCalls.at(-1)[1], "function"); + release(); + assert.equal(widgetCalls.length, 4, "release is idempotent"); + }); + + it("does not restore standalone UI between an immediate release and re-claim", async () => { + const widgetCalls: any[] = []; + shared.latestCtx = { + hasUI: true, + ui: { setWidget: (...args: any[]) => widgetCalls.push(args) }, + }; + addRunning("worker"); + testApi.updateWidget(); + + const firstChange = () => {}; + const releaseFirst = statusWidget.claim(firstChange); + releaseFirst(); + const secondChange = () => {}; + const releaseSecond = statusWidget.claim(secondChange); + await Promise.resolve(); + + assert.equal(statusWidget.isClaimed(firstChange), false); + assert.equal(statusWidget.isClaimed(secondChange), true); + assert.equal(widgetCalls.at(-1)[1], undefined); + releaseSecond(); + await Promise.resolve(); + assert.equal(typeof widgetCalls.at(-1)[1], "function"); + }); + + it("caps body rows with an honest overflow row", () => { + addRunning("worker"); + addRunning("scout"); + addRunning("reviewer"); + + const lines = statusWidget.render(40, 2); + assert.equal(lines.length, 3, "header + one agent + overflow"); + assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line))); + assert.match(lines[2], /\+2 more running\./); + assert.equal(statusWidget.getBodyRowCount(), 3); + assert.equal(statusWidget.hasContent(), true); + }); + + it("keeps capped output within narrow widths", () => { + addRunning("worker"); + addRunning("scout"); + + for (const width of [0, 1, 2, 4, 8]) { + for (const line of statusWidget.render(width, 1)) { + assert.ok( + visibleWidth(line) <= width, + `expected line width <= ${width}, got ${visibleWidth(line)}`, + ); + } + } + }); + + it("drops old claims during reload and allows a fresh runtime to claim", async () => { + const oldRuntime = createMockExtensionApi(); + (subagentsModule as any).default(oldRuntime.api); + const oldCtx = { + hasUI: true, + ui: { setWidget() {} }, + }; + await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, oldCtx); + addRunning("worker"); + + let oldChanges = 0; + const oldOnChange = () => oldChanges++; + const oldRelease = statusWidget.claim(oldOnChange); + + // Bind the replacement before the old runtime emits shutdown. The old + // shutdown must not undo the fresh binding or retain its old callback. + const freshRuntime = createMockExtensionApi(); + (subagentsModule as any).default(freshRuntime.api); + await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, oldCtx); + // A delayed old start must also be ignored once the fresh runtime is bound. + await emitMockEvent(oldRuntime, "session_start", { reason: "reload" }, oldCtx); + assert.equal(shared.latestPi, freshRuntime.api); + assert.equal(statusWidget.isClaimed(oldOnChange), false); + await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, oldCtx); + testApi.updateWidget(); + + assert.equal(oldChanges, 0); + assert.equal(shared.statusWidgetClaim, null); + assert.equal(shared.runningSubagents.size, 1); + + let freshChanges = 0; + const freshRelease = statusWidget.claim(() => freshChanges++); + oldRelease(); + testApi.updateWidget(); + assert.equal(freshChanges, 1, "a stale release cannot affect the fresh claim"); + freshRelease(); + }); + + it("replaces malformed and old widget contracts on a duplicate load", async () => { + const globalApi = (globalThis as any).__pi_interactive_subagents; + (globalThis as any).__pi_interactive_subagents = "old-api"; + shared.statusWidgetClaim = { id: 1, onChange: () => {} }; + await import(`../pi-extension/subagents/index.ts?malformed-global=${Date.now()}`); + const repairedApi = (globalThis as any).__pi_interactive_subagents; + assert.equal(typeof repairedApi.registerToolExtension, "function"); + assert.equal(repairedApi.statusWidget, globalApi.statusWidget); + + shared.statusWidgetClaim = { id: 1, onChange: () => {} }; + await import(`../pi-extension/subagents/index.ts?old-widget-claim=${Date.now()}`); + assert.equal(shared.statusWidgetClaim, null); + + const oldContract = { + hasContent: () => true, + getBodyRowCount: () => 1, + render: () => [], + claim: () => () => {}, + }; + shared.statusWidget = oldContract; + (globalThis as any).__pi_interactive_subagents.statusWidget = oldContract; + + await import(`../pi-extension/subagents/index.ts?old-widget-contract=${Date.now()}`); + + const currentApi = (globalThis as any).__pi_interactive_subagents; + const current = currentApi.statusWidget; + assert.equal(currentApi, repairedApi); + assert.notEqual(current, oldContract); + assert.equal(typeof current.hasContent, "function"); + assert.equal(typeof current.getBodyRowCount, "function"); + assert.equal(typeof current.render, "function"); + assert.equal(typeof current.claim, "function"); + assert.equal(typeof current.isClaimed, "function"); + assert.equal(shared.statusWidget, current); + }); }); describe("subagent display helpers", () => {