Expose theme-aware status widget integration

This commit is contained in:
jb
2026-08-30 13:00:14 -04:00
parent 9a8169523b
commit bc3e3d6049
4 changed files with 871 additions and 146 deletions
+20 -5
View File
@@ -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 <count> 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
@@ -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 <count> 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.
+451 -110
View File
@@ -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<StatusWidgetColor, string> = {
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<PropertyKey, unknown>;
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<string, RunningSubagent>;
latestCtx: ExtensionContext | null;
latestPi: ExtensionAPI | null;
widgetInterval: ReturnType<typeof setInterval> | null;
statusInterval: ReturnType<typeof setInterval> | 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<typeof setInterval> | 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);
if (sharedRuntime.statusInterval) clearInterval(sharedRuntime.statusInterval);
sharedRuntime.statusInterval = null;
}
function clearRefreshIntervals(): void {
clearRefreshInterval("widget");
clearRefreshInterval("status");
}
function ownsRefreshInterval(
kind: RefreshIntervalKind,
interval: ReturnType<typeof setInterval>,
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;
}
return;
}
try {
ctx.ui.setWidget(
"subagent-status",
(_tui: any, _theme: any) => {
(_tui: any, theme?: StatusWidgetTheme) => {
return {
invalidate() {},
render(width: number) {
return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
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;
}
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<PropertyKey, unknown>;
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);
},
};
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;
+390 -26
View File
@@ -141,6 +141,37 @@ async function emitMockEvent(
}
}
type SharedRuntimeTestState = {
runningSubagents: Map<string, unknown>;
latestCtx: unknown;
latestPi: unknown;
widgetInterval: ReturnType<typeof setInterval> | null;
statusInterval: ReturnType<typeof setInterval> | 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<string, any>;
latestCtx: any;
latestPi: any;
widgetInterval: ReturnType<typeof setInterval> | null;
statusInterval: ReturnType<typeof setInterval> | 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", () => {