Compare commits
4
Commits
9a8169523b
...
a3cd328ede
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3cd328ede | ||
|
|
0b7af601a3 | ||
|
|
208cd6f4e7 | ||
|
|
bc3e3d6049 |
@@ -2,31 +2,30 @@
|
|||||||
|
|
||||||
# pi-interactive-subagents
|
# pi-interactive-subagents
|
||||||
|
|
||||||
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking.
|
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs, Orca terminal tabs, or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking.
|
||||||
|
|
||||||
The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), otherwise tmux is used when available.
|
The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), then Orca when Pi runs in an Orca-managed terminal (`ORCA_TERMINAL_HANDLE`), otherwise tmux is used when available.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
`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.
|
`subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In Orca, it runs in a new unfocused terminal tab in the current worktree, started in the parent Pi's working directory. 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 ─╮
|
Subagents 2 running
|
||||||
│ 00:23 scout active · bash 7m │
|
⟳ 00:23 scout active · bash 7m
|
||||||
│ 00:45 scout-2 waiting 2m │
|
○ 00:45 scout-2 waiting 2m
|
||||||
╰────────────────────────────────────────────────────╯
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
||||||
|
|
||||||
Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer.
|
Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer.
|
||||||
|
|
||||||
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr subagents use separate tabs, so they do not resize the parent tab.
|
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr and Orca subagents use separate tabs, so they do not resize the parent tab.
|
||||||
|
|
||||||
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, tmux 500
|
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, Orca 500, tmux 500
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
@@ -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.
|
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
|
## Role folders
|
||||||
|
|
||||||
@@ -184,10 +199,13 @@ Status display is configured via `config.json` in the extension directory (copy
|
|||||||
- [pi](https://github.com/badlogic/pi-mono)
|
- [pi](https://github.com/badlogic/pi-mono)
|
||||||
- One supported terminal manager:
|
- One supported terminal manager:
|
||||||
- [Herdr](https://herdr.dev), with Pi launched inside Herdr
|
- [Herdr](https://herdr.dev), with Pi launched inside Herdr
|
||||||
|
- [Orca](https://orca.build), with Pi launched inside an Orca-managed terminal
|
||||||
- [tmux](https://github.com/tmux/tmux)
|
- [tmux](https://github.com/tmux/tmux)
|
||||||
|
|
||||||
Herdr requires no extension configuration once its Pi integration is installed. The extension detects `HERDR_ENV=1` and creates subagents as unfocused tabs in the current workspace.
|
Herdr requires no extension configuration once its Pi integration is installed. The extension detects `HERDR_ENV=1` and creates subagents as unfocused tabs in the current workspace.
|
||||||
|
|
||||||
|
Orca requires no extension configuration either. The extension detects `ORCA_TERMINAL_HANDLE` and creates subagents as unfocused terminal tabs in the current worktree via the `orca` CLI (`ORCA_CLI_COMMAND` is honored when set). New tabs start in the parent Pi's working directory with a bash shell.
|
||||||
|
|
||||||
For tmux:
|
For tmux:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: researcher
|
name: researcher
|
||||||
description: Web researcher — searches the web and synthesizes findings
|
description: Web researcher — searches the web and synthesizes findings
|
||||||
tools: web_search, web_fetch, safe_bash
|
tools: safe_bash
|
||||||
model: openai-codex/gpt-5.6-luna
|
model: openai-codex/gpt-5.6-luna
|
||||||
thinking: xhigh
|
thinking: xhigh
|
||||||
system-prompt: append
|
system-prompt: append
|
||||||
@@ -14,9 +14,9 @@ You operate in an isolated context with no knowledge of any prior conversation.
|
|||||||
|
|
||||||
Process:
|
Process:
|
||||||
1. Break the question into 2-4 searchable facets
|
1. Break the question into 2-4 searchable facets
|
||||||
2. Search with `web_search` using varied angles
|
2. Search with the web using varied angles
|
||||||
3. Read the answers. Identify what's well-covered, what has gaps.
|
3. Read the answers. Identify what's well-covered, what has gaps.
|
||||||
4. For the 2-3 most promising source URLs, use `web_fetch` to get full page content
|
4. For the 2-3 most promising source URLs, use firecrawl to get full page content
|
||||||
5. Synthesize everything into a brief that directly answers the question
|
5. Synthesize everything into a brief that directly answers the question
|
||||||
|
|
||||||
Search strategy — always vary your angles:
|
Search strategy — always vary your angles:
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: worker
|
name: worker
|
||||||
description: General-purpose worker — reads, writes, and edits code
|
description: General-purpose worker — reads, writes, and edits code
|
||||||
tools: read, write, edit, bash, web_search, web_fetch
|
tools: read, write, edit, bash
|
||||||
subagent_agents: scout, researcher
|
subagent_agents: scout, researcher
|
||||||
model: openai-codex/gpt-5.6-luna
|
model: openai-codex/gpt-5.6-luna
|
||||||
thinking: xhigh
|
thinking: xhigh
|
||||||
@@ -26,7 +26,7 @@ Your context is finite. Reading large or unfamiliar codebases directly will burn
|
|||||||
|
|
||||||
You can dispatch:
|
You can dispatch:
|
||||||
- **scout** — read-only recon (read, grep, find, ls). Returns a structured map of files, line ranges, and key snippets. Cheap (haiku). Use for *exploring unfamiliar territory*.
|
- **scout** — read-only recon (read, grep, find, ls). Returns a structured map of files, line ranges, and key snippets. Cheap (haiku). Use for *exploring unfamiliar territory*.
|
||||||
- **researcher** — web research (web_search, web_fetch). Returns a sourced brief. Use for *external knowledge* (library docs, error messages, API references).
|
- **researcher** — web research. Returns a sourced brief. Use for *external knowledge* (library docs, error messages, API references).
|
||||||
|
|
||||||
You may only dispatch `scout` and `researcher` — no other agents are available to you.
|
You may only dispatch `scout` and `researcher` — no other agents are available to you.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+458
-115
@@ -55,6 +55,7 @@ import {
|
|||||||
formatTransitionLine,
|
formatTransitionLine,
|
||||||
observeStatus,
|
observeStatus,
|
||||||
loadStatusConfig,
|
loadStatusConfig,
|
||||||
|
normalizeStatusName,
|
||||||
} from "./status.ts";
|
} from "./status.ts";
|
||||||
import {
|
import {
|
||||||
getSubagentActivityFile,
|
getSubagentActivityFile,
|
||||||
@@ -97,6 +98,30 @@ const SubagentParams = Type.Object({
|
|||||||
|
|
||||||
type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
|
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 {
|
interface AgentDefaults {
|
||||||
model?: string;
|
model?: string;
|
||||||
tools?: 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
|
// Expose registration on a process-global so project-local extensions loaded
|
||||||
// via jiti (separate module instances) can reach this shared map. Set at module
|
// via jiti (separate module instances) can reach this shared map. Keep the
|
||||||
// load so it's available before any `session_start` listener runs.
|
// object itself stable across /reload so an optional host integration can keep
|
||||||
(globalThis as any).__pi_interactive_subagents = {
|
// its reference while this extension is re-imported.
|
||||||
registerToolExtension,
|
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
|
* 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;
|
return segs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ANSI colors for widget status icons (raw, since the widget bypasses theme). */
|
/**
|
||||||
const ICON_GREEN = "\x1b[38;2;126;186;103m";
|
* ANSI fallback for hosts that still call render() without passing Pi's theme.
|
||||||
const ICON_YELLOW = "\x1b[38;2;214;181;94m";
|
* The widget remains useful in older hosts while themed hosts get the same
|
||||||
const ICON_RED = "\x1b[38;2;224;108;117m";
|
* semantic colors as the rest of Pi's UI.
|
||||||
const ICON_DIM = "\x1b[38;2;128;128;128m";
|
*/
|
||||||
|
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. */
|
const FALLBACK_STATUS_THEME: StatusWidgetTheme = {
|
||||||
function widgetIcon(kind: StatusSnapshot["kind"]): string {
|
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) {
|
switch (kind) {
|
||||||
case "active":
|
case "active":
|
||||||
case "running":
|
case "running":
|
||||||
return `${ICON_YELLOW}⟳${RST}`;
|
return "warning";
|
||||||
case "stalled":
|
case "stalled":
|
||||||
return `${ICON_RED}⟳${RST}`;
|
return "error";
|
||||||
case "waiting":
|
case "waiting":
|
||||||
|
return "accent";
|
||||||
case "starting":
|
case "starting":
|
||||||
default:
|
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.
|
* Wait long enough for a freshly created pane to finish shell startup.
|
||||||
*
|
*
|
||||||
@@ -479,6 +542,8 @@ function widgetIcon(kind: StatusSnapshot["kind"]): string {
|
|||||||
* dropped commands. Keep the historical default at 500ms.
|
* dropped commands. Keep the historical default at 500ms.
|
||||||
*/
|
*/
|
||||||
function defaultShellReadyDelayMs(backend: ReturnType<typeof getMuxBackend>): number {
|
function defaultShellReadyDelayMs(backend: ReturnType<typeof getMuxBackend>): number {
|
||||||
|
// Orca surfaces block on a readiness marker at create time, so they only
|
||||||
|
// need the same small buffer as tmux.
|
||||||
return backend === "herdr" ? 1000 : 500;
|
return backend === "herdr" ? 1000 : 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,23 +578,40 @@ function getArtifactDir(sessionDir: string, sessionId: string): string {
|
|||||||
|
|
||||||
const statusConfig = loadStatusConfig();
|
const statusConfig = loadStatusConfig();
|
||||||
|
|
||||||
function formatWidgetRightLabel(snapshot: StatusSnapshot): string {
|
function formatWidgetRightLabel(
|
||||||
if (snapshot.kind === "starting") return " starting… ";
|
snapshot: StatusSnapshot,
|
||||||
if (snapshot.kind === "running") return ` running ${snapshot.elapsedText} `;
|
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") {
|
if (snapshot.kind === "active") {
|
||||||
const label = snapshot.activityLabel ?? snapshot.activeScope;
|
const label = snapshot.activityLabel ?? snapshot.activeScope;
|
||||||
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
|
const duration = snapshot.activeDurationText
|
||||||
return label ? ` active · ${label}${duration} ` : " active ";
|
? ` ${elapsed(snapshot.activeDurationText)}`
|
||||||
|
: "";
|
||||||
|
return label
|
||||||
|
? status(" active · ") + status(label) + duration + status(" ")
|
||||||
|
: status(" active ") + duration;
|
||||||
}
|
}
|
||||||
if (snapshot.kind === "waiting") {
|
if (snapshot.kind === "waiting") {
|
||||||
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
|
const duration = snapshot.waitingDurationText
|
||||||
const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
|
? ` ${elapsed(snapshot.waitingDurationText)}`
|
||||||
return ` waiting${duration}${detail} `;
|
: "";
|
||||||
|
const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : "";
|
||||||
|
return status(" waiting") + duration + detail + status(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = snapshot.statusLabel ? ` · ${snapshot.statusLabel}` : "";
|
const detail = snapshot.statusLabel ? status(` · ${snapshot.statusLabel}`) : "";
|
||||||
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
|
const duration = snapshot.snapshotProblemText
|
||||||
return ` stalled${detail}${duration} `;
|
? status(` ${snapshot.snapshotProblemText}`)
|
||||||
|
: "";
|
||||||
|
return status(" stalled") + detail + duration + status(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveResultPresentation(
|
function resolveResultPresentation(
|
||||||
@@ -619,15 +701,48 @@ interface RunningSubagent {
|
|||||||
|
|
||||||
type PendingDelivery = (pi: ExtensionAPI) => void;
|
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 {
|
interface SharedSubagentRuntime {
|
||||||
runningSubagents: Map<string, RunningSubagent>;
|
runningSubagents: Map<string, RunningSubagent>;
|
||||||
latestCtx: ExtensionContext | null;
|
latestCtx: ExtensionContext | null;
|
||||||
latestPi: ExtensionAPI | null;
|
latestPi: ExtensionAPI | null;
|
||||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||||
statusInterval: ReturnType<typeof setInterval> | null;
|
statusInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
standaloneWidgetSyncScheduled: boolean;
|
||||||
pendingDeliveries: PendingDelivery[];
|
pendingDeliveries: PendingDelivery[];
|
||||||
deliveryFlushScheduled: boolean;
|
deliveryFlushScheduled: boolean;
|
||||||
activeRuntimeToken: symbol | null;
|
activeRuntimeToken: symbol | null;
|
||||||
|
registeredRuntimeToken: symbol | null;
|
||||||
|
statusWidgetClaim: StatusWidgetClaim | null;
|
||||||
|
statusWidgetRenderer: StatusWidgetRenderer;
|
||||||
|
statusWidget?: StatusWidget;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSharedRuntime(): SharedSubagentRuntime {
|
function createSharedRuntime(): SharedSubagentRuntime {
|
||||||
@@ -637,15 +752,38 @@ function createSharedRuntime(): SharedSubagentRuntime {
|
|||||||
latestPi: null,
|
latestPi: null,
|
||||||
widgetInterval: null,
|
widgetInterval: null,
|
||||||
statusInterval: null,
|
statusInterval: null,
|
||||||
|
standaloneWidgetSyncScheduled: false,
|
||||||
pendingDeliveries: [],
|
pendingDeliveries: [],
|
||||||
deliveryFlushScheduled: false,
|
deliveryFlushScheduled: false,
|
||||||
activeRuntimeToken: null,
|
activeRuntimeToken: null,
|
||||||
|
registeredRuntimeToken: null,
|
||||||
|
statusWidgetClaim: null,
|
||||||
|
statusWidgetRenderer: () => [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??=
|
const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??=
|
||||||
createSharedRuntime()) as SharedSubagentRuntime;
|
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. */
|
/** All currently running subagents, preserved across /reload. */
|
||||||
const runningSubagents = sharedRuntime.runningSubagents;
|
const runningSubagents = sharedRuntime.runningSubagents;
|
||||||
|
|
||||||
@@ -684,15 +822,42 @@ function schedulePendingDeliveryFlush(): void {
|
|||||||
setImmediate(flushPendingDeliveries);
|
setImmediate(flushPendingDeliveries);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRefreshIntervals(): void {
|
type RefreshIntervalKind = "widget" | "status";
|
||||||
if (sharedRuntime.widgetInterval) {
|
|
||||||
clearInterval(sharedRuntime.widgetInterval);
|
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;
|
sharedRuntime.widgetInterval = null;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (sharedRuntime.statusInterval) {
|
|
||||||
clearInterval(sharedRuntime.statusInterval);
|
if (sharedRuntime.statusInterval) clearInterval(sharedRuntime.statusInterval);
|
||||||
sharedRuntime.statusInterval = null;
|
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 {
|
function formatElapsedMMSS(startTime: number): string {
|
||||||
@@ -702,117 +867,262 @@ function formatElapsedMMSS(startTime: number): string {
|
|||||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
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│
|
* Build an unboxed content line with a right-aligned status. The helper keeps
|
||||||
* Left content is truncated if needed, right is preserved, padded to fill width.
|
* 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 {
|
function borderLine(left: string, right: string, width: number): string {
|
||||||
if (width <= 0) return "";
|
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);
|
const rightVis = visibleWidth(right);
|
||||||
|
// If the status chunk alone is too wide, preserve it rather than overflowing
|
||||||
// If the status chunk alone is too wide, prefer preserving it in compact form
|
// the terminal. The left side is intentionally dropped in this case.
|
||||||
// rather than overflowing the terminal.
|
if (rightVis >= width) {
|
||||||
if (rightVis >= contentWidth) {
|
const truncRight = truncateToWidth(right, width);
|
||||||
const truncRight = truncateToWidth(right, contentWidth);
|
return `${truncRight}${" ".repeat(Math.max(0, width - visibleWidth(truncRight)))}`;
|
||||||
const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
|
|
||||||
return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLeft = Math.max(0, contentWidth - rightVis);
|
const maxLeft = Math.max(0, width - rightVis);
|
||||||
const truncLeft = truncateToWidth(left, maxLeft);
|
const truncLeft = truncateToWidth(left, maxLeft);
|
||||||
const leftVis = visibleWidth(truncLeft);
|
const pad = Math.max(0, width - visibleWidth(truncLeft) - rightVis);
|
||||||
const pad = Math.max(0, contentWidth - leftVis - rightVis);
|
return `${truncLeft}${" ".repeat(pad)}${right}`;
|
||||||
return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Build the compact, unboxed header row for the status panel. */
|
||||||
* Build the bordered top line: ╭─ Title ──── info ─╮
|
function renderWidgetHeader(count: number, width: number, theme: StatusWidgetTheme): string {
|
||||||
* All chars are accounted for within `width`.
|
|
||||||
*/
|
|
||||||
function borderTop(title: string, info: string, width: number): string {
|
|
||||||
if (width <= 0) return "";
|
if (width <= 0) return "";
|
||||||
if (width === 1) return `${ACCENT}╭${RST}`;
|
const title = theme.fg("accent", "Subagents");
|
||||||
|
const info = theme.fg("muted", `${count} running`);
|
||||||
// ╭─ Title ───...─── info ─╮
|
return truncateToWidth(`${title} ${info}`, width);
|
||||||
// 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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function renderSubagentWidgetLines(
|
||||||
* Build the bordered bottom line: ╰──────────────────╯
|
agents: RunningSubagent[],
|
||||||
*/
|
width: number,
|
||||||
function borderBottom(width: number): string {
|
maxBodyRows?: number,
|
||||||
if (width <= 0) return "";
|
theme?: StatusWidgetTheme,
|
||||||
if (width === 1) return `${ACCENT}╰${RST}`;
|
): string[] {
|
||||||
|
|
||||||
const inner = Math.max(0, width - 2);
|
|
||||||
return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
|
|
||||||
const count = agents.length;
|
const count = agents.length;
|
||||||
const title = "Subagents";
|
const renderTheme = resolveStatusWidgetTheme(theme);
|
||||||
const info = `${count} running`;
|
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 lines: string[] = [renderWidgetHeader(count, width, renderTheme)];
|
||||||
const elapsed = formatElapsedMMSS(agent.startTime);
|
|
||||||
const agentTag = agent.agent ? ` (${agent.agent})` : "";
|
for (const agent of agents.slice(0, visibleAgentCount)) {
|
||||||
|
const elapsed = renderTheme.fg("dim", formatElapsedMMSS(agent.startTime));
|
||||||
const snapshot = classifyStatus(agent.statusState, Date.now());
|
const snapshot = classifyStatus(agent.statusState, Date.now());
|
||||||
const icon = widgetIcon(snapshot.kind);
|
const statusColor = statusWidgetColor(snapshot.kind);
|
||||||
const left = ` ${icon} ${elapsed} ${agent.name}${agentTag} `;
|
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
|
const right = statusConfig.enabled
|
||||||
? formatWidgetRightLabel(snapshot)
|
? formatWidgetRightLabel(snapshot, renderTheme)
|
||||||
: agent.cli === "claude"
|
: renderTheme.fg(
|
||||||
? " running… "
|
statusColor,
|
||||||
: " starting… ";
|
agent.cli === "claude" ? " running… " : " starting… ",
|
||||||
|
);
|
||||||
|
|
||||||
lines.push(borderLine(left, right, width));
|
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;
|
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;
|
const ctx = sharedRuntime.latestCtx;
|
||||||
if (!ctx?.hasUI) return;
|
if (!ctx?.hasUI) return;
|
||||||
|
|
||||||
if (runningSubagents.size === 0) {
|
try {
|
||||||
ctx.ui.setWidget("subagent-status", undefined);
|
ctx.ui.setWidget(
|
||||||
if (sharedRuntime.widgetInterval) {
|
"subagent-status",
|
||||||
clearInterval(sharedRuntime.widgetInterval);
|
(_tui: any, theme?: StatusWidgetTheme) => {
|
||||||
sharedRuntime.widgetInterval = null;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.ui.setWidget(
|
try {
|
||||||
"subagent-status",
|
currentClaim.onChange();
|
||||||
(_tui: any, _theme: any) => {
|
} catch {
|
||||||
return {
|
// A host can be destroyed independently of this extension. Drop a claim
|
||||||
invalidate() {},
|
// whose callback is no longer usable, then restore the standalone widget.
|
||||||
render(width: number) {
|
if (sharedRuntime.statusWidgetClaim === claim) {
|
||||||
return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
|
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);
|
||||||
},
|
},
|
||||||
{ 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 +1411,15 @@ function handleSubagentSteer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startStatusRefresh() {
|
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 (runningSubagents.size === 0) {
|
||||||
if (sharedRuntime.statusInterval) {
|
clearRefreshInterval("status");
|
||||||
clearInterval(sharedRuntime.statusInterval);
|
|
||||||
sharedRuntime.statusInterval = null;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1150,6 +1461,7 @@ function startStatusRefresh() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
sharedRuntime.statusInterval = interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resuming a finished session is always autonomous: the relaunched agent runs
|
// Resuming a finished session is always autonomous: the relaunched agent runs
|
||||||
@@ -1165,6 +1477,8 @@ export const __test__ = {
|
|||||||
defaultShellReadyDelayMs,
|
defaultShellReadyDelayMs,
|
||||||
getShellReadyDelayMs,
|
getShellReadyDelayMs,
|
||||||
renderSubagentWidgetLines,
|
renderSubagentWidgetLines,
|
||||||
|
statusWidget,
|
||||||
|
updateWidget,
|
||||||
loadAgentDefaults,
|
loadAgentDefaults,
|
||||||
discoverAgentDefinitions,
|
discoverAgentDefinitions,
|
||||||
resolveEffectiveSessionMode,
|
resolveEffectiveSessionMode,
|
||||||
@@ -1197,11 +1511,18 @@ export const __test__ = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function startWidgetRefresh() {
|
function startWidgetRefresh() {
|
||||||
if (sharedRuntime.widgetInterval) return;
|
const runtimeToken = sharedRuntime.activeRuntimeToken;
|
||||||
|
if (!runtimeToken) return;
|
||||||
|
if (getRefreshInterval("widget")) return;
|
||||||
|
|
||||||
updateWidget(); // immediate first render
|
updateWidget(); // immediate first render
|
||||||
sharedRuntime.widgetInterval = setInterval(() => {
|
if (runningSubagents.size === 0) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (!ownsRefreshInterval("widget", interval, runtimeToken)) return;
|
||||||
updateWidget();
|
updateWidget();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
sharedRuntime.widgetInterval = interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1706,11 +2027,25 @@ async function watchSubagent(
|
|||||||
|
|
||||||
export default function subagentsExtension(pi: ExtensionAPI) {
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
||||||
const runtimeToken = Symbol("pi-subagents/runtime");
|
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
|
// Bind only after Pi has installed the fresh runtime. During /reload the old
|
||||||
// session_shutdown handler clears these references first, so watcher results
|
// session_shutdown handler clears these references first, so watcher results
|
||||||
// cannot enqueue a turn through stale extension APIs.
|
// cannot enqueue a turn through stale extension APIs.
|
||||||
pi.on("session_start", (_event, ctx) => {
|
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.activeRuntimeToken = runtimeToken;
|
||||||
sharedRuntime.latestPi = pi;
|
sharedRuntime.latestPi = pi;
|
||||||
sharedRuntime.latestCtx = ctx;
|
sharedRuntime.latestCtx = ctx;
|
||||||
@@ -1729,6 +2064,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
// state already rebound to the replacement runtime.
|
// state already rebound to the replacement runtime.
|
||||||
if (sharedRuntime.activeRuntimeToken !== runtimeToken) return;
|
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.activeRuntimeToken = null;
|
||||||
sharedRuntime.latestPi = null;
|
sharedRuntime.latestPi = null;
|
||||||
sharedRuntime.latestCtx = null;
|
sharedRuntime.latestCtx = null;
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Orca surface backend. Subagents run in unfocused terminal tabs in the
|
||||||
|
* current Orca worktree.
|
||||||
|
*
|
||||||
|
* Detection relies on ORCA_TERMINAL_HANDLE, which Orca exports inside its
|
||||||
|
* managed terminals. Inside those terminals `orca` resolves to the Orca CLI
|
||||||
|
* shim; outside them bare `orca` on Linux is usually the GNOME screen reader,
|
||||||
|
* so resolution explicitly rejects /usr/bin/orca.
|
||||||
|
*/
|
||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** GNOME screen reader path — never a valid Orca CLI. */
|
||||||
|
const GNOME_SCREEN_READER = "/usr/bin/orca";
|
||||||
|
|
||||||
|
let cachedCli: string | null | undefined;
|
||||||
|
|
||||||
|
/** Terminal handles created as whole tabs (vs splits), mapped for tab close. */
|
||||||
|
const tabByHandle = new Map<string, string>();
|
||||||
|
|
||||||
|
interface OrcaResponse {
|
||||||
|
ok?: boolean;
|
||||||
|
error?: { message?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrcaTerminalCreated extends OrcaResponse {
|
||||||
|
result?: {
|
||||||
|
terminal?: { handle?: unknown; tabId?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrcaSplitCreated extends OrcaResponse {
|
||||||
|
result?: {
|
||||||
|
split?: { handle?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrcaTerminalRead extends OrcaResponse {
|
||||||
|
result?: {
|
||||||
|
terminal?: { tail?: unknown; status?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker echoed by the startup command once the new terminal has cd'd into
|
||||||
|
* the target directory and is about to exec bash. Written with a quote split
|
||||||
|
* (`__PI_SURFACE_''READY__`) so the typed command line on screen never
|
||||||
|
* matches, only the echoed output does.
|
||||||
|
*/
|
||||||
|
const READY_MARKER = "__PI_SURFACE_READY__";
|
||||||
|
|
||||||
|
function resolveCli(): string | null {
|
||||||
|
if (cachedCli !== undefined) return cachedCli;
|
||||||
|
|
||||||
|
const override = process.env.ORCA_CLI_COMMAND?.trim();
|
||||||
|
if (override) {
|
||||||
|
cachedCli = override;
|
||||||
|
return cachedCli;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = execFileSync("sh", ["-c", "command -v orca"], { encoding: "utf8" }).trim();
|
||||||
|
cachedCli = resolved && resolved !== GNOME_SCREEN_READER ? "orca" : null;
|
||||||
|
} catch {
|
||||||
|
cachedCli = null;
|
||||||
|
}
|
||||||
|
return cachedCli;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAvailable(): boolean {
|
||||||
|
return !!process.env.ORCA_TERMINAL_HANDLE && resolveCli() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupHint(): string {
|
||||||
|
return "Start pi inside an Orca-managed terminal with the `orca` CLI on PATH.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOrca(): string {
|
||||||
|
const cli = resolveCli();
|
||||||
|
if (!isAvailable() || !cli) {
|
||||||
|
throw new Error(`Orca is required for this surface. ${setupHint()}`);
|
||||||
|
}
|
||||||
|
return cli;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellEscape(value: string): string {
|
||||||
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startup command that lands the new terminal in pi's cwd with a bash shell. */
|
||||||
|
function startupCommand(): string {
|
||||||
|
return `cd ${shellEscape(process.cwd())} && echo __PI_SURFACE_''READY__ && exec bash`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepSync(seconds: number): void {
|
||||||
|
try {
|
||||||
|
execFileSync("sleep", [String(seconds)], { stdio: "ignore" });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe marker proving the shell reads and executes typed input. */
|
||||||
|
const SHELL_UP_MARKER = "__PI_SHELL_UP__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block until the new terminal's shell is genuinely interactive.
|
||||||
|
*
|
||||||
|
* Two races exist at startup: input typed before the initial shell execs bash
|
||||||
|
* is swallowed with it, and input typed while bash itself initializes can be
|
||||||
|
* flushed (observed as stray kitty-keyboard residue like `0u` at the prompt).
|
||||||
|
* So first wait for the startup command's ready marker, then repeatedly send a
|
||||||
|
* probe echo until its output appears — an executed probe also clears any
|
||||||
|
* residue from the readline buffer. Falls through after the deadline as
|
||||||
|
* best-effort.
|
||||||
|
*/
|
||||||
|
function waitForSurfaceReady(surface: string, timeoutMs = 15_000): void {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
if (readScreen(surface, 40).includes(READY_MARKER)) break;
|
||||||
|
} catch {}
|
||||||
|
sleepSync(0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
sendCommand(surface, "echo __PI_SHELL_''UP__");
|
||||||
|
} catch {
|
||||||
|
sleepSync(0.25);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const probeDeadline = Math.min(Date.now() + 2_000, deadline);
|
||||||
|
while (Date.now() < probeDeadline) {
|
||||||
|
sleepSync(0.25);
|
||||||
|
try {
|
||||||
|
if (readScreen(surface, 40).includes(SHELL_UP_MARKER)) return;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponse<T extends OrcaResponse>(command: string, output: string): T {
|
||||||
|
let response: T;
|
||||||
|
try {
|
||||||
|
response = JSON.parse(output) as T;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Unexpected Orca ${command} output: ${output.trim() || "(empty)"}`);
|
||||||
|
}
|
||||||
|
if (response.ok === false) {
|
||||||
|
const message =
|
||||||
|
typeof response.error?.message === "string" ? response.error.message : "unknown error";
|
||||||
|
throw new Error(`Orca ${command} failed: ${message}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireId(value: unknown, command: string, field: string): string {
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new Error(`Orca ${command} did not return ${field}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreatedTerminal(output: string): { handle: string; tabId: string } {
|
||||||
|
const response = parseResponse<OrcaTerminalCreated>("terminal create", output);
|
||||||
|
return {
|
||||||
|
handle: requireId(response.result?.terminal?.handle, "terminal create", "result.terminal.handle"),
|
||||||
|
tabId: requireId(response.result?.terminal?.tabId, "terminal create", "result.terminal.tabId"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreatedSplit(output: string): string {
|
||||||
|
const response = parseResponse<OrcaSplitCreated>("terminal split", output);
|
||||||
|
return requireId(response.result?.split?.handle, "terminal split", "result.split.handle");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseReadTail(output: string): string {
|
||||||
|
const response = parseResponse<OrcaTerminalRead>("terminal read", output);
|
||||||
|
const status = response.result?.terminal?.status;
|
||||||
|
if (typeof status === "string" && status !== "running") {
|
||||||
|
throw new Error(`Orca terminal read: terminal is not running (status: ${status})`);
|
||||||
|
}
|
||||||
|
const tail = response.result?.terminal?.tail;
|
||||||
|
if (!Array.isArray(tail)) {
|
||||||
|
throw new Error("Orca terminal read did not return result.terminal.tail");
|
||||||
|
}
|
||||||
|
return tail.filter((line): line is string => typeof line === "string").join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurface(name: string): string {
|
||||||
|
const cli = requireOrca();
|
||||||
|
// Target the enclosing terminal's worktree explicitly: pi's cwd (which the
|
||||||
|
// CLI would otherwise resolve against) may live outside any Orca worktree.
|
||||||
|
const worktreeId = process.env.ORCA_WORKTREE_ID;
|
||||||
|
const output = execFileSync(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"terminal",
|
||||||
|
"create",
|
||||||
|
...(worktreeId ? ["--worktree", `id:${worktreeId}`] : []),
|
||||||
|
"--title",
|
||||||
|
name,
|
||||||
|
"--command",
|
||||||
|
startupCommand(),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
const { handle, tabId } = parseCreatedTerminal(output);
|
||||||
|
tabByHandle.set(handle, tabId);
|
||||||
|
waitForSurfaceReady(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurfaceSplit(
|
||||||
|
name: string,
|
||||||
|
direction: "left" | "right" | "up" | "down",
|
||||||
|
fromSurface?: string,
|
||||||
|
): string {
|
||||||
|
void name;
|
||||||
|
const cli = requireOrca();
|
||||||
|
if (direction === "left" || direction === "up") {
|
||||||
|
throw new Error(`Orca terminal splits only support right or down, not ${direction}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = fromSurface ?? process.env.ORCA_TERMINAL_HANDLE!;
|
||||||
|
const output = execFileSync(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"terminal",
|
||||||
|
"split",
|
||||||
|
"--terminal",
|
||||||
|
source,
|
||||||
|
"--direction",
|
||||||
|
direction === "right" ? "horizontal" : "vertical",
|
||||||
|
"--command",
|
||||||
|
startupCommand(),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
const handle = parseCreatedSplit(output);
|
||||||
|
waitForSurfaceReady(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendCommand(surface: string, command: string): void {
|
||||||
|
const cli = requireOrca();
|
||||||
|
const output = execFileSync(
|
||||||
|
cli,
|
||||||
|
["terminal", "send", "--terminal", surface, "--text", command, "--enter", "--json"],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
parseResponse("terminal send", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScreenArgs(surface: string, lines: number): string[] {
|
||||||
|
return [
|
||||||
|
"terminal",
|
||||||
|
"read",
|
||||||
|
"--terminal",
|
||||||
|
surface,
|
||||||
|
"--screen",
|
||||||
|
"--limit",
|
||||||
|
String(Math.max(1, lines)),
|
||||||
|
"--json",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readScreen(surface: string, lines = 50): string {
|
||||||
|
const cli = requireOrca();
|
||||||
|
const output = execFileSync(cli, readScreenArgs(surface, lines), { encoding: "utf8" });
|
||||||
|
return parseReadTail(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||||
|
const cli = requireOrca();
|
||||||
|
const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
return parseReadTail(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSurface(surface: string): void {
|
||||||
|
const cli = requireOrca();
|
||||||
|
const args = ["terminal", "close", "--terminal", surface];
|
||||||
|
if (tabByHandle.has(surface)) args.push("--tab");
|
||||||
|
args.push("--json");
|
||||||
|
const output = execFileSync(cli, args, { encoding: "utf8" });
|
||||||
|
parseResponse("terminal close", output);
|
||||||
|
tabByHandle.delete(surface);
|
||||||
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Backend-neutral terminal surface facade.
|
* Backend-neutral terminal surface facade.
|
||||||
*
|
*
|
||||||
* Herdr is selected when Pi is running in a Herdr-managed pane. tmux remains
|
* Herdr is selected when Pi is running in a Herdr-managed pane, Orca when Pi
|
||||||
* the fallback when Pi is running inside tmux. Selection is automatic so a
|
* is running in an Orca-managed terminal. tmux remains the fallback when Pi is
|
||||||
* process uses one backend consistently for its whole lifetime.
|
* running inside tmux. Selection is automatic so a process uses one backend
|
||||||
|
* consistently for its whole lifetime.
|
||||||
*/
|
*/
|
||||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import * as herdr from "./herdr.ts";
|
import * as herdr from "./herdr.ts";
|
||||||
|
import * as orca from "./orca.ts";
|
||||||
import * as tmux from "./tmux.ts";
|
import * as tmux from "./tmux.ts";
|
||||||
|
|
||||||
export type MuxBackend = "herdr" | "tmux";
|
export type MuxBackend = "herdr" | "orca" | "tmux";
|
||||||
type Direction = "left" | "right" | "up" | "down";
|
type Direction = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
type SurfaceBackend = {
|
type SurfaceBackend = {
|
||||||
@@ -25,20 +27,27 @@ type SurfaceBackend = {
|
|||||||
|
|
||||||
export function selectMuxBackend(availability: {
|
export function selectMuxBackend(availability: {
|
||||||
herdr: boolean;
|
herdr: boolean;
|
||||||
|
orca: boolean;
|
||||||
tmux: boolean;
|
tmux: boolean;
|
||||||
}): MuxBackend | null {
|
}): MuxBackend | null {
|
||||||
if (availability.herdr) return "herdr";
|
if (availability.herdr) return "herdr";
|
||||||
|
if (availability.orca) return "orca";
|
||||||
if (availability.tmux) return "tmux";
|
if (availability.tmux) return "tmux";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMuxBackend(): MuxBackend | null {
|
export function getMuxBackend(): MuxBackend | null {
|
||||||
return selectMuxBackend({ herdr: herdr.isAvailable(), tmux: tmux.isAvailable() });
|
return selectMuxBackend({
|
||||||
|
herdr: herdr.isAvailable(),
|
||||||
|
orca: orca.isAvailable(),
|
||||||
|
tmux: tmux.isAvailable(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBackend(): SurfaceBackend {
|
function getBackend(): SurfaceBackend {
|
||||||
const backend = getMuxBackend();
|
const backend = getMuxBackend();
|
||||||
if (backend === "herdr") return herdr;
|
if (backend === "herdr") return herdr;
|
||||||
|
if (backend === "orca") return orca;
|
||||||
if (backend === "tmux") return tmux;
|
if (backend === "tmux") return tmux;
|
||||||
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
|
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
|
||||||
}
|
}
|
||||||
@@ -49,8 +58,9 @@ export function isMuxAvailable(): boolean {
|
|||||||
|
|
||||||
export function muxSetupHint(): string {
|
export function muxSetupHint(): string {
|
||||||
if (process.env.HERDR_ENV === "1") return herdr.setupHint();
|
if (process.env.HERDR_ENV === "1") return herdr.setupHint();
|
||||||
|
if (process.env.ORCA_TERMINAL_HANDLE) return orca.setupHint();
|
||||||
if (process.env.TMUX) return tmux.setupHint();
|
if (process.env.TMUX) return tmux.setupHint();
|
||||||
return "Start pi inside Herdr or tmux.";
|
return "Start pi inside Herdr, Orca, or tmux.";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSurface(name: string): string {
|
export function createSurface(name: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: test-fork
|
||||||
|
description: Integration test agent — fork-mode child that inherits the parent session
|
||||||
|
model: anthropic/claude-haiku-4-5
|
||||||
|
tools: read, bash
|
||||||
|
session-mode: fork
|
||||||
|
auto-exit: true
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a test agent. Complete the task given to you immediately. Be direct and concise.
|
||||||
|
When asked to write content to a file, do it right away using the bash tool.
|
||||||
|
Do not ask questions. Do not explain. Just execute the task.
|
||||||
@@ -73,13 +73,14 @@ const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.
|
|||||||
|
|
||||||
// ── Configuration ──
|
// ── Configuration ──
|
||||||
|
|
||||||
/** Model used for integration tests. Override with PI_TEST_MODEL env var. */
|
/**
|
||||||
const activePiModel =
|
* Model used for integration tests. Override with PI_TEST_MODEL env var.
|
||||||
process.env.PI_PROVIDER && process.env.PI_MODEL
|
*
|
||||||
? `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}`
|
* The default must be a provider that exists under `pi -ne`: startPi() launches
|
||||||
: undefined;
|
* with extensions disabled, so extension-registered providers (and therefore
|
||||||
export const TEST_MODEL =
|
* the parent session's active model) may not resolve in the child.
|
||||||
process.env.PI_TEST_MODEL ?? activePiModel ?? "anthropic/claude-haiku-4-5";
|
*/
|
||||||
|
export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "openai-codex/gpt-5.6-luna";
|
||||||
|
|
||||||
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
|
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
|
||||||
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
||||||
@@ -97,7 +98,42 @@ function parseHerdrJson(command: string, args: string[]): any {
|
|||||||
return JSON.parse(output);
|
return JSON.parse(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orcaCli(): string {
|
||||||
|
return process.env.ORCA_CLI_COMMAND?.trim() || "orca";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrcaJson(args: string[]): any {
|
||||||
|
const output = execFileSync(orcaCli(), args, { encoding: "utf8" });
|
||||||
|
const response = JSON.parse(output);
|
||||||
|
if (response.ok === false) {
|
||||||
|
throw new Error(`Orca ${args.slice(0, 2).join(" ")} failed: ${response.error?.message}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first search of an Orca visual-layout pane tree for the active terminal. */
|
||||||
|
function findActiveOrcaTerminal(node: any): string | null {
|
||||||
|
if (!node || typeof node !== "object") return null;
|
||||||
|
if (node.type === "terminal") return node.active ? node.handle ?? null : null;
|
||||||
|
for (const child of Object.values(node)) {
|
||||||
|
if (Array.isArray(child)) {
|
||||||
|
for (const item of child) {
|
||||||
|
const found = findActiveOrcaTerminal(item);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
} else if (child && typeof child === "object") {
|
||||||
|
const found = findActiveOrcaTerminal(child);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function focusSurface(surface: string): void {
|
export function focusSurface(surface: string): void {
|
||||||
|
if (getMuxBackend() === "orca") {
|
||||||
|
parseOrcaJson(["terminal", "switch", "--terminal", surface, "--json"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (getMuxBackend() === "herdr") {
|
if (getMuxBackend() === "herdr") {
|
||||||
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
|
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
|
||||||
const tabId = pane.result?.pane?.tab_id;
|
const tabId = pane.result?.pane?.tab_id;
|
||||||
@@ -110,6 +146,19 @@ export function focusSurface(surface: string): void {
|
|||||||
|
|
||||||
export function getFocusedSurface(): string | null {
|
export function getFocusedSurface(): string | null {
|
||||||
try {
|
try {
|
||||||
|
if (getMuxBackend() === "orca") {
|
||||||
|
const response = parseOrcaJson(["terminal", "list", "--include-visual-layouts", "--json"]);
|
||||||
|
const layouts = response.result?.visualLayouts ?? [];
|
||||||
|
for (const layout of layouts) {
|
||||||
|
const root = layout.root;
|
||||||
|
if (!root) continue;
|
||||||
|
const activeTab = root.tabs?.find((tab: any) => tab.tabId === root.activeTabId);
|
||||||
|
const handle = findActiveOrcaTerminal(activeTab?.panes);
|
||||||
|
if (handle) return handle;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (getMuxBackend() === "herdr") {
|
if (getMuxBackend() === "herdr") {
|
||||||
const workspace = process.env.HERDR_WORKSPACE_ID;
|
const workspace = process.env.HERDR_WORKSPACE_ID;
|
||||||
if (!workspace) return null;
|
if (!workspace) return null;
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Integration tests for the full subagent lifecycle.
|
* Integration tests for the full subagent lifecycle.
|
||||||
*
|
*
|
||||||
* These tests spawn REAL pi sessions with REAL LLM calls (haiku by default).
|
* These tests spawn REAL pi sessions with REAL LLM calls (gpt-5.6-luna by default).
|
||||||
* Each test creates a terminal surface, runs pi with a task that uses the subagent
|
* Each test creates a terminal surface, runs pi with a task that uses the subagent
|
||||||
* tool, and verifies the outcome via marker files and screen output.
|
* tool, and verifies the outcome via marker files and screen output.
|
||||||
*
|
*
|
||||||
* Costs: ~$0.01-0.05 per test run (haiku).
|
* Costs: ~$0.01-0.05 per test run.
|
||||||
* Duration: ~30-90s per test.
|
* Duration: ~30-90s per test.
|
||||||
*
|
*
|
||||||
* Run inside Herdr, or inside tmux:
|
* Run inside Herdr, inside an Orca terminal, or inside tmux:
|
||||||
* tmux new 'npm run test:integration'
|
* tmux new 'npm run test:integration'
|
||||||
*
|
*
|
||||||
* Configuration:
|
* Configuration:
|
||||||
* PI_TEST_MODEL — model for all pi sessions (default: anthropic/claude-haiku-4-5)
|
* PI_TEST_MODEL — model for all pi sessions (default: openai-codex/gpt-5.6-luna)
|
||||||
* PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000)
|
* PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000)
|
||||||
*/
|
*/
|
||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, after } from "node:test";
|
||||||
@@ -38,8 +38,8 @@ import {
|
|||||||
const backends = getAvailableBackends();
|
const backends = getAvailableBackends();
|
||||||
|
|
||||||
if (backends.length === 0) {
|
if (backends.length === 0) {
|
||||||
console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests");
|
console.log("⚠️ Herdr/Orca/tmux is not available — skipping subagent lifecycle integration tests");
|
||||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
@@ -253,9 +253,9 @@ for (const backend of backends) {
|
|||||||
const task = [
|
const task = [
|
||||||
`Call the subagent tool with these EXACT parameters:`,
|
`Call the subagent tool with these EXACT parameters:`,
|
||||||
` name: "Fork-${id}"`,
|
` name: "Fork-${id}"`,
|
||||||
` fork: true`,
|
` agent: "test-fork"`,
|
||||||
` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
|
` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
|
||||||
`Do not set the agent parameter. Just set name, fork, and task.`,
|
`Do not do anything else. Just call the subagent tool once.`,
|
||||||
`After you receive the result, say FORK_COMPLETE.`,
|
`After you receive the result, say FORK_COMPLETE.`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Integration tests for the selected terminal-surface backend.
|
* Integration tests for the selected terminal-surface backend.
|
||||||
*
|
*
|
||||||
* These tests exercise real Herdr or tmux operations: creating surfaces,
|
* These tests exercise real Herdr, Orca, or tmux operations: creating surfaces,
|
||||||
* sending commands, reading screen output, and closing panes.
|
* sending commands, reading screen output, and closing panes.
|
||||||
* No LLM calls — fast and free.
|
* No LLM calls — fast and free.
|
||||||
*
|
*
|
||||||
* Run inside Herdr, or inside tmux:
|
* Run inside Herdr, inside an Orca terminal, or inside tmux:
|
||||||
* tmux new 'npm run test:integration'
|
* tmux new 'npm run test:integration'
|
||||||
*/
|
*/
|
||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, after } from "node:test";
|
||||||
@@ -39,8 +39,8 @@ const backends = getAvailableBackends();
|
|||||||
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
|
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
|
||||||
|
|
||||||
if (backends.length === 0) {
|
if (backends.length === 0) {
|
||||||
console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests");
|
console.log("⚠️ Herdr/Orca/tmux is not available — skipping surface integration tests");
|
||||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
|
|||||||
+436
-31
@@ -36,6 +36,11 @@ import {
|
|||||||
shellEscape,
|
shellEscape,
|
||||||
} from "../pi-extension/subagents/surface.ts";
|
} from "../pi-extension/subagents/surface.ts";
|
||||||
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
||||||
|
import {
|
||||||
|
parseCreatedSplit,
|
||||||
|
parseCreatedTerminal,
|
||||||
|
parseReadTail,
|
||||||
|
} from "../pi-extension/subagents/orca.ts";
|
||||||
import {
|
import {
|
||||||
advanceStatusState,
|
advanceStatusState,
|
||||||
capStatusLines,
|
capStatusLines,
|
||||||
@@ -141,6 +146,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) {
|
function restoreEnvVar(name: string, value: string | undefined) {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
delete process.env[name];
|
delete process.env[name];
|
||||||
@@ -1860,33 +1896,11 @@ describe("commands", () => {
|
|||||||
|
|
||||||
describe("reload lifecycle", () => {
|
describe("reload lifecycle", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
const shared = testApi.sharedRuntime as {
|
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||||
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 ctx = { hasUI: false, ui: { setWidget() {} } };
|
const ctx = { hasUI: false, ui: { setWidget() {} } };
|
||||||
|
|
||||||
function resetSharedRuntime() {
|
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||||
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
|
after(() => resetSharedRuntimeForTest(shared));
|
||||||
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);
|
|
||||||
|
|
||||||
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
||||||
const oldRuntime = createMockExtensionApi();
|
const oldRuntime = createMockExtensionApi();
|
||||||
@@ -2604,6 +2618,7 @@ describe("subagent startup delay", () => {
|
|||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
|
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
|
||||||
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
|
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
|
||||||
|
assert.equal(testApi.defaultShellReadyDelayMs("orca"), 500);
|
||||||
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
|
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2623,6 +2638,71 @@ describe("subagent startup delay", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe("subagents widget rendering", () => {
|
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", () => {
|
it("keeps every rendered line within a very narrow width", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||||
@@ -2663,13 +2743,33 @@ describe("subagents widget rendering", () => {
|
|||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
lines.map((line: string) => visibleWidth(line)),
|
lines.map((line: string) => visibleWidth(line)),
|
||||||
[16, 16, 16, 16, 16],
|
[16, 16, 16, 16],
|
||||||
);
|
);
|
||||||
|
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||||
} finally {
|
} finally {
|
||||||
Date.now = originalNow;
|
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", () => {
|
it("truncates the right-hand status instead of overflowing when it alone is too wide", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||||
@@ -2707,6 +2807,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", () => {
|
describe("subagent display helpers", () => {
|
||||||
@@ -2785,11 +3155,13 @@ describe("subagent display helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("surface backend selection", () => {
|
describe("surface backend selection", () => {
|
||||||
it("prefers Herdr and otherwise falls back to tmux", () => {
|
it("prefers Herdr, then Orca, and otherwise falls back to tmux", () => {
|
||||||
assert.equal(selectMuxBackend({ herdr: true, tmux: true }), "herdr");
|
assert.equal(selectMuxBackend({ herdr: true, orca: true, tmux: true }), "herdr");
|
||||||
assert.equal(selectMuxBackend({ herdr: true, tmux: false }), "herdr");
|
assert.equal(selectMuxBackend({ herdr: true, orca: false, tmux: false }), "herdr");
|
||||||
assert.equal(selectMuxBackend({ herdr: false, tmux: true }), "tmux");
|
assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: true }), "orca");
|
||||||
assert.equal(selectMuxBackend({ herdr: false, tmux: false }), null);
|
assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: false }), "orca");
|
||||||
|
assert.equal(selectMuxBackend({ herdr: false, orca: false, tmux: true }), "tmux");
|
||||||
|
assert.equal(selectMuxBackend({ herdr: false, orca: false, tmux: false }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses Herdr tab and pane creation responses", () => {
|
it("parses Herdr tab and pane creation responses", () => {
|
||||||
@@ -2808,6 +3180,39 @@ describe("surface backend selection", () => {
|
|||||||
);
|
);
|
||||||
assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/);
|
assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses Orca terminal create, split, and read responses", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseCreatedTerminal(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
result: { terminal: { handle: "term_abc", tabId: "tab-1" } },
|
||||||
|
})),
|
||||||
|
{ handle: "term_abc", tabId: "tab-1" },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parseCreatedSplit(JSON.stringify({ ok: true, result: { split: { handle: "term_def" } } })),
|
||||||
|
"term_def",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parseReadTail(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
result: { terminal: { status: "running", tail: ["line one", "line two"] } },
|
||||||
|
})),
|
||||||
|
"line one\nline two",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => parseReadTail(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
result: { terminal: { status: "exited", tail: [] } },
|
||||||
|
})),
|
||||||
|
/not running \(status: exited\)/,
|
||||||
|
);
|
||||||
|
assert.throws(() => parseCreatedTerminal("{}"), /result\.terminal\.handle/);
|
||||||
|
assert.throws(
|
||||||
|
() => parseCreatedTerminal(JSON.stringify({ ok: false, error: { message: "nope" } })),
|
||||||
|
/terminal create failed: nope/,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("subagent completion wrapper", () => {
|
describe("subagent completion wrapper", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user