feat: package Pi extensions as Bun workspaces

This commit is contained in:
jb
2026-09-01 17:24:46 -04:00
parent 90f8090f61
commit 118b5bee06
214 changed files with 19566 additions and 5876 deletions
@@ -0,0 +1,6 @@
node_modules/
config.json
.DS_Store
.memory/
.pi/
.worktrees/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 HazAT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+227
View File
@@ -0,0 +1,227 @@
## Forked from [amosblomqvist/pi-interactive-subagents](https://github.com/amosblomqvist/pi-interactive-subagents)
# pi-interactive-subagents
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`), then Orca when Pi runs in an Orca-managed terminal (`ORCA_TERMINAL_HANDLE`), otherwise tmux is used when available.
## How it works
`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
⟳ 00:23 scout active · bash 7m
○ 00:45 scout-2 waiting 2m
```
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
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 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:
```bash
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, Orca 500, tmux 500
```
## Tools
| Tool | Description |
| --- | --- |
| `subagent` | Spawn a sub-agent in a dedicated terminal surface (async) |
| `subagent_message` | Message a sub-agent by name — steers it if running, resumes its session if finished |
| `subagents_list` | List available agent definitions |
| `ask_question` | *(sub-agent sessions only)* Ask the orchestrator a question and wait for the reply |
There is also a `/subagent <agent> <task>` command for spawning directly.
### Spawning
```typescript
subagent({ agent: "scout", task: "Analyze the auth module" });
subagent({ agent: "worker", name: "dark-mode", task: "Implement the dark mode toggle" });
```
| Parameter | Type | Default | Description |
| --------- | ---- | ------- | ----------- |
| `agent` | string | required | Which agent to spawn (must be known and permitted) |
| `task` | string | required | Task prompt |
| `name` | string | agent name | Display name for the pane and widget. Must be unique — duplicates are auto-suffixed (`scout`, `scout-2`, …) |
| `model` | string | agent's model | Override the model for this spawn |
| `cwd` | string | agent's `cwd` | Working directory (see [Role folders](#role-folders)) |
### Messaging
`subagent_message` is addressed **by name only**. Names are unique per session and persist after a sub-agent finishes, so the same name works either way:
```typescript
subagent_message({ name: "scout", message: "Also check the auth middleware" });
```
- **Running** — the message is typed into the live pane (newlines flattened) and picked up at the next turn boundary. The call returns immediately; the eventual completion still arrives as a steer message.
- **Finished** — the session is resumed with the message as the follow-up task, like a fresh spawn: fire-and-forget, always autonomous, result steered back later. The resumed run reclaims its original name.
Every spawn records name → session file in `artifacts/<sessionId>/subagent-registry.json`, so names stay addressable across pi restarts. A nested sub-agent that spawns children gets its own registry keyed by its own session id. Resume is refused with a clear error (listing known names) if the name isn't registered, the session file is gone, or the session predates sandboxed resume.
**Resume replays the original sandbox.** At spawn time the fully-resolved loadout — tool allowlist, backing extensions, model, thinking level, system prompt, spawn whitelist, cwd — is snapshotted to `<session>.loadout.json`. Resume rebuilds the exact same restricted process from that snapshot rather than relaunching unrestricted.
### ask_question
A sub-agent can ask its orchestrator a single freeform question when requirements are ambiguous or a decision materially affects the work. The session **stays open** (parked as `waiting`) instead of exiting; the parent is notified with the sub-agent's name, replies via `subagent_message({ name, message })`, and the reply arrives as the sub-agent's next turn. Parallel questions are supported — each waiting sub-agent has its own name.
If the reply arrives while the sub-agent is still mid-turn, it is absorbed into the current turn — either way the question is marked answered and the session exits normally when the work is done. If the parent never replies, the pane stays open until a human closes it. Only available inside sub-agent sessions.
## Bundled agents
| Agent | Model | Tools | Role |
| ----- | ----- | ----- | ---- |
| **scout** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `grep`, `find`, `ls` | Fast read-only codebase recon |
| **researcher** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `web_search`, `web_fetch`, `safe_bash` | Web research, synthesized into a sourced brief |
| **worker** | `openai-codex/gpt-5.6-luna` (`xhigh`) | `read`, `write`, `edit`, `bash`, `web_search`, `web_fetch` + spawning | General implementer; may spawn `scout` and `researcher` |
All three are autonomous (`auto-exit: true`) and carry their identity in the system prompt (`system-prompt: append`).
## Custom agents
Place a `.md` file in `.pi/agents/` (project) or `~/.pi/agent/agents/` (global). Discovery priority: **project > global > package-bundled** — a project-local file overrides a bundled agent with the same name.
```markdown
---
name: my-agent
description: Does something specific
model: openai-codex/gpt-5.6-luna
thinking: xhigh
tools: read, edit, write, safe_bash, web_search
session-mode: lineage-only
auto-exit: true
---
You are a specialized agent that does X...
```
### Frontmatter reference
| Field | Type | Description |
| ----- | ---- | ----------- |
| `name` | string | Agent name (used in `agent: "my-agent"`) |
| `description` | string | Shown in `subagents_list` |
| `model` | string | Default model |
| `thinking` | string | `minimal`, `low`, `medium`, or `high` |
| `tools` | string | Strict tool allowlist. Built-ins: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`. Extension-backed: `web_search`, `web_fetch`, `safe_bash`, `video_extract`, `youtube_search`, `google_image_search`. Only the extensions backing the listed tools are loaded into the child |
| `subagent_agents` | string | Comma-separated agent names this agent may spawn. **Presence of this field grants the spawning toolset** (`subagent`, `subagent_message`, `subagents_list`) and restricts spawn targets to the list. Omit it and the agent cannot spawn at all |
| `skills` | string | Comma-separated skill names to auto-load |
| `session-mode` | string | `standalone` (default), `lineage-only`, or `fork` — see below |
| `system-prompt` | string | `append` or `replace`: pass the body as the child's `--append-system-prompt` / `--system-prompt`. Omit and the body is prepended to the task prompt instead |
| `auto-exit` | boolean | Auto-shutdown when the agent finishes (see below) |
| `interactive` | boolean | Whether stall/recovery transitions wake the parent (see below) |
| `cwd` | string | Default working directory |
| `disable-model-invocation` | boolean | Hide from `subagents_list`; still spawnable by explicit name |
| `cli` | string | `claude` runs the agent via the Claude Code CLI instead of pi |
### session-mode
- `standalone` — fresh session, no lineage link to the caller (default)
- `lineage-only` — fresh session with `parentSession` linkage for discovery/fork UX, but no copied turns
- `fork` — child session seeded with the caller's conversation context
### auto-exit
With `auto-exit: true`, the session shuts down when the agent's turn ends — the agent just writes its final message and stops (there is no "done" tool). The last assistant message becomes the summary returned to the parent. Recommended for all autonomous agents.
Notes:
- **Manual input does not strand an auto-exit sub-agent.** If a human types into the pane, the session still closes once that turn completes normally — only an escape/abort leaves it open.
- **Auto-exit is suppressed while work is in flight:** the session parks as `waiting` instead of exiting when an `ask_question` is still unanswered, or when the agent's own child sub-agents are still running (a worker can stop after dispatching children and stays open until the last result returns).
### interactive
Controls whether `stalled`/`recovered` status transitions send a steer message to the parent session. Defaults to the inverse of `auto-exit`: autonomous agents get stall pings; user-driven agents stay quiet (the user is already working in that pane — the widget still updates). Set explicitly to override.
## Tool access control
Access is **whitelist-only**. Every sub-agent process is launched with `--no-extensions` (extension discovery disabled) and `--tools <allowlist>`; only the extensions backing the listed tools are loaded back in explicitly. There is no default toolset and no deny-list — an agent gets exactly what its frontmatter lists. The restriction survives resume via the loadout snapshot.
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. 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
`cwd` starts a sub-agent in a directory with its own config, so role-specific setups (CLAUDE.md, skills, extensions) apply:
```
project/
└── agents/
├── game-designer/ ← CLAUDE.md, .pi/…
└── sre/ ← CLAUDE.md, .pi/…
```
```typescript
subagent({ agent: "worker", cwd: "agents/sre", task: "Review the deployment pipeline" });
```
Set a per-agent default with `cwd:` in frontmatter.
## Status widget & configuration
The widget tracks each sub-agent from a runtime activity snapshot written by the child: `starting`, `active` (turn/provider/tool work), `waiting` (open for input or another stage), `stalled` (no valid snapshot for too long), or `running` (fallback). Sub-agent sessions also show their own tools widget — toggle it with `Ctrl+Alt+O`. Completion messages expand with `Ctrl+O`.
Status display is configured via `config.json` in the extension directory (copy `config.json.example`; it's gitignored):
```json
{
"status": { "enabled": true }
}
```
## Install
```bash
pi install npm:@_jb/pi-interactive-subagents
```
## Requirements
- [Pi](https://github.com/earendil-works/pi-mono)
- One supported terminal manager:
- [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)
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:
```bash
tmux new -A -s pi 'pi'
```
## Acknowledgements
Forked from [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents), which originated the subagent architecture, the multi-multiplexer surface layer, and the status widget; its supervision features were inspired by [RepoPrompt](https://repoprompt.com/).
## License
MIT
@@ -0,0 +1,51 @@
---
name: researcher
description: Web researcher — searches the web and synthesizes findings
tools: safe_bash
model: openai-codex/gpt-5.6-luna
thinking: xhigh
system-prompt: append
auto-exit: true
---
You are a research specialist. Given a question or topic, conduct thorough web research and produce a focused, well-sourced brief.
You operate in an isolated context with no knowledge of any prior conversation. All necessary context is in the task description.
Process:
1. Break the question into 2-4 searchable facets
2. Search with the web using varied angles
3. Read the answers. Identify what's well-covered, what has gaps.
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
Search strategy — always vary your angles:
- Direct answer query (the obvious one)
- Authoritative source query (official docs, specs, primary sources)
- Practical experience query (case studies, benchmarks, real-world usage)
- Recent developments query (only if the topic is time-sensitive)
Evaluation — what to keep vs drop:
- Official docs and primary sources outweigh blog posts and forum threads
- Recent sources outweigh stale ones
- Sources that directly address the question outweigh tangentially related ones
- Drop: SEO filler, outdated info, beginner tutorials (unless that's the audience)
If the first round of searches doesn't fully answer the question, search again with refined queries targeting the gaps.
Your FINAL assistant message is your entire deliverable — it must stand alone, using this format:
## Summary
2-3 sentence direct answer.
## Findings
Numbered findings with inline source citations:
1. **Finding** — explanation. [Source](url)
2. **Finding** — explanation. [Source](url)
## Sources
- Kept: Source Title (url) — why relevant
- Dropped: Source Title — why excluded
## Gaps
What couldn't be answered. Suggested next steps.
@@ -0,0 +1,40 @@
---
name: scout
description: Fast codebase recon — explores files, finds patterns, maps architecture
tools: read, grep, find, ls
model: openai-codex/gpt-5.6-luna
thinking: xhigh
system-prompt: append
auto-exit: true
---
You are a scout agent. Quickly investigate a codebase and return structured findings.
You operate in an isolated context with no knowledge of any prior conversation. All necessary context is in the task description. You are read-only: never build, test, or modify anything.
Thoroughness (infer from task, default medium):
- Quick: Targeted lookups, key files only
- Medium: Follow imports, read critical sections
- Thorough: Trace all dependencies, check tests/types
Strategy:
1. grep/find to locate relevant code
2. Read key sections (not entire files)
3. Identify types, interfaces, key functions
4. Note dependencies between files
Your FINAL assistant message is your entire deliverable — it must stand alone, using this format:
## Files Found
List with exact line ranges:
1. `path/to/file.ts` (lines 10-50) — Description
2. `path/to/other.ts` (lines 100-150) — Description
## Key Code
Critical types, interfaces, or functions with actual code snippets.
## Architecture
Brief explanation of how the pieces connect.
## Start Here
Which file to look at first and why.
@@ -0,0 +1,79 @@
---
name: worker
description: General-purpose worker — reads, writes, and edits code
tools: read, write, edit, bash
subagent_agents: scout, researcher
model: openai-codex/gpt-5.6-luna
thinking: xhigh
system-prompt: append
auto-exit: true
---
You are a worker agent. You operate in an isolated context — you have no knowledge of any prior conversation. All necessary context will be provided in the task description.
You run in your own pane and work autonomously to complete the assigned task. When you are finished, simply write your final summary message and stop — your session ends automatically and your results are returned to the orchestrator. Do not announce that you are finishing; just produce the answer. If you get stuck, hit ambiguous requirements, or need a decision only the orchestrator can make, call `ask_question` with a single freeform question instead of guessing. Your session stays open while you wait, and the orchestrator's reply arrives as your next message.
Guidelines:
- Read files before editing to understand existing code
- Make targeted edits, not wholesale rewrites
- Use `bash` for running commands (tests, builds, installs, etc.)
- If something fails, diagnose and fix it
- Your FINAL assistant message should summarize what you did and what changed
## Delegation — protecting your context window
Your context is finite. Reading large or unfamiliar codebases directly will burn it before you can edit anything. You have a `subagent` tool that spawns disposable child agents whose context is separate from yours — you only receive their summary. Use it.
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*.
- **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.
**Always select the agent with the `agent` field**, e.g. `subagent({ agent: "scout", name: "recon", task: "…" })`. The `name` field is only a cosmetic pane label — it does NOT pick the agent. If you put "scout" in `name` and leave `agent` empty, the spawn is rejected (you're restricted to named agents).
### When to dispatch a scout vs. read directly
Dispatch a scout when:
- The task brief names a feature/area but not specific files ("fix the auth flow", "add a field to user settings")
- You'd need to grep + read 5+ files just to orient
- You only need to know *where* something lives or *what shape* it has, not its full source
Read directly when:
- The brief gives you explicit file paths
- You already know the file you need to edit
- You need the exact bytes for an `edit` call (scouts return summaries, not verbatim source — re-read the 1–3 files you actually edit)
A good rhythm: **scout to find, read to edit.** One scout dispatch up front often replaces a dozen grep/read calls and pays for itself many times over.
### When to dispatch a researcher vs. web_fetch directly
Dispatch a researcher when:
- The question is open-ended ("what's the idiomatic way to X in library Y")
- You'd need to search + read 3+ pages to triangulate
- You want sources synthesized, not raw HTML in your context
Fetch directly when:
- You already have the exact URL (a known docs page, a GitHub issue)
- You need a single specific piece of information from one page
### Parallelism
If you need two independent investigations (e.g. "map the auth code" AND "look up the library's session API"), emit multiple `subagent` tool calls in the same turn — they run in parallel automatically. Don't serialize independent work. After spawning, the results arrive as steer messages — don't poll or fabricate them.
After dispatching subagents you can just say what you're waiting for and stop the turn — your session will **not** close while children are still running. It stays open until every child has reported back, then wakes you with each result. Don't spin in a loop trying to "check" on them.
### What a subagent doesn't replace
Subagents can't edit files for you. You still do the `edit`/`write` calls yourself, with the focused context the scouts gave you. Treat them as a context-protecting prefetch, not a substitute for thinking.
## Output format when done
## Changes Made
- `path/to/file.ts` — what changed and why
## Verification
How you verified the changes work (tests run, build succeeded, etc.)
## Notes
Any caveats, follow-up items, or decisions made.
@@ -0,0 +1,5 @@
{
"status": {
"enabled": true
}
}
@@ -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.
@@ -0,0 +1,61 @@
{
"name": "@_jb/pi-interactive-subagents",
"version": "3.7.2",
"description": "Interactive subagents for Pi — spawn, orchestrate, and manage sub-agent sessions in Orca, Herdr or tmux.",
"type": "module",
"license": "MIT",
"author": "JB",
"homepage": "https://git.jb.codes/jb/harness-tools/src/branch/main/extensions/interactive-subagents",
"repository": {
"type": "git",
"url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git",
"directory": "extensions/interactive-subagents"
},
"bugs": {
"url": "https://git.jb.codes/jb/harness-tools/issues"
},
"engines": {
"node": ">=22.19.0"
},
"files": [
"pi-extension/**/*",
"agents/**/*",
"config.json.example",
"README.md",
"LICENSE"
],
"keywords": [
"pi-package",
"pi-extension",
"subagents",
"herdr",
"tmux"
],
"pi": {
"extensions": [
"./pi-extension/subagents/index.ts"
]
},
"scripts": {
"test": "node --test test/test.ts test/system-prompt-mode.test.ts",
"test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "bun run typecheck && bun run test"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "catalog:pi-peer",
"@earendil-works/pi-tui": "catalog:pi-peer",
"typebox": "catalog:pi-peer"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "catalog:",
"@earendil-works/pi-tui": "catalog:",
"@types/node": "catalog:",
"typebox": "catalog:",
"typescript": "catalog:"
},
"publishConfig": {
"access": "public",
"registry": "https://git.jb.codes/api/packages/jb/npm/"
}
}
@@ -0,0 +1,513 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
export type SubagentActivityPhase = "starting" | "active" | "waiting" | "done";
export type SubagentActivityScope = "agent" | "turn" | "provider" | "streaming" | "tool";
export type SubagentActivityEvent =
| "session_start"
| "input"
| "before_agent_start"
| "agent_start"
| "agent_end"
| "turn_start"
| "turn_end"
| "before_provider_request"
| "after_provider_response"
| "message_update"
| "tool_execution_start"
| "tool_call"
| "tool_execution_update"
| "tool_result"
| "tool_execution_end"
| "ask_question"
| "session_shutdown";
export interface SubagentActivityState {
version: 1;
runningChildId: string;
createdAt: number;
updatedAt: number;
sequence: number;
latestEvent: SubagentActivityEvent;
phase: SubagentActivityPhase;
agentActive: boolean;
turnActive: boolean;
providerActive: boolean;
toolActive: boolean;
activeScope?: SubagentActivityScope;
activeSince?: number;
waitingSince?: number;
turnIndex?: number;
messageEventType?: string;
toolCallId?: string;
toolName?: string;
toolStartedAt?: number;
toolEndedAt?: number;
}
export type ActivityReadResult =
| { ok: true; activity: SubagentActivityState }
| { ok: false; reason: "missing" | "invalid" | "wrong-id"; error?: string };
export type SubagentShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
export interface SubagentActivityRecorder {
sessionStart(): void;
input(): void;
beforeAgentStart(): void;
agentStart(): void;
agentEndWaiting(): void;
agentEndDone(): void;
turnStart(turnIndex?: number): void;
turnEnd(turnIndex?: number): void;
beforeProviderRequest(): void;
afterProviderResponse(): void;
messageUpdate(messageEventType?: string): void;
toolExecutionStart(toolCallId?: string, toolName?: string): void;
toolCall(toolCallId?: string, toolName?: string): void;
toolExecutionUpdate(toolCallId?: string, toolName?: string): void;
toolResult(toolCallId?: string, toolName?: string): void;
toolExecutionEnd(toolCallId?: string, toolName?: string): void;
askQuestion(): void;
sessionShutdown(reason: SubagentShutdownReason): void;
}
const ACTIVITY_UPDATE_THROTTLE_MS = 500;
const MAX_WRITE_FAILURES = 3;
const KNOWN_PHASES = new Set<SubagentActivityPhase>(["starting", "active", "waiting", "done"]);
const KNOWN_SCOPES = new Set<SubagentActivityScope>(["agent", "turn", "provider", "streaming", "tool"]);
const KNOWN_EVENTS = new Set<SubagentActivityEvent>([
"session_start",
"input",
"before_agent_start",
"agent_start",
"agent_end",
"turn_start",
"turn_end",
"before_provider_request",
"after_provider_response",
"message_update",
"tool_execution_start",
"tool_call",
"tool_execution_update",
"tool_result",
"tool_execution_end",
"ask_question",
"session_shutdown",
]);
const MAX_ACTIVITY_STRING_LENGTH = 200;
export function getSubagentActivityFile(artifactDir: string, runningChildId: string): string {
return join(artifactDir, "subagent-activity", `${runningChildId}.json`);
}
function requireObject(value: unknown): Record<string, unknown> | null {
if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function validateFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
return Number.isFinite(object[fieldName]) ? null : `${fieldName} must be finite`;
}
function validateOptionalFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
const value = object[fieldName];
return value == null || Number.isFinite(value) ? null : `${fieldName} must be finite when present`;
}
function validateInteger(object: Record<string, unknown>, fieldName: string): string | null {
return Number.isInteger(object[fieldName]) ? null : `${fieldName} must be an integer`;
}
function validateOptionalInteger(object: Record<string, unknown>, fieldName: string): string | null {
const value = object[fieldName];
return value == null || Number.isInteger(value) ? null : `${fieldName} must be an integer when present`;
}
function validateBoolean(object: Record<string, unknown>, fieldName: string): string | null {
return typeof object[fieldName] === "boolean" ? null : `${fieldName} must be a boolean`;
}
function validateOptionalActivityString(object: Record<string, unknown>, fieldName: string): string | null {
const value = object[fieldName];
if (value == null) return null;
if (typeof value !== "string") return `${fieldName} must be a string when present`;
if (/\r|\n/.test(value)) return `${fieldName} must not contain newlines`;
return value.length <= MAX_ACTIVITY_STRING_LENGTH ? null : `${fieldName} is too long`;
}
function invalidActivity(error: string): ActivityReadResult {
return { ok: false, reason: "invalid", error };
}
function validateActivity(value: unknown, expectedRunningChildId: string): ActivityReadResult {
const object = requireObject(value);
if (!object) return invalidActivity("activity must be an object");
if (object.version !== 1) return invalidActivity("unsupported activity version");
if (typeof object.runningChildId !== "string") return invalidActivity("runningChildId must be a string");
if (object.runningChildId !== expectedRunningChildId) return { ok: false, reason: "wrong-id" };
if (typeof object.latestEvent !== "string" || !KNOWN_EVENTS.has(object.latestEvent as SubagentActivityEvent)) {
return invalidActivity("unknown latestEvent");
}
if (typeof object.phase !== "string" || !KNOWN_PHASES.has(object.phase as SubagentActivityPhase)) {
return invalidActivity("unknown activity phase");
}
if (
object.activeScope != null &&
(typeof object.activeScope !== "string" || !KNOWN_SCOPES.has(object.activeScope as SubagentActivityScope))
) {
return invalidActivity("unknown activeScope");
}
const validationError = [
validateFiniteNumber(object, "createdAt"),
validateFiniteNumber(object, "updatedAt"),
validateInteger(object, "sequence"),
validateBoolean(object, "agentActive"),
validateBoolean(object, "turnActive"),
validateBoolean(object, "providerActive"),
validateBoolean(object, "toolActive"),
validateOptionalFiniteNumber(object, "activeSince"),
validateOptionalFiniteNumber(object, "waitingSince"),
validateOptionalInteger(object, "turnIndex"),
validateOptionalFiniteNumber(object, "toolStartedAt"),
validateOptionalFiniteNumber(object, "toolEndedAt"),
validateOptionalActivityString(object, "messageEventType"),
validateOptionalActivityString(object, "toolCallId"),
validateOptionalActivityString(object, "toolName"),
].find((error) => error != null);
if (validationError) return invalidActivity(validationError);
return { ok: true, activity: object as unknown as SubagentActivityState };
}
export function readSubagentActivityFile(
activityFile: string,
expectedRunningChildId: string,
): ActivityReadResult {
if (!existsSync(activityFile)) return { ok: false, reason: "missing" };
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(activityFile, "utf8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { ok: false, reason: "invalid", error: message };
}
return validateActivity(parsed, expectedRunningChildId);
}
export function writeSubagentActivityFile(activityFile: string, activity: SubagentActivityState): void {
const dir = dirname(activityFile);
mkdirSync(dir, { recursive: true });
const tempFile = join(dir, `${activity.runningChildId}.json.${process.pid}.${activity.sequence}.tmp`);
try {
writeFileSync(tempFile, `${JSON.stringify(activity)}\n`, "utf8");
renameSync(tempFile, activityFile);
} catch (error) {
try {
unlinkSync(tempFile);
} catch (cleanupError) {
// Temp cleanup is best effort; preserve the original write/rename failure
void cleanupError;
}
throw error;
}
}
function createNoopRecorder(): SubagentActivityRecorder {
return {
sessionStart() {},
input() {},
beforeAgentStart() {},
agentStart() {},
agentEndWaiting() {},
agentEndDone() {},
turnStart() {},
turnEnd() {},
beforeProviderRequest() {},
afterProviderResponse() {},
messageUpdate() {},
toolExecutionStart() {},
toolCall() {},
toolExecutionUpdate() {},
toolResult() {},
toolExecutionEnd() {},
askQuestion() {},
sessionShutdown() {},
};
}
function clearActiveState(activity: SubagentActivityState): void {
activity.agentActive = false;
activity.turnActive = false;
activity.providerActive = false;
activity.toolActive = false;
delete activity.activeScope;
delete activity.activeSince;
}
function refreshActiveScope(activity: SubagentActivityState): void {
if (activity.toolActive) {
activity.phase = "active";
activity.activeScope = "tool";
return;
}
if (activity.providerActive) {
activity.phase = "active";
activity.activeScope = "provider";
return;
}
if (activity.turnActive) {
activity.phase = "active";
activity.activeScope = "turn";
return;
}
if (activity.agentActive) {
activity.phase = "active";
activity.activeScope = "agent";
return;
}
delete activity.activeScope;
delete activity.activeSince;
}
function markActive(
activity: SubagentActivityState,
scope: SubagentActivityScope,
now: number,
resetActiveSince = false,
): void {
activity.phase = "active";
activity.activeScope = scope;
if (activity.activeSince == null || resetActiveSince) activity.activeSince = now;
delete activity.waitingSince;
}
export function createSubagentActivityRecorder(params: {
runningChildId?: string;
activityFile?: string;
now?: () => number;
}): SubagentActivityRecorder {
const candidateRunningChildId = params.runningChildId?.trim();
const candidateActivityFile = params.activityFile?.trim();
if (!candidateRunningChildId || !candidateActivityFile) return createNoopRecorder();
const runningChildId: string = candidateRunningChildId;
const activityFile: string = candidateActivityFile;
const now = params.now ?? (() => Date.now());
const createdAt = now();
const activity: SubagentActivityState = {
version: 1,
runningChildId,
createdAt,
updatedAt: createdAt,
sequence: 0,
latestEvent: "session_start",
phase: "starting",
agentActive: false,
turnActive: false,
providerActive: false,
toolActive: false,
};
let disabled = false;
let failureCount = 0;
let lastFlushAt = 0;
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
function clearPendingFlush(): void {
if (!pendingFlush) return;
clearTimeout(pendingFlush);
pendingFlush = null;
}
function disable(): void {
disabled = true;
clearPendingFlush();
}
function flushNow(): void {
if (disabled) return;
try {
writeSubagentActivityFile(activityFile, activity);
lastFlushAt = now();
failureCount = 0;
} catch {
failureCount += 1;
if (failureCount >= MAX_WRITE_FAILURES) disable();
}
}
function scheduleFlush(): void {
if (disabled || pendingFlush) return;
const remainingMs = Math.max(0, ACTIVITY_UPDATE_THROTTLE_MS - (now() - lastFlushAt));
if (remainingMs === 0) {
flushNow();
return;
}
pendingFlush = setTimeout(() => {
pendingFlush = null;
flushNow();
}, remainingMs);
}
function record(
latestEvent: SubagentActivityEvent,
update: (current: SubagentActivityState, now: number) => void,
flush: "immediate" | "throttled",
): void {
if (disabled) return;
if (flush === "immediate") clearPendingFlush();
const observedAt = now();
activity.latestEvent = latestEvent;
activity.updatedAt = observedAt;
activity.sequence += 1;
update(activity, observedAt);
if (flush === "immediate") flushNow();
else scheduleFlush();
}
function markDone(latestEvent: SubagentActivityEvent): void {
record(latestEvent, (current) => {
current.phase = "done";
clearActiveState(current);
delete current.waitingSince;
}, "immediate");
disable();
}
return {
sessionStart() {
record("session_start", (current) => {
current.phase = "starting";
clearActiveState(current);
delete current.waitingSince;
}, "immediate");
},
input() {
record("input", () => {}, "immediate");
},
beforeAgentStart() {
record("before_agent_start", (current, observedAt) => {
current.agentActive = true;
markActive(current, "agent", observedAt);
}, "immediate");
},
agentStart() {
record("agent_start", (current, observedAt) => {
current.agentActive = true;
markActive(current, "agent", observedAt);
}, "immediate");
},
agentEndWaiting() {
record("agent_end", (current, observedAt) => {
clearActiveState(current);
current.phase = "waiting";
current.waitingSince = observedAt;
}, "immediate");
},
agentEndDone() {
markDone("agent_end");
},
turnStart(turnIndex) {
record("turn_start", (current, observedAt) => {
current.agentActive = true;
current.turnActive = true;
if (turnIndex != null) current.turnIndex = turnIndex;
markActive(current, current.toolActive || current.providerActive ? current.activeScope ?? "turn" : "turn", observedAt);
}, "immediate");
},
turnEnd(turnIndex) {
record("turn_end", (current) => {
current.turnActive = false;
current.providerActive = false;
current.toolActive = false;
if (turnIndex != null) current.turnIndex = turnIndex;
refreshActiveScope(current);
}, "immediate");
},
beforeProviderRequest() {
record("before_provider_request", (current, observedAt) => {
current.providerActive = true;
markActive(current, "provider", observedAt, true);
}, "immediate");
},
afterProviderResponse() {
record("after_provider_response", (current) => {
current.providerActive = false;
refreshActiveScope(current);
}, "immediate");
},
messageUpdate(messageEventType) {
record("message_update", (current, observedAt) => {
current.agentActive = true;
current.turnActive = true;
current.messageEventType = messageEventType;
if (!current.toolActive) markActive(current, "streaming", observedAt);
}, "throttled");
},
toolExecutionStart(toolCallId, toolName) {
record("tool_execution_start", (current, observedAt) => {
current.toolActive = true;
current.toolCallId = toolCallId;
current.toolName = toolName;
current.toolStartedAt = observedAt;
markActive(current, "tool", observedAt, true);
}, "immediate");
},
toolCall(toolCallId, toolName) {
record("tool_call", (current, observedAt) => {
current.toolActive = true;
current.toolCallId = toolCallId ?? current.toolCallId;
current.toolName = toolName ?? current.toolName;
markActive(current, "tool", observedAt);
}, "immediate");
},
toolExecutionUpdate(toolCallId, toolName) {
record("tool_execution_update", (current, observedAt) => {
current.toolActive = true;
current.toolCallId = toolCallId ?? current.toolCallId;
current.toolName = toolName ?? current.toolName;
markActive(current, "tool", observedAt);
}, "throttled");
},
toolResult(toolCallId, toolName) {
record("tool_result", (current) => {
current.toolCallId = toolCallId ?? current.toolCallId;
current.toolName = toolName ?? current.toolName;
refreshActiveScope(current);
}, "immediate");
},
toolExecutionEnd(toolCallId, toolName) {
record("tool_execution_end", (current, observedAt) => {
current.toolActive = false;
current.toolCallId = toolCallId ?? current.toolCallId;
current.toolName = toolName ?? current.toolName;
current.toolEndedAt = observedAt;
refreshActiveScope(current);
}, "immediate");
},
askQuestion() {
// The subagent paused to ask the orchestrator a question. Park it in the
// "waiting" phase (do NOT disable the recorder) so the status widget shows
// it as waiting and recording resumes when the answer arrives.
record("ask_question", (current, observedAt) => {
clearActiveState(current);
current.phase = "waiting";
current.waitingSince = observedAt;
}, "immediate");
},
sessionShutdown(reason) {
if (reason === "quit") markDone("session_shutdown");
else disable();
},
};
}
@@ -0,0 +1,178 @@
/** Herdr surface backend. Subagents run in unfocused tabs in the current workspace. */
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const commandAvailability = new Map<string, boolean>();
const tabByPane = new Map<string, string>();
interface HerdrTabCreated {
result?: {
root_pane?: { pane_id?: unknown };
tab?: { tab_id?: unknown };
};
}
interface HerdrPaneCreated {
result?: {
pane?: { pane_id?: unknown };
};
}
function hasCommand(command: string): boolean {
const cached = commandAvailability.get(command);
if (cached !== undefined) return cached;
let available = false;
try {
execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
available = true;
} catch {
available = false;
}
commandAvailability.set(command, available);
return available;
}
export function isAvailable(): boolean {
return (
process.env.HERDR_ENV === "1" &&
!!process.env.HERDR_WORKSPACE_ID &&
!!process.env.HERDR_PANE_ID &&
hasCommand("herdr")
);
}
export function setupHint(): string {
return "Start pi inside a Herdr-managed pane with the `herdr` CLI on PATH.";
}
function requireHerdr(): void {
if (!isAvailable()) {
throw new Error(`Herdr is required for this surface. ${setupHint()}`);
}
}
function parseJson<T>(command: string, output: string): T {
try {
return JSON.parse(output) as T;
} catch {
throw new Error(`Unexpected Herdr ${command} output: ${output.trim() || "(empty)"}`);
}
}
function requireId(value: unknown, command: string, field: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Herdr ${command} did not return ${field}`);
}
return value;
}
export function parseCreatedTab(output: string): { paneId: string; tabId: string } {
const response = parseJson<HerdrTabCreated>("tab create", output);
return {
paneId: requireId(response.result?.root_pane?.pane_id, "tab create", "result.root_pane.pane_id"),
tabId: requireId(response.result?.tab?.tab_id, "tab create", "result.tab.tab_id"),
};
}
export function parseCreatedPane(output: string): string {
const response = parseJson<HerdrPaneCreated>("pane split", output);
return requireId(response.result?.pane?.pane_id, "pane split", "result.pane.pane_id");
}
export function createSurface(name: string): string {
requireHerdr();
const workspace = process.env.HERDR_WORKSPACE_ID!;
const output = execFileSync(
"herdr",
[
"tab",
"create",
"--workspace",
workspace,
"--cwd",
process.cwd(),
"--label",
name,
"--no-focus",
],
{ encoding: "utf8" },
);
const { paneId, tabId } = parseCreatedTab(output);
tabByPane.set(paneId, tabId);
return paneId;
}
export function createSurfaceSplit(
name: string,
direction: "left" | "right" | "up" | "down",
fromSurface?: string,
): string {
requireHerdr();
if (direction === "left" || direction === "up") {
throw new Error(`Herdr pane splits only support right or down, not ${direction}`);
}
const source = fromSurface ?? process.env.HERDR_PANE_ID!;
const output = execFileSync(
"herdr",
[
"pane",
"split",
"--pane",
source,
"--direction",
direction,
"--cwd",
process.cwd(),
"--no-focus",
],
{ encoding: "utf8" },
);
const paneId = parseCreatedPane(output);
try {
execFileSync("herdr", ["pane", "rename", paneId, name], { encoding: "utf8" });
} catch {
// A cosmetic label must not break a successful split.
}
return paneId;
}
export function sendCommand(surface: string, command: string): void {
requireHerdr();
execFileSync("herdr", ["pane", "run", surface, command], { encoding: "utf8" });
}
export function readScreen(surface: string, lines = 50): string {
requireHerdr();
return execFileSync(
"herdr",
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
{ encoding: "utf8" },
);
}
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
requireHerdr();
const { stdout } = await execFileAsync(
"herdr",
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(Math.max(1, lines))],
{ encoding: "utf8" },
);
return stdout;
}
export function closeSurface(surface: string): void {
requireHerdr();
const tabId = tabByPane.get(surface);
if (tabId) {
execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" });
tabByPane.delete(surface);
return;
}
execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" });
}
File diff suppressed because it is too large Load Diff
@@ -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);
}
@@ -0,0 +1,5 @@
{
"name": "pi-auto-exit",
"version": "1.0.0",
"description": "Auto-exit plugin for pi-spawned Claude sessions"
}
@@ -0,0 +1,15 @@
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/on-stop.sh",
"timeout": 10
}
]
}
]
}
}
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Stop hook for pi-spawned Claude sessions.
# Writes a sentinel file when Claude completes autonomously (no user interjection).
set -euo pipefail
# Read JSON input from stdin
input=$(cat)
# Guard: if stop_hook_active is true, we're in a loop — bail out
stop_hook_active=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('stop_hook_active', False))" 2>/dev/null || echo "False")
if [ "$stop_hook_active" = "True" ]; then
exit 0
fi
# Guard: only act for pi-spawned sessions
if [ -z "${PI_CLAUDE_SENTINEL:-}" ]; then
exit 0
fi
# Get transcript path
transcript_path=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('transcript_path', ''))" 2>/dev/null || echo "")
if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
exit 0
fi
# Count real human messages in transcript (not tool results)
# Claude's transcript format:
# Human message: {"type": "user", "message": {"role": "user", "content": "..."}}
# Tool result: {"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", ...}]}}
# We only count entries where content is a string (real human input)
user_msg_count=$(python3 - "$transcript_path" <<'EOF'
import sys, json
transcript_path = sys.argv[1]
count = 0
with open(transcript_path, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if entry.get('type') != 'user':
continue
content = entry.get('message', {}).get('content', '')
# Real human messages have string content
# Tool results have array content with tool_result blocks
if isinstance(content, str):
count += 1
except (json.JSONDecodeError, AttributeError):
pass
print(count)
EOF
)
# Always write transcript path so the watcher can copy the session file
if [ -n "$transcript_path" ]; then
echo "$transcript_path" > "${PI_CLAUDE_SENTINEL}.transcript" 2>/dev/null || true
fi
# If exactly 1 user message (the initial prompt), this was autonomous — signal completion
if [ "$user_msg_count" -eq 1 ]; then
# Write last_assistant_message to sentinel so the watcher gets a clean result
echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('last_assistant_message', ''))" > "$PI_CLAUDE_SENTINEL" 2>/dev/null || touch "$PI_CLAUDE_SENTINEL"
fi
exit 0
@@ -0,0 +1,625 @@
import {
appendFileSync,
closeSync,
copyFileSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
readSync,
readdirSync,
renameSync,
statSync,
writeFileSync,
} from "node:fs";
import { randomBytes, randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
export interface SessionEntry {
type: string;
id: string;
parentId?: string;
[key: string]: unknown;
}
export interface MessageEntry extends SessionEntry {
type: "message";
message: {
role: "user" | "assistant" | "toolResult";
content: Array<{ type: string; text?: string; [key: string]: unknown }>;
};
}
export type SeededSubagentSessionMode = "lineage-only" | "fork";
function getForkContentLines(parentSessionFile: string): string[] {
const raw = readFileSync(parentSessionFile, "utf8");
const lines = raw.split("\n").filter((line) => line.trim());
let truncateAt = lines.length;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (entry.type === "message" && entry.message?.role === "user") {
truncateAt = i;
break;
}
} catch {
// ignore malformed lines
}
}
return lines.slice(0, truncateAt).filter((line) => {
try {
return JSON.parse(line).type !== "session";
} catch {
return true;
}
});
}
export function seedSubagentSessionFile(params: {
mode: SeededSubagentSessionMode;
parentSessionFile: string;
childSessionFile: string;
childCwd: string;
}): void {
const header = {
type: "session",
version: 3,
id: randomUUID(),
timestamp: new Date().toISOString(),
cwd: params.childCwd,
parentSession: params.parentSessionFile,
};
const contentLines =
params.mode === "fork" ? getForkContentLines(params.parentSessionFile) : [];
const lines = [JSON.stringify(header), ...contentLines];
mkdirSync(dirname(params.childSessionFile), { recursive: true });
writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8");
}
/**
* A snapshot of everything needed to reconstruct a subagent's sandbox when its
* session is later resumed via `subagent_message({ sessionId })`.
*
* Written next to the session file as `<sessionFile>.loadout.json` at spawn
* time. Resume replays this exact snapshot so the reincarnated process gets the
* same `--no-extensions` + `--tools` restriction, model, identity, spawn
* whitelist, cwd, and config dir it originally ran with — instead of falling
* back to pi's default (all global extensions + full toolset). Storing the
* resolved loadout (rather than re-deriving from the agent `.md` by name) keeps
* resume faithful even if the agent definition is later edited, moved, or
* deleted.
*/
export interface SubagentLoadout {
/** Agent profile name (for PI_SUBAGENT_AGENT); null for agentless spawns. */
agent: string | null;
/** The `--tools` allowlist string, or null when the spawn was unrestricted. */
toolAllowlist: string | null;
/** Model id (without thinking suffix), or null to use the session default. */
model: string | null;
/** Thinking level appended to the model as `model:level`, or null. */
thinking: string | null;
/** How the identity text was applied: append/replace, or null. */
systemPromptMode: "append" | "replace" | null;
/** The system-prompt/identity text, only when it lived in the system prompt. */
identity: string | null;
/** Agents this subagent was allowed to spawn (for PI_SUBAGENT_ALLOWED). */
spawnable: string[] | null;
/** Whether the agent auto-exits (informational; resume forces autonomous). */
autoExit: boolean;
/** Working directory the subagent ran in, or null. */
cwd: string | null;
/** PI_CODING_AGENT_DIR the subagent resolved config/extensions from, or null. */
agentDir: string | null;
}
/** Path of the loadout sidecar written next to a subagent session file. */
export function loadoutSidecarPath(sessionFile: string): string {
return `${sessionFile}.loadout.json`;
}
/** Persist a subagent's resolved sandbox loadout beside its session file. */
export function writeSubagentLoadout(sessionFile: string, loadout: SubagentLoadout): void {
try {
writeFileSync(loadoutSidecarPath(sessionFile), JSON.stringify(loadout), "utf8");
} catch {
// Best-effort: a missing snapshot only means resume will refuse, never that
// it launches unrestricted.
}
}
/** Read a subagent's loadout snapshot, or null if absent/unparseable. */
export function readSubagentLoadout(sessionFile: string): SubagentLoadout | null {
try {
const p = loadoutSidecarPath(sessionFile);
if (!existsSync(p)) return null;
const parsed = JSON.parse(readFileSync(p, "utf8"));
if (!parsed || typeof parsed !== "object") return null;
return parsed as SubagentLoadout;
} catch {
return null;
}
}
// ── Name registry ────────────────────────────────────────────────────────────
// Each spawner session (the top-level pi session, or a worker that spawns its
// own children) gets a registry mapping a subagent's display name to the
// session file it ran in. Names are unique per spawner session and persist on
// disk, so `subagent_message({ name })` can steer a running subagent or resume
// a finished one by the same handle — even across a pi restart. The registry
// lives in the spawner's own artifact dir, which is directly addressable from
// the spawner's session id (no sessions-tree scan, so resume stays fast).
export interface NameRegistryEntry {
/** Absolute path to the subagent's session .jsonl file. */
sessionFile: string;
/** Canonical session header id (kept for display/lineage). */
sessionId: string | null;
}
export type NameRegistry = Record<string, NameRegistryEntry>;
/** Path of the name registry for a given spawner session's artifact dir. */
export function nameRegistryPath(artifactDir: string): string {
return join(artifactDir, "subagent-registry.json");
}
/** Read a spawner session's name registry, or {} if absent/corrupt. */
export function readNameRegistry(artifactDir: string): NameRegistry {
try {
const p = nameRegistryPath(artifactDir);
if (!existsSync(p)) return {};
const parsed = JSON.parse(readFileSync(p, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as NameRegistry;
} catch {
return {};
}
}
/**
* Register (or overwrite) a name → session mapping for a spawner session.
* Writes atomically (temp file + rename) so a concurrent reader never sees a
* partial registry.
*/
export function registerName(
artifactDir: string,
name: string,
entry: NameRegistryEntry,
): void {
try {
mkdirSync(artifactDir, { recursive: true });
const registry = readNameRegistry(artifactDir);
registry[name] = entry;
const p = nameRegistryPath(artifactDir);
const tmp = `${p}.tmp-${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
writeFileSync(tmp, JSON.stringify(registry, null, 2), "utf8");
renameSync(tmp, p);
} catch {
// Best-effort: a failed registration only means resume-by-name won't find
// this subagent later; it never breaks the spawn itself.
}
}
/** Resolve a name to its registry entry within a spawner session, or null. */
export function resolveNameInRegistry(
artifactDir: string,
name: string,
): NameRegistryEntry | null {
const entry = readNameRegistry(artifactDir)[name];
return entry && typeof entry.sessionFile === "string" ? entry : null;
}
function readEntries(sessionFile: string): SessionEntry[] {
const raw = readFileSync(sessionFile, "utf8");
return raw
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as SessionEntry);
}
/**
* Return the id of the last entry in the session file (current branch point / leaf).
*/
export function getLeafId(sessionFile: string): string | null {
const entries = readEntries(sessionFile);
return entries.length > 0 ? entries[entries.length - 1].id : null;
}
/**
* Read the canonical session id from a session file's header.
*
* pi's `--session <id>` flag resolves against this header `id` (exact match,
* then prefix), NOT the filename — so this is the value to hand back to the
* orchestrator for follow-ups.
*/
/**
* Read only the first line of a file without loading the whole thing into
* memory. Session files grow to many MB, but the header we need is always the
* first JSON line, so reading a small prefix keeps header lookups cheap — this
* is what makes scanning a large session tree fast enough to avoid blocking the
* event loop. Returns the first line (sans trailing newline), or null.
*/
function readFirstLine(path: string, maxBytes = 65536): string | null {
let fd: number | undefined;
try {
fd = openSync(path, "r");
const buf = Buffer.allocUnsafe(maxBytes);
const bytes = readSync(fd, buf, 0, maxBytes, 0);
if (bytes <= 0) return null;
const nl = buf.indexOf(0x0a); // '\n'
const end = nl === -1 || nl >= bytes ? bytes : nl;
return buf.toString("utf8", 0, end);
} catch {
return null;
} finally {
if (fd !== undefined) {
try {
closeSync(fd);
} catch {
/* ignore */
}
}
}
}
export function getSessionId(sessionFile: string): string | null {
return readHeaderId(sessionFile);
}
function readHeaderId(sessionFile: string): string | null {
const firstLine = readFirstLine(sessionFile)?.trim();
if (!firstLine) return null;
try {
const entry = JSON.parse(firstLine) as { type?: string; id?: string };
return entry.type === "session" && typeof entry.id === "string" ? entry.id : null;
} catch {
return null;
}
}
/**
* Resolve a session id (or id prefix) to a session file path by scanning every
* `*.jsonl` under `sessionsRoot` and matching the header `id`. Mirrors pi's own
* resolution order: exact match first, then prefix match. Most recently
* modified file wins on ties. Returns null when nothing matches.
*/
/**
* In-process index of session id → session file, per sessions root.
*
* Resolving a session id naively walks every `.jsonl` under the sessions tree
* and reads each header. With a few thousand sessions that is thousands of
* synchronous open/read/stat syscalls — on the extension host's single thread
* that blocks the entire terminal UI for many seconds (measured ~67s on a
* 2010-file tree). To avoid that, we build the index once per root and cache
* it; subsequent lookups are O(1). The cache is validated cheaply (a directory
* listing plus statSync-only mtime checks) on every call, so new sessions are
* picked up without re-reading unchanged headers and without ever freezing the
* UI again.
*/
interface SessionIndex {
idToFile: Map<string, { path: string; mtime: number }>;
/** file path → mtime when indexed (staleness detection). */
files: Map<string, number>;
/** top-level dir signature used to detect newly added cwd dirs. */
topSig: string;
}
const sessionIndexCache = new Map<string, SessionIndex>();
function topLevelSignature(root: string): string {
const parts: string[] = [];
let entries: import("node:fs").Dirent[];
try {
entries = readdirSync(root, { withFileTypes: true });
} catch {
return "";
}
for (const e of entries) {
const full = join(root, e.name);
if (e.isDirectory()) {
let m = 0;
try {
m = statSync(full).mtimeMs;
} catch {
/* ignore */
}
parts.push(`d:${e.name}:${m}`);
} else if (e.isFile() && e.name.endsWith(".jsonl")) {
parts.push(`f:${e.name}`);
}
}
parts.sort();
return parts.join("|");
}
/** Recursively index new/changed .jsonl files under dir into idx. */
function indexDir(dir: string, idx: SessionIndex): void {
let entries: import("node:fs").Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
indexDir(full, idx);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
let mtime = 0;
try {
mtime = statSync(full).mtimeMs;
} catch {
continue;
}
const known = idx.files.get(full);
if (known !== undefined && known === mtime) continue; // unchanged
const id = readHeaderId(full); // only read headers for new/changed files
idx.files.set(full, mtime);
if (!id) continue;
const prev = idx.idToFile.get(id);
if (!prev || mtime >= prev.mtime) {
idx.idToFile.set(id, { path: full, mtime });
}
}
}
}
function getSessionIndex(sessionsRoot: string): SessionIndex {
let idx = sessionIndexCache.get(sessionsRoot);
const sig = topLevelSignature(sessionsRoot);
if (!idx) {
idx = { idToFile: new Map(), files: new Map(), topSig: sig };
sessionIndexCache.set(sessionsRoot, idx);
indexDir(sessionsRoot, idx); // first build: full scan, once per process
} else if (idx.topSig !== sig) {
idx.topSig = sig;
indexDir(sessionsRoot, idx); // a cwd dir was added/changed: incremental rescan
} else {
indexDir(sessionsRoot, idx); // cheap: stats files, reads only new/changed headers
}
return idx;
}
export function resolveSessionFileById(sessionId: string, sessionsRoot: string): string | null {
if (!sessionId || !existsSync(sessionsRoot)) return null;
const idx = getSessionIndex(sessionsRoot);
return lookupSessionIndex(idx, sessionId);
}
function lookupSessionIndex(
idx: { idToFile: Map<string, { path: string; mtime: number }> },
sessionId: string,
): string | null {
// Exact match first.
const exact = idx.idToFile.get(sessionId);
if (exact && existsSync(exact.path)) return exact.path;
// Prefix match: most recently modified wins (ids are unique in practice, so
// this is only a convenience for hand-typed short prefixes).
let best: { path: string; mtime: number } | null = null;
for (const [id, rec] of idx.idToFile) {
if (!id.startsWith(sessionId)) continue;
if (!existsSync(rec.path)) continue;
if (!best || rec.mtime > best.mtime) best = rec;
}
return best ? best.path : null;
}
/**
* Async variant used by the interactive resume path. Index building/refresh is
* synchronous I/O, which can take many seconds on a cold OS page cache with a
* few thousand sessions; running it synchronously would block the extension
* host's single thread and freeze the terminal UI. Deferring to a macrotask
* keeps the event loop responsive. The heavy work only happens on the first
* resolution per process (and incrementally thereafter); warm lookups are ~50ms.
*/
export async function resolveSessionFileByIdAsync(
sessionId: string,
sessionsRoot: string,
): Promise<string | null> {
if (!sessionId || !existsSync(sessionsRoot)) return null;
// Let the event loop breathe (and the UI repaint) before the sync scan.
await new Promise<void>((r) => setImmediate(r));
const idx = getSessionIndex(sessionsRoot);
return lookupSessionIndex(idx, sessionId);
}
/** Test hook: drop the cached session index so tests start clean. */
export function resetSessionIndexCache(): void {
sessionIndexCache.clear();
}
/**
* Return entries added after `afterLine` (1-indexed count of existing entries).
*/
/**
* Count the number of entry lines in a session file without parsing each line
* into an object. Used by the resume path, which only needs the *count* of
* pre-existing entries (so it can later slice out the new ones). Parsing every
* line of a large resumed transcript synchronously at resume time would block
* the UI; counting newlines is dramatically cheaper.
*/
export function countSessionEntryLines(sessionFile: string): number {
try {
const raw = readFileSync(sessionFile, "utf8");
// Count non-blank lines, mirroring getNewEntries' `.filter(line => line.trim())`
// but skipping the per-line JSON.parse that makes resume slow on big files.
let count = 0;
for (const line of raw.split("\n")) {
if (line.trim()) count++;
}
return count;
} catch {
return 0;
}
}
export function getNewEntries(sessionFile: string, afterLine: number): SessionEntry[] {
const raw = readFileSync(sessionFile, "utf8");
const lines = raw.split("\n").filter((line) => line.trim());
return lines.slice(afterLine).map((line) => JSON.parse(line) as SessionEntry);
}
/**
* Find the last assistant message text in a list of entries.
*
* Falls back to the `errorMessage` field when the last assistant message has
* `stopReason: "error"` and no usable text content — this happens when
* auto-retry exhausts on a provider overload / rate limit / server error, and
* without this fallback the parent would silently see a stale earlier message.
*/
export function findLastAssistantMessage(entries: SessionEntry[]): string | null {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
const msg = entry as MessageEntry;
if (msg.message.role !== "assistant") continue;
const texts = msg.message.content
.filter(
(block) =>
block.type === "text" && typeof block.text === "string" && block.text.trim() !== "",
)
.map((block) => block.text as string);
if (texts.length > 0 && texts.join("").trim()) return texts.join("\n");
const stopReason = (msg.message as { stopReason?: unknown }).stopReason;
const errorMessage = (msg.message as { errorMessage?: unknown }).errorMessage;
if (
stopReason === "error" &&
typeof errorMessage === "string" &&
errorMessage.trim() !== ""
) {
return `Subagent error: ${errorMessage.trim()}`;
}
}
return null;
}
/**
* Append a branch_summary entry to the session file.
* Returns the new entry's id.
*/
export function appendBranchSummary(
sessionFile: string,
branchPointId: string,
fromId: string | null,
summary: string,
): string {
const id = randomBytes(4).toString("hex");
const entry = {
type: "branch_summary",
id,
parentId: branchPointId,
timestamp: new Date().toISOString(),
fromId: fromId ?? branchPointId,
summary,
};
appendFileSync(sessionFile, JSON.stringify(entry) + "\n", "utf8");
return id;
}
/**
* Copy the session file to destDir for parallel worker isolation.
* Returns the path of the copy.
*/
export function copySessionFile(sessionFile: string, destDir: string): string {
const id = randomBytes(4).toString("hex");
const dest = join(destDir, `subagent-${id}.jsonl`);
copyFileSync(sessionFile, dest);
return dest;
}
/**
* Read new entries from sourceFile (after afterLine), append them to targetFile.
* Returns the appended entries.
*/
export function mergeNewEntries(
sourceFile: string,
targetFile: string,
afterLine: number,
): SessionEntry[] {
const entries = getNewEntries(sourceFile, afterLine);
for (const entry of entries) {
appendFileSync(targetFile, JSON.stringify(entry) + "\n", "utf8");
}
return entries;
}
export interface SessionStats {
model: string | null;
toolCount: number;
/** Cumulative token usage across all assistant turns. */
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
/** Current context size: the last assistant turn's totalTokens. */
contextTokens: number;
/** Cumulative cost in USD across all assistant turns. */
cost: number;
}
/**
* Parse a completed subagent session JSONL into aggregate stats for display:
* model, tool-call count, cumulative token usage + cost, and current context
* size. Cumulative usage fields are summed across every assistant turn; the
* context size is taken from the last assistant turn's `totalTokens` (the live
* context window occupancy). Returns null if the file can't be read.
*/
export function summarizeSessionStats(sessionFile: string): SessionStats | null {
let entries: SessionEntry[];
try {
entries = readEntries(sessionFile);
} catch {
return null;
}
const stats: SessionStats = {
model: null,
toolCount: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
contextTokens: 0,
cost: 0,
};
for (const entry of entries) {
if (entry.type === "model_change") {
const modelId = (entry as { modelId?: unknown }).modelId;
if (typeof modelId === "string" && modelId) stats.model = modelId;
continue;
}
if (entry.type !== "message") continue;
const msg = (entry as MessageEntry).message;
if (msg.role !== "assistant") continue;
const model = (msg as { model?: unknown }).model;
if (typeof model === "string" && model) stats.model = model;
for (const block of msg.content) {
if (block.type === "toolCall") stats.toolCount++;
}
const usage = (msg as { usage?: Record<string, unknown> }).usage;
if (usage && typeof usage === "object") {
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
stats.inputTokens += num(usage.input);
stats.outputTokens += num(usage.output);
stats.cacheReadTokens += num(usage.cacheRead);
stats.cacheWriteTokens += num(usage.cacheWrite);
const total = num(usage.totalTokens);
if (total > 0) stats.contextTokens = total;
const cost = usage.cost;
if (cost && typeof cost === "object") stats.cost += num((cost as Record<string, unknown>).total);
}
}
return stats;
}
@@ -0,0 +1,513 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export const SNAPSHOT_STALLED_AFTER_MS = 60_000;
export const DEFAULT_STATUS_LINE_LIMIT = 4;
export const MAX_STATUS_NAME_LENGTH = 72;
export const MAX_STATUS_LINE_LENGTH = 120;
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
const DEFAULT_STATUS_CONFIG_PATH = join(PACKAGE_ROOT, "config.json");
const STATUS_CONFIG_EXAMPLE_PATH = join(PACKAGE_ROOT, "config.json.example");
export type SubagentStatusKind = "starting" | "active" | "waiting" | "stalled" | "running";
export type SubagentStatusSource = "pi" | "claude";
export type SubagentStatusTransition = "stalled" | "recovered" | null;
export type StatusSnapshotState = "unseen" | "present" | "missing" | "invalid" | "wrong-id";
export type StatusActivityPhase = "starting" | "active" | "waiting" | "done";
export interface StatusConfig {
enabled: boolean;
lineLimit: number;
}
export type StatusObservation =
| {
snapshot: "present";
updatedAt: number;
sequence: number;
phase: StatusActivityPhase;
active?: boolean;
activeScope?: string;
activeSince?: number;
waitingSince?: number;
latestEvent?: string;
activityLabel?: string;
}
| {
snapshot: "missing" | "invalid" | "wrong-id";
snapshotError?: string;
};
export interface SubagentStatusState {
source: SubagentStatusSource;
startTimeMs: number;
firstObservationAtMs: number | null;
lastActivityAtMs: number | null;
lastActivitySequence: number | null;
localOverrideAtMs: number | null;
localOverrideSequence: number | null;
activeNow: boolean;
activeSinceMs: number | null;
activeScope: string | null;
waitingSinceMs: number | null;
phase: StatusActivityPhase | null;
latestEvent: string | null;
activityLabel: string | null;
snapshotState: StatusSnapshotState;
snapshotProblemSinceMs: number | null;
snapshotError: string | null;
currentKind: SubagentStatusKind;
}
export interface StatusSnapshot {
kind: SubagentStatusKind;
elapsedMs: number;
elapsedText: string;
activeSinceMs: number | null;
activeDurationText: string | null;
activeScope: string | null;
waitingSinceMs: number | null;
waitingDurationText: string | null;
latestEvent: string | null;
activityLabel: string | null;
snapshotState: StatusSnapshotState;
snapshotError: string | null;
snapshotProblemText: string | null;
statusLabel: string | null;
}
export interface CappedStatusLines {
visibleLines: string[];
overflow: number;
}
function invalidStatusConfig(source: string, message: string): never {
throw new Error(`Invalid subagent status config in ${source}: ${message}`);
}
function requireObject(value: unknown, source: string, fieldName: string): Record<string, unknown> {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
invalidStatusConfig(source, `${fieldName} must be an object`);
}
return value as Record<string, unknown>;
}
function requireBoolean(value: unknown, source: string, fieldName: string): boolean {
if (typeof value !== "boolean") {
invalidStatusConfig(source, `${fieldName} must be a boolean`);
}
return value;
}
function rejectUnsupportedKeys(
value: Record<string, unknown>,
allowedKeys: string[],
source: string,
fieldName: string,
): void {
const unsupportedKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key));
if (unsupportedKeys.length > 0) {
invalidStatusConfig(source, `${fieldName} has unsupported key(s): ${unsupportedKeys.join(", ")}`);
}
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
if (maxLength <= 1) return text.slice(0, maxLength);
return `${text.slice(0, maxLength - 1)}…`;
}
export function normalizeStatusName(name: string): string {
const collapsed = name.replace(/\s+/g, " ").trim() || "subagent";
return truncateText(collapsed, MAX_STATUS_NAME_LENGTH);
}
function boundStatusLine(line: string): string {
return truncateText(line.replace(/\s+/g, " ").trim(), MAX_STATUS_LINE_LENGTH);
}
function snapshotProblemLabel(snapshotState: StatusSnapshotState): string | null {
if (snapshotState === "wrong-id") return "wrong activity id";
return null;
}
function activityLabel(snapshot: Pick<StatusSnapshot, "activityLabel" | "activeScope">): string | null {
return snapshot.activityLabel ?? snapshot.activeScope;
}
export function parseStatusConfig(rawConfig: unknown, source = "config.json"): StatusConfig {
const config = requireObject(rawConfig, source, "root");
const status = requireObject(config.status, source, "status");
rejectUnsupportedKeys(status, ["enabled"], source, "status");
const enabled = requireBoolean(status.enabled, source, "status.enabled");
return {
enabled,
lineLimit: DEFAULT_STATUS_LINE_LIMIT,
};
}
function readStatusConfigFile(configPath: string, examplePath: string): { sourcePath: string; rawConfig: string } {
try {
return { sourcePath: configPath, rawConfig: readFileSync(configPath, "utf8") };
} catch (error) {
const errno = error as NodeJS.ErrnoException;
if (errno.code !== "ENOENT") throw error;
}
try {
return { sourcePath: examplePath, rawConfig: readFileSync(examplePath, "utf8") };
} catch (error) {
const errno = error as NodeJS.ErrnoException;
if (errno.code === "ENOENT") {
throw new Error(
`Missing subagent status config. Expected ${configPath} or ${examplePath}.`,
);
}
throw error;
}
}
export function loadStatusConfig(
configPath = DEFAULT_STATUS_CONFIG_PATH,
examplePath = STATUS_CONFIG_EXAMPLE_PATH,
): StatusConfig {
const { sourcePath, rawConfig } = readStatusConfigFile(configPath, examplePath);
let parsed: unknown;
try {
parsed = JSON.parse(rawConfig) as unknown;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid JSON in subagent config ${sourcePath}: ${detail}`);
}
return parseStatusConfig(parsed, sourcePath);
}
export function formatElapsedDuration(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
export function createStatusState(params: {
source: SubagentStatusSource;
startTimeMs: number;
}): SubagentStatusState {
const initialKind = params.source === "claude" ? "running" : "starting";
return {
source: params.source,
startTimeMs: params.startTimeMs,
firstObservationAtMs: null,
lastActivityAtMs: null,
lastActivitySequence: null,
localOverrideAtMs: null,
localOverrideSequence: null,
activeNow: false,
activeSinceMs: null,
activeScope: null,
waitingSinceMs: null,
phase: null,
latestEvent: null,
activityLabel: null,
snapshotState: params.source === "claude" ? "unseen" : "unseen",
snapshotProblemSinceMs: null,
snapshotError: null,
currentKind: initialKind,
};
}
export function observeStatus(
state: SubagentStatusState,
observation: StatusObservation,
now: number,
): SubagentStatusState {
if (state.source === "claude") return state;
if (observation.snapshot !== "present") {
return {
...state,
firstObservationAtMs: state.firstObservationAtMs ?? now,
snapshotState: observation.snapshot,
snapshotProblemSinceMs: state.snapshotProblemSinceMs ?? now,
snapshotError: observation.snapshotError ?? null,
};
}
const updatedAt = observation.updatedAt;
const sequence = observation.sequence;
const lastActivityAtMs = state.lastActivityAtMs;
const lastActivitySequence = state.lastActivitySequence;
const olderThanLastActivity = lastActivityAtMs != null && (
updatedAt < lastActivityAtMs ||
(updatedAt === lastActivityAtMs && lastActivitySequence != null && sequence < lastActivitySequence)
);
if (olderThanLastActivity) return state;
const blockedByLocalOverride = state.localOverrideAtMs != null && (
updatedAt < state.localOverrideAtMs ||
(updatedAt === state.localOverrideAtMs && state.localOverrideSequence != null && sequence <= state.localOverrideSequence)
);
if (blockedByLocalOverride) return state;
const phase = observation.phase;
const activeNow = phase === "active" || observation.active === true;
const activeSinceMs = activeNow
? observation.activeSince ?? state.activeSinceMs ?? updatedAt
: null;
const waitingSinceMs = phase === "waiting"
? observation.waitingSince ?? state.waitingSinceMs ?? updatedAt
: null;
return {
...state,
firstObservationAtMs: state.firstObservationAtMs ?? now,
lastActivityAtMs: updatedAt,
lastActivitySequence: sequence,
activeNow,
activeSinceMs,
activeScope: activeNow ? observation.activeScope ?? null : null,
waitingSinceMs,
phase,
latestEvent: observation.latestEvent ?? null,
activityLabel: observation.activityLabel ?? null,
snapshotState: "present",
snapshotProblemSinceMs: null,
snapshotError: null,
localOverrideAtMs: null,
localOverrideSequence: null,
};
}
export function forceStatusAfterInterrupt(state: SubagentStatusState, now: number): SubagentStatusState {
if (state.source === "claude") return state;
return {
...state,
firstObservationAtMs: state.firstObservationAtMs ?? now,
lastActivityAtMs: now,
localOverrideAtMs: now,
localOverrideSequence: state.lastActivitySequence,
activeNow: false,
activeSinceMs: null,
activeScope: null,
waitingSinceMs: now,
phase: "waiting",
latestEvent: "interrupt_requested",
activityLabel: "interrupted",
snapshotState: "present",
snapshotProblemSinceMs: null,
snapshotError: null,
currentKind: "waiting",
};
}
function classifyProblemState(state: SubagentStatusState, now: number): Pick<StatusSnapshot, "kind" | "statusLabel"> {
const problemLabel = snapshotProblemLabel(state.snapshotState);
const hasValidSnapshot = state.lastActivityAtMs != null;
if (!hasValidSnapshot) {
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
const elapsedMs = Math.max(0, now - referenceMs);
return elapsedMs >= SNAPSHOT_STALLED_AFTER_MS
? { kind: "stalled", statusLabel: problemLabel }
: { kind: "starting", statusLabel: null };
}
const problemSinceMs = state.snapshotProblemSinceMs ?? now;
const problemMs = Math.max(0, now - problemSinceMs);
if (problemMs >= SNAPSHOT_STALLED_AFTER_MS) return { kind: "stalled", statusLabel: problemLabel };
const lastHealthyKind = state.activeNow
? "active"
: state.waitingSinceMs != null || state.phase === "done"
? "waiting"
: state.currentKind === "stalled"
? "starting"
: state.currentKind;
return { kind: lastHealthyKind, statusLabel: problemLabel };
}
export function classifyStatus(state: SubagentStatusState, now: number): StatusSnapshot {
const elapsedMs = Math.max(0, now - state.startTimeMs);
const elapsedText = formatElapsedDuration(elapsedMs);
if (state.source === "claude") {
return {
kind: "running",
elapsedMs,
elapsedText,
activeSinceMs: null,
activeDurationText: null,
activeScope: null,
waitingSinceMs: null,
waitingDurationText: null,
latestEvent: null,
activityLabel: null,
snapshotState: state.snapshotState,
snapshotError: null,
snapshotProblemText: null,
statusLabel: null,
};
}
let kind: SubagentStatusKind;
let statusLabel: string | null = null;
if (state.snapshotState === "present") {
if (state.phase === "active" || state.activeNow) {
kind = "active";
} else if (state.phase === "waiting") {
kind = "waiting";
} else if (state.phase === "done") {
kind = "waiting";
statusLabel = "done";
} else {
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
const elapsedSinceObservationMs = Math.max(0, now - referenceMs);
kind = elapsedSinceObservationMs >= SNAPSHOT_STALLED_AFTER_MS ? "stalled" : "starting";
statusLabel = null;
}
} else {
const classified = classifyProblemState(state, now);
kind = classified.kind;
statusLabel = classified.statusLabel;
}
const activeDurationText = state.activeSinceMs == null
? null
: formatElapsedDuration(now - state.activeSinceMs);
const waitingDurationText = state.waitingSinceMs == null
? null
: formatElapsedDuration(now - state.waitingSinceMs);
const snapshotProblemText = state.snapshotProblemSinceMs == null
? null
: formatElapsedDuration(now - state.snapshotProblemSinceMs);
return {
kind,
elapsedMs,
elapsedText,
activeSinceMs: state.activeSinceMs,
activeDurationText,
activeScope: state.activeScope,
waitingSinceMs: state.waitingSinceMs,
waitingDurationText,
latestEvent: state.latestEvent,
activityLabel: state.activityLabel,
snapshotState: state.snapshotState,
snapshotError: state.snapshotError,
snapshotProblemText,
statusLabel,
};
}
export function advanceStatusState(
state: SubagentStatusState,
now: number,
): {
nextState: SubagentStatusState;
snapshot: StatusSnapshot;
transition: SubagentStatusTransition;
} {
const snapshot = classifyStatus(state, now);
const transition =
state.currentKind !== "stalled" && snapshot.kind === "stalled"
? "stalled"
: state.currentKind === "stalled" && (snapshot.kind === "active" || snapshot.kind === "waiting")
? "recovered"
: null;
return {
snapshot,
transition,
nextState: {
...state,
currentKind: snapshot.kind,
},
};
}
function formatActiveDetail(snapshot: StatusSnapshot): string {
const label = activityLabel(snapshot);
if (!label) return "active";
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
return `active (${label}${duration})`;
}
function formatWaitingDetail(snapshot: StatusSnapshot): string {
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
return `waiting${duration}`;
}
function formatStalledDetail(snapshot: StatusSnapshot): string {
const detail = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
return `stalled${duration}${detail}`;
}
export function formatStatusLine(name: string, snapshot: StatusSnapshot): string {
const boundedName = normalizeStatusName(name);
if (snapshot.kind === "starting") {
const label = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, starting${label}.`);
}
if (snapshot.kind === "running") {
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}.`);
}
if (snapshot.kind === "active") {
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatActiveDetail(snapshot)}.`);
}
if (snapshot.kind === "waiting") {
const problem = snapshot.statusLabel && snapshot.statusLabel !== "done"
? ` (${snapshot.statusLabel})`
: snapshot.statusLabel === "done"
? " (done)"
: "";
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatWaitingDetail(snapshot)}${problem}.`);
}
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatStalledDetail(snapshot)}.`);
}
export function formatTransitionLine(
name: string,
snapshot: StatusSnapshot,
transition: Exclude<SubagentStatusTransition, null>,
): string {
const boundedName = normalizeStatusName(name);
if (transition === "recovered") {
const detail = snapshot.kind === "waiting" ? formatWaitingDetail(snapshot) : formatActiveDetail(snapshot);
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, recovered; ${detail}.`);
}
return formatStatusLine(boundedName, snapshot);
}
export function capStatusLines(lines: string[], lineLimit: number): CappedStatusLines {
const visibleLines = lines.slice(0, lineLimit);
return {
visibleLines,
overflow: Math.max(0, lines.length - visibleLines.length),
};
}
export function formatStatusAggregate(lines: string[], lineLimit: number): string {
const { visibleLines, overflow } = capStatusLines(lines, lineLimit);
const bulletLines = visibleLines.map((line) => `• ${line}`);
if (overflow > 0) bulletLines.push(`• +${overflow} more running.`);
return `Subagent status:\n${bulletLines.join("\n")}`;
}
@@ -0,0 +1,399 @@
/**
* Extension loaded into sub-agents.
* - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+Alt+O)
* - Provides an `ask_question` tool for asking the parent orchestrator a question
*
* Subagents do NOT self-terminate via a tool. Auto-exit agents shut down
* automatically when their agent loop ends (see the `agent_end` handler);
* interactive agents end when the human exits the pane.
*
* `ask_question` keeps the session OPEN: it writes a `${sessionFile}.ask`
* signal the parent's watcher picks up, parks the session in a "waiting" state
* (auto-exit is suppressed for that turn via `awaitingAnswer`), and the parent
* replies with subagent_message — which lands as the subagent's next turn.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Box, Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { writeFileSync } from "node:fs";
import { createSubagentActivityRecorder } from "./activity.ts";
export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
return agentStarted;
}
/**
* Number of child subagents this session itself still has in flight.
*
* When this extension is loaded inside a subagent that can spawn its own
* children (e.g. a worker delegating to scout/researcher), `index.ts` runs in
* the same process and publishes a live count through a shared process-global
* symbol. A subagent that spawns children and then writes a "waiting for
* results" message would otherwise auto-exit the instant that turn ends —
* killing the session before its children report back. Reading this count lets
* `agent_end` keep the session open until every child has finished and its
* result has been delivered.
*
* Returns 0 when the spawning tools aren't loaded (scout/researcher, or a
* standalone session), so those agents auto-exit exactly as before.
*/
export function runningChildrenCount(): number {
const fn = (globalThis as any)[Symbol.for("pi-subagents/running-children-count")];
if (typeof fn !== "function") return 0;
try {
const n = fn();
return typeof n === "number" && n > 0 ? n : 0;
} catch {
return 0;
}
}
export function shouldAutoExitOnAgentEnd(
_userTookOver: boolean,
messages: any[] | undefined,
): boolean {
// Manual input should not strand an auto-exit subagent. If the latest agent
// turn completed normally, close the session. Escape/abort still leaves it
// open for inspection or another prompt.
//
// stopReason: "error" (e.g. exhausted retries on a provider overload) also
// returns true — we want to shut down so the parent is woken up — but we
// pair this with findLatestAssistantError() so the parent learns it was an
// error, not a clean completion.
if (messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg?.role === "assistant") {
return msg.stopReason !== "aborted";
}
}
}
return true;
}
export interface SubagentErrorInfo {
errorMessage: string;
stopReason: "error";
}
/**
* If the last assistant message in the turn ended with `stopReason: "error"`
* (typically auto-retry exhausted on an overload / rate limit / server error),
* return its error info so the parent orchestrator can surface a clear
* failure instead of silently treating the run as completed.
*
* Returns `null` when the latest assistant turn completed normally or was
* aborted by the user (handled separately by shouldAutoExitOnAgentEnd).
*/
export function findLatestAssistantError(
messages: any[] | undefined,
): SubagentErrorInfo | null {
if (!messages) return null;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg?.role !== "assistant") continue;
if (msg.stopReason !== "error") return null;
const raw = typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : "";
return {
errorMessage: raw || "Subagent agent loop ended with stopReason=error (no errorMessage field).",
stopReason: "error",
};
}
return null;
}
export function parseDeniedTools(rawValue: string | undefined): string[] {
return (rawValue ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
export default function (pi: ExtensionAPI) {
let toolNames: string[] = [];
let denied: string[] = [];
let expanded = false;
// Read subagent identity from env vars (set by parent orchestrator)
const subagentName = process.env.PI_SUBAGENT_NAME ?? "";
const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? "";
const deniedToolsValue = process.env.PI_DENY_TOOLS;
const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1";
const recorder = createSubagentActivityRecorder({
runningChildId: process.env.PI_SUBAGENT_ID,
activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE,
});
function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
ctx.ui.setWidget(
"subagent-tools",
(_tui: any, theme: any) => {
const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text));
const label = subagentAgent || subagentName;
const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : "";
if (expanded) {
// Expanded: full tool list + denied
const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
const hint = theme.fg("muted", " (Ctrl+Alt+O to collapse)");
const toolList = toolNames
.map((name: string) => theme.fg("dim", name))
.join(theme.fg("muted", ", "));
let deniedLine = "";
if (denied.length > 0) {
const deniedList = denied
.map((name: string) => theme.fg("error", name))
.join(theme.fg("muted", ", "));
deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList;
}
const content = new Text(
`${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`,
0,
0,
);
box.addChild(content);
} else {
// Collapsed: one-line summary
const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`);
const deniedInfo =
denied.length > 0
? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
: "";
const hint = theme.fg("muted", " (Ctrl+Alt+O to expand)");
const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
box.addChild(content);
}
return box;
},
{ placement: "aboveEditor" },
);
}
let userTookOver = false;
let agentStarted = false;
// Set when ask_question is called; suppresses auto-exit so the session stays
// open while it waits for the orchestrator's reply. Cleared when the reply
// lands — on `input` (covers a reply steered into the current run) and on
// `agent_start` (covers a reply that starts a fresh turn after parking).
let awaitingAnswer = false;
// Show widget + status bar on session start
pi.on("session_start", (_event, ctx) => {
recorder.sessionStart();
const tools = pi.getAllTools();
toolNames = tools.map((t) => t.name).sort();
denied = parseDeniedTools(deniedToolsValue);
renderWidget(ctx, null);
});
pi.on("input", () => {
recorder.input();
// A submitted message is the orchestrator's (or a human's) reply — the
// pending ask_question has been answered, however it was delivered. Clear
// here, not only on agent_start, because a reply steered in *mid-run* is
// absorbed into the current run (pi's `steer` behavior injects it before
// the next LLM call): no new agent_start fires, so without this the flag
// would stay set and agent_end would park the session as `waiting` even
// though the answer already arrived and was consumed. (The `input` event
// fires for mid-run steers because prompt() emits it before queueing.)
awaitingAnswer = false;
// Ignore the initial task message that starts an autonomous subagent.
// Only inputs after the first agent run has started count as user takeover.
if (!shouldMarkUserTookOver(agentStarted)) return;
userTookOver = true;
});
pi.on("before_agent_start", () => {
recorder.beforeAgentStart();
});
pi.on("agent_start", () => {
agentStarted = true;
// A new turn is starting — any pending ask_question has now been answered
// (or superseded), so let auto-exit resume normally when this turn ends.
awaitingAnswer = false;
recorder.agentStart();
});
pi.on("agent_end", (event, ctx) => {
const messages = (event as any).messages as any[] | undefined;
// Never shut down while this session still has work in flight:
// - awaitingAnswer: an ask_question is pending the orchestrator's reply.
// - runningChildrenCount(): this subagent spawned its own children and is
// waiting for their results (delivered as steered turns). Exiting now
// would strand those children and drop their results.
// In both cases the session parks as `waiting` and resumes when the next
// turn lands.
const hasPendingChildren = runningChildrenCount() > 0;
const shouldExit =
!awaitingAnswer &&
!hasPendingChildren &&
autoExit &&
shouldAutoExitOnAgentEnd(userTookOver, messages);
if (shouldExit) {
// Surface stopReason: "error" turns (auto-retry exhausted, provider
// overload, etc.) to the parent via the .exit sidecar so the watcher
// can report a clear failure with the underlying error message.
// Without this the parent would only see exit code 0 and a stale
// assistant message, mistaking the crash for a successful completion.
const errorInfo = findLatestAssistantError(messages);
const sessionFile = process.env.PI_SUBAGENT_SESSION;
if (errorInfo && sessionFile) {
try {
writeFileSync(
`${sessionFile}.exit`,
JSON.stringify({
type: "error",
errorMessage: errorInfo.errorMessage,
stopReason: errorInfo.stopReason,
}),
);
} catch {
// Best effort — even without the sidecar, watcher's session-file
// fallback can still recover the errorMessage.
}
}
recorder.agentEndDone();
ctx.shutdown();
return;
}
recorder.agentEndWaiting();
if (autoExit) {
// Reset any recorded manual input marker. Auto-exit is decided by whether
// the latest agent turn completed normally, not by who initiated it.
userTookOver = false;
}
});
pi.on("turn_start", (event) => {
recorder.turnStart((event as any).turnIndex);
});
pi.on("turn_end", (event) => {
recorder.turnEnd((event as any).turnIndex);
});
pi.on("before_provider_request", () => {
recorder.beforeProviderRequest();
});
pi.on("after_provider_response", () => {
recorder.afterProviderResponse();
});
pi.on("message_update", (event) => {
recorder.messageUpdate((event as any).assistantMessageEvent?.type);
});
pi.on("tool_execution_start", (event) => {
recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName);
});
pi.on("tool_call", (event) => {
recorder.toolCall((event as any).toolCallId, (event as any).toolName);
});
pi.on("tool_execution_update", (event) => {
recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName);
});
pi.on("tool_result", (event) => {
recorder.toolResult((event as any).toolCallId, (event as any).toolName);
});
pi.on("tool_execution_end", (event) => {
recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName);
});
pi.on("session_shutdown", (event) => {
recorder.sessionShutdown((event as any).reason);
});
// Toggle expand/collapse with Ctrl+Alt+O
pi.registerShortcut("ctrl+alt+o", {
description: "Toggle subagent tools widget",
handler: (ctx) => {
expanded = !expanded;
renderWidget(ctx, null);
},
});
pi.registerTool({
name: "ask_question",
label: "ask_question",
description:
"Ask the orchestrator (the parent agent that spawned you) a single question and pause until they reply. " +
"Use this when requirements are ambiguous, a decision would materially affect your work, you're blocked, " +
"or you need information or confirmation only the orchestrator has. Prefer asking over guessing. " +
"Your session stays open while you wait — the answer arrives as your next message, then you continue. " +
"Ask exactly one question per call; make separate calls for unrelated questions.",
promptSnippet:
"Use this tool to ask the orchestrator one clarifying, missing-requirement, preference, or decision question before continuing — instead of guessing.",
promptGuidelines: [
"Ask exactly one question per tool call.",
"If you need answers to multiple things, make separate ask_question calls instead of bundling them.",
"Prefer this tool over guessing when requirements, preferences, or implementation choices are unclear.",
"Use it when multiple valid paths exist and the right one depends on the orchestrator's intent.",
"Give enough context in the question that the orchestrator can answer without re-reading your whole task.",
"After asking, stop and wait — the reply will arrive as your next message.",
],
parameters: Type.Object({
question: Type.String({
description:
"The single freeform question to ask the orchestrator. Include enough context to answer it directly.",
}),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const sessionFile = process.env.PI_SUBAGENT_SESSION;
if (!sessionFile) {
throw new Error(
"ask_question is only available in subagent contexts. " +
"PI_SUBAGENT_SESSION environment variable is not set.",
);
}
// Keep the session open: suppress auto-exit for this turn and park in the
// "waiting" phase. The parent's watcher picks up the `.ask` signal and
// notifies the orchestrator, who replies via subagent_message.
awaitingAnswer = true;
recorder.askQuestion();
const askData = {
name: process.env.PI_SUBAGENT_NAME ?? "subagent",
agent: process.env.PI_SUBAGENT_AGENT ?? "",
question: params.question,
};
writeFileSync(`${sessionFile}.ask`, JSON.stringify(askData));
return {
content: [
{
type: "text",
text:
"Question sent to the orchestrator. Stop here and wait — do not continue working or " +
"assume an answer. Their reply will arrive as your next message.",
},
],
details: { question: params.question },
};
},
renderCall(args, theme) {
const text =
theme.fg("toolTitle", theme.bold("ask_question ")) +
theme.fg("muted", String((args as any).question ?? ""));
return new Text(text, 0, 0);
},
});
}
@@ -0,0 +1,224 @@
/**
* Backend-neutral terminal surface facade.
*
* Herdr is selected when Pi is running in a Herdr-managed pane, Orca when Pi
* is running in an Orca-managed terminal. tmux remains the fallback when Pi is
* 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 { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import * as herdr from "./herdr.ts";
import * as orca from "./orca.ts";
import * as tmux from "./tmux.ts";
export type MuxBackend = "herdr" | "orca" | "tmux";
type Direction = "left" | "right" | "up" | "down";
type SurfaceBackend = {
createSurface(name: string): string;
createSurfaceSplit(name: string, direction: Direction, fromSurface?: string): string;
sendCommand(surface: string, command: string): void;
readScreen(surface: string, lines?: number): string;
readScreenAsync(surface: string, lines?: number): Promise<string>;
closeSurface(surface: string): void;
};
export function selectMuxBackend(availability: {
herdr: boolean;
orca: boolean;
tmux: boolean;
}): MuxBackend | null {
if (availability.herdr) return "herdr";
if (availability.orca) return "orca";
if (availability.tmux) return "tmux";
return null;
}
export function getMuxBackend(): MuxBackend | null {
return selectMuxBackend({
herdr: herdr.isAvailable(),
orca: orca.isAvailable(),
tmux: tmux.isAvailable(),
});
}
function getBackend(): SurfaceBackend {
const backend = getMuxBackend();
if (backend === "herdr") return herdr;
if (backend === "orca") return orca;
if (backend === "tmux") return tmux;
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
}
export function isMuxAvailable(): boolean {
return getMuxBackend() !== null;
}
export function muxSetupHint(): string {
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();
return "Start pi inside Herdr, Orca, or tmux.";
}
export function createSurface(name: string): string {
return getBackend().createSurface(name);
}
export function createSurfaceSplit(
name: string,
direction: Direction,
fromSurface?: string,
): string {
return getBackend().createSurfaceSplit(name, direction, fromSurface);
}
export function sendCommand(surface: string, command: string): void {
getBackend().sendCommand(surface, command);
}
export function readScreen(surface: string, lines = 50): string {
return getBackend().readScreen(surface, lines);
}
export function readScreenAsync(surface: string, lines = 50): Promise<string> {
return getBackend().readScreenAsync(surface, lines);
}
export function closeSurface(surface: string): void {
getBackend().closeSurface(surface);
}
export function shellEscape(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
export function wrapWithCompletionFile(command: string, completionFile: string): string {
return (
`${command}; exit_code=$?; ` +
`printf '%s\\n' "$exit_code" > ${shellEscape(completionFile)}; ` +
`echo "__SUBAGENT_DONE_\${exit_code}__"`
);
}
export function sendLongCommand(
surface: string,
command: string,
options?: { scriptPath?: string; scriptPreamble?: string },
): string {
const scriptPath =
options?.scriptPath ??
join(
tmpdir(),
"pi-subagent-scripts",
`cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
);
mkdirSync(dirname(scriptPath), { recursive: true });
const scriptParts = ["#!/bin/bash"];
if (options?.scriptPreamble) scriptParts.push(options.scriptPreamble.trimEnd());
scriptParts.push(command);
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
return scriptPath;
}
export interface PollResult {
reason: "done" | "sentinel" | "error";
exitCode: number;
errorMessage?: string;
}
function interpretExitSidecar(data: unknown): PollResult {
const payload = data as { type?: unknown; errorMessage?: unknown } | null;
if (payload?.type === "error") {
const errorMessage =
typeof payload.errorMessage === "string" && payload.errorMessage.trim() !== ""
? payload.errorMessage
: "Subagent exited with stopReason=error (no errorMessage in sidecar).";
return { reason: "error", exitCode: 1, errorMessage };
}
return { reason: "done", exitCode: 0 };
}
function readCompletionFile(path: string): PollResult | null {
if (!existsSync(path)) return null;
const raw = readFileSync(path, "utf8").trim();
const exitCode = Number.parseInt(raw, 10);
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(exitCode)) return null;
rmSync(path, { force: true });
return { reason: "sentinel", exitCode };
}
export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile };
export async function pollForExit(
surface: string,
signal: AbortSignal,
options: {
interval: number;
sessionFile?: string;
sentinelFile?: string;
completionFile?: string;
onTick?: (elapsed: number) => void;
},
): Promise<PollResult> {
const start = Date.now();
for (;;) {
if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish");
if (options.sessionFile) {
try {
const exitFile = `${options.sessionFile}.exit`;
if (existsSync(exitFile)) {
const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
rmSync(exitFile, { force: true });
return result;
}
} catch {}
}
if (options.sentinelFile) {
try {
if (existsSync(options.sentinelFile)) {
return { reason: "sentinel", exitCode: 0 };
}
} catch {}
}
if (options.completionFile) {
try {
const result = readCompletionFile(options.completionFile);
if (result) return result;
} catch {}
}
try {
const screen = await readScreenAsync(surface, 5);
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
} catch {
// Surface reads are best-effort. Filesystem completion remains authoritative.
}
const elapsed = Math.floor((Date.now() - start) / 1000);
options.onTick?.(elapsed);
await new Promise<void>((resolve, reject) => {
if (signal.aborted) return reject(new Error("Aborted"));
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, options.interval);
function onAbort() {
clearTimeout(timer);
reject(new Error("Aborted"));
}
signal.addEventListener("abort", onAbort, { once: true });
});
}
}
@@ -0,0 +1,119 @@
/**
* tmux surface backend.
*
* This module only implements tmux-specific terminal operations. Backend
* selection, command scripts, and exit polling live in surface.ts.
*/
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const commandAvailability = new Map<string, boolean>();
function hasCommand(command: string): boolean {
const cached = commandAvailability.get(command);
if (cached !== undefined) return cached;
let available = false;
try {
execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
available = true;
} catch {
available = false;
}
commandAvailability.set(command, available);
return available;
}
export function isAvailable(): boolean {
return !!process.env.TMUX && hasCommand("tmux");
}
export function setupHint(): string {
return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
}
function requireTmux(): void {
if (!isAvailable()) {
throw new Error(`tmux is required for this surface. ${setupHint()}`);
}
}
/** tmux layout used to keep sibling panes evenly sized. */
const SUBAGENT_TMUX_LAYOUT = "even-horizontal";
let rebalanceTimer: ReturnType<typeof setTimeout> | null = null;
function rebalanceSurfaces(hintPane?: string): void {
const target = process.env.TMUX_PANE ?? hintPane;
if (!target) return;
if (rebalanceTimer) clearTimeout(rebalanceTimer);
rebalanceTimer = setTimeout(() => {
rebalanceTimer = null;
try {
execFileSync("tmux", ["select-layout", "-t", target, SUBAGENT_TMUX_LAYOUT], {
encoding: "utf8",
});
} catch {
// Pane/window may be gone; balancing is best-effort.
}
}, 120);
}
export function createSurface(name: string): string {
return createSurfaceSplit(name, "right", process.env.TMUX_PANE);
}
export function createSurfaceSplit(
name: string,
direction: "left" | "right" | "up" | "down",
fromSurface?: string,
): string {
void name;
requireTmux();
const args = ["split-window", "-d"];
args.push(direction === "left" || direction === "right" ? "-h" : "-v");
if (direction === "left" || direction === "up") args.push("-b");
if (fromSurface) args.push("-t", fromSurface);
args.push("-P", "-F", "#{pane_id}");
const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
if (!pane.startsWith("%")) {
throw new Error(`Unexpected tmux split-window output: ${pane}`);
}
rebalanceSurfaces(pane);
return pane;
}
export function sendCommand(surface: string, command: string): void {
requireTmux();
execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
}
export function readScreen(surface: string, lines = 50): string {
requireTmux();
return execFileSync(
"tmux",
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
{ encoding: "utf8" },
);
}
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
requireTmux();
const { stdout } = await execFileAsync(
"tmux",
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
{ encoding: "utf8" },
);
return stdout;
}
export function closeSurface(surface: string): void {
requireTmux();
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
rebalanceSurfaces();
}
@@ -0,0 +1,63 @@
/**
* Safe bash extension for the worker subagent.
* Wraps the built-in bash tool with dangerous command blocking.
*
* Loaded into a child pi process via `--extension` when an agent's `tools`
* frontmatter lists `safe_bash`. See CUSTOM_TOOL_EXTENSIONS in ../index.ts.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createBashTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const DANGEROUS_PATTERNS = [
/\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
/\bsudo\b/,
/\bmkfs\b/,
/\bdd\s+if=/,
/:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
/>\s*\/dev\/[sh]d[a-z]/,
/\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
/\bchown\s+(-[a-zA-Z]+\s+)?root/,
/\bcurl\s.*\|\s*(ba)?sh/,
/\bwget\s.*\|\s*(ba)?sh/,
/\bshutdown\b/,
/\breboot\b/,
/\binit\s+0\b/,
/\bkill\s+-9\s+1\b/,
/\bkillall\b/,
];
function isDangerous(command: string): string | null {
const normalized = command.replace(/\\\n/g, " ");
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(normalized)) {
return `Command blocked by safe_bash: matches dangerous pattern ${pattern}`;
}
}
return null;
}
export default function (pi: ExtensionAPI) {
const bashTool = createBashTool(process.cwd());
pi.registerTool({
name: "safe_bash",
label: "Safe Bash",
description:
"Execute a bash command. Blocks dangerous commands (rm -rf /, sudo, mkfs, etc.).",
parameters: Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(
Type.Number({ description: "Timeout in seconds (optional)" }),
),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const danger = isDangerous(params.command);
if (danger) {
throw new Error(danger);
}
return bashTool.execute(toolCallId, params, signal, onUpdate);
},
});
}
@@ -0,0 +1,13 @@
---
name: test-echo
description: Integration test agent — completes simple file-writing tasks
model: anthropic/claude-haiku-4-5
tools: read, bash, write, edit
spawning: false
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.
@@ -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.
@@ -0,0 +1,11 @@
---
name: test-ping
description: Integration test agent — calls caller_ping instead of completing task
model: anthropic/claude-haiku-4-5
tools: read, bash
spawning: false
disable-model-invocation: true
---
You are a test agent. When given ANY task, you must call the caller_ping tool with the message set to "PING: " followed by the task text you received.
Do NOT complete the task yourself. Do NOT use any other tools. ONLY call caller_ping.
@@ -0,0 +1,402 @@
/**
* Integration test harness for pi-interactive-subagents.
*
* Provides utilities to:
* - Detect the active terminal-surface backend
* - Create isolated test environments with test agent definitions
* - Start real pi sessions in Herdr tabs or tmux panes
* - Poll for file creation and screen output
* - Clean up panes and temp files after tests
*/
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
cpSync,
readdirSync,
rmSync,
existsSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import {
getMuxBackend,
createSurface,
createSurfaceSplit,
sendCommand,
sendLongCommand,
readScreen,
readScreenAsync,
closeSurface,
pollForExit,
shellEscape,
wrapWithCompletionFile,
type MuxBackend,
} from "../../pi-extension/subagents/surface.ts";
// Re-export backend-neutral surface primitives for tests
export {
createSurface,
createSurfaceSplit,
sendCommand,
sendLongCommand,
readScreen,
readScreenAsync,
closeSurface,
pollForExit,
shellEscape,
wrapWithCompletionFile,
};
// ── Paths ──
const HARNESS_DIR = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(HARNESS_DIR, "../..");
const TEST_AGENTS_SRC = join(HARNESS_DIR, "agents");
/**
* Absolute path to the extension source in the working tree.
*
* Integration tests must exercise the code on the current branch — NOT the
* version installed as a pi-package under `~/.pi/agent/git/...` or the project
* mirror under `.pi/git/...`, which stays pinned to the last released tag.
*
* We force-load this file via `pi -ne -e <path>` in startPi() below so local
* edits are always the code under test, regardless of what pi-packages are
* installed on the host.
*/
const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.ts");
// ── Configuration ──
/**
* Model used for integration tests. Override with PI_TEST_MODEL env var.
*
* The default must be a provider that exists under `pi -ne`: startPi() launches
* with extensions disabled, so extension-registered providers (and therefore
* the parent session's active model) may not resolve in the child.
*/
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. */
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
// ── Backend detection ──
/** Return the one backend selected automatically for this process. */
export function getAvailableBackends(): MuxBackend[] {
const backend = getMuxBackend();
return backend ? [backend] : [];
}
function parseHerdrJson(command: string, args: string[]): any {
const output = execFileSync(command, args, { encoding: "utf8" });
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 {
if (getMuxBackend() === "orca") {
parseOrcaJson(["terminal", "switch", "--terminal", surface, "--json"]);
return;
}
if (getMuxBackend() === "herdr") {
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
const tabId = pane.result?.pane?.tab_id;
if (!tabId) throw new Error(`Herdr pane ${surface} has no tab id`);
execFileSync("herdr", ["tab", "focus", tabId], { encoding: "utf8" });
return;
}
execFileSync("tmux", ["select-pane", "-t", surface], { encoding: "utf8" });
}
export function getFocusedSurface(): string | null {
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") {
const workspace = process.env.HERDR_WORKSPACE_ID;
if (!workspace) return null;
const response = parseHerdrJson("herdr", ["pane", "list", "--workspace", workspace]);
return response.result?.panes?.find((pane: any) => pane.focused)?.pane_id ?? null;
}
const panes = execFileSync("tmux", ["list-panes", "-F", "#{pane_id} #{pane_active}"], {
encoding: "utf8",
});
const activeLine = panes.split("\n").find((line) => line.endsWith(" 1"));
return activeLine?.split(" ")[0] ?? null;
} catch {
return null;
}
}
export async function waitForFocusedSurface(
surface: string,
timeout: number = PI_TIMEOUT,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (getFocusedSurface() === surface) return;
await sleep(200);
}
throw new Error(
`Timeout (${timeout}ms) waiting for focused terminal surface ${surface}; ` +
`current focus is ${getFocusedSurface() ?? "unknown"}`,
);
}
// ── Test environment ──
export interface TestEnv {
/** Temp directory serving as the test project root */
dir: string;
/** Panes created during the test (cleaned up automatically) */
surfaces: string[];
/** Temp files to clean up */
tempFiles: string[];
}
/**
* Create an isolated test environment with test agent definitions.
* The temp dir has `.pi/agents/` containing copies of all test agents.
*/
export function createTestEnv(): TestEnv {
const dir = mkdtempSync(join(tmpdir(), "pi-integ-"));
const agentsDir = join(dir, ".pi", "agents");
mkdirSync(agentsDir, { recursive: true });
// Copy test agent definitions into the project-local agents dir
if (existsSync(TEST_AGENTS_SRC)) {
for (const file of readdirSync(TEST_AGENTS_SRC)) {
if (file.endsWith(".md")) {
const destination = join(agentsDir, file);
cpSync(join(TEST_AGENTS_SRC, file), destination);
const definition = readFileSync(destination, "utf8").replace(
/^model:\s*.+$/m,
`model: ${TEST_MODEL}`,
);
writeFileSync(destination, definition);
}
}
}
return { dir, surfaces: [], tempFiles: [] };
}
/**
* Clean up all resources created during the test.
*/
export function cleanupTestEnv(env: TestEnv): void {
for (const surface of env.surfaces) {
try {
closeSurface(surface);
} catch {}
}
for (const file of env.tempFiles) {
try {
unlinkSync(file);
} catch {}
}
try {
rmSync(env.dir, { recursive: true, force: true });
} catch {}
}
/**
* Create a surface and register it for automatic cleanup.
*/
export function createTrackedSurface(env: TestEnv, name: string): string {
const surface = createSurface(name);
env.surfaces.push(surface);
return surface;
}
export function createTrackedSurfaceSplit(
env: TestEnv,
name: string,
direction: "left" | "right" | "up" | "down",
fromSurface?: string,
): string {
const surface = createSurfaceSplit(name, direction, fromSurface);
env.surfaces.push(surface);
return surface;
}
/**
* Remove a surface from tracking (after manual close).
*/
export function untrackSurface(env: TestEnv, surface: string): void {
env.surfaces = env.surfaces.filter((s) => s !== surface);
}
// ── Pi session management ──
/**
* Start a pi session in a mux surface with the subagents extension loaded.
* Returns immediately — the pi process runs asynchronously in the surface.
*
* The command ends with a sentinel so we can detect when pi exits:
* `pi ...; echo '__TEST_DONE_'$?'__'`
*/
export function startPi(
surface: string,
testDir: string,
task: string,
opts?: { model?: string; extraArgs?: string },
): void {
const model = opts?.model ?? TEST_MODEL;
const extra = opts?.extraArgs ?? "";
// Force pi to load the working-tree extension (not an installed pi-package
// snapshot). `-ne` disables extension auto-discovery, `-e <path>` loads the
// current branch's source directly. Without this, the tests silently run
// against whatever version is checked out under `~/.pi/agent/git/...`.
const cmd = [
`cd ${shellEscape(testDir)} &&`,
`pi`,
`-ne`,
`-e ${shellEscape(EXTENSION_SOURCE)}`,
`--model ${shellEscape(model)}`,
extra,
shellEscape(task),
]
.filter(Boolean)
.join(" ");
// Herdr does not retain terminal output for tabs that have never been shown.
// Focus the test runner surface so waitForScreen can observe its transcript.
focusSurface(surface);
sendLongCommand(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, {
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
});
}
// ── Polling helpers ──
/**
* Poll until a regex pattern appears in the surface's screen output.
* Throws on timeout with the last screen contents for debugging.
*/
export async function waitForScreen(
surface: string,
pattern: RegExp,
timeout: number = PI_TIMEOUT,
lines: number = 200,
): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const screen = await readScreenAsync(surface, lines);
if (pattern.test(screen)) return screen;
} catch {}
await sleep(2000);
}
let finalScreen = "";
try {
finalScreen = readScreen(surface, lines);
} catch {}
throw new Error(
`Timeout (${timeout}ms) waiting for pattern ${pattern}.\nLast screen:\n${finalScreen.slice(-1000)}`,
);
}
/**
* Poll until a file exists and optionally matches a content pattern.
* Returns the file content on success.
*/
export async function waitForFile(
path: string,
timeout: number = PI_TIMEOUT,
contentPattern?: RegExp,
): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (existsSync(path)) {
const content = readFileSync(path, "utf8");
if (!contentPattern || contentPattern.test(content)) return content;
}
await sleep(2000);
}
throw new Error(
`Timeout (${timeout}ms) waiting for file: ${path}` +
(contentPattern ? ` matching ${contentPattern}` : ""),
);
}
/**
* Wait for the pi process in a surface to exit (sentinel detection).
* Returns the exit code.
*/
export async function waitForPiExit(
surface: string,
timeout: number = PI_TIMEOUT,
): Promise<number> {
const screen = await waitForScreen(surface, /__TEST_DONE_(\d+)__/, timeout);
const match = screen.match(/__TEST_DONE_(\d+)__/);
return match ? parseInt(match[1], 10) : -1;
}
// ── Utilities ──
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function uniqueId(): string {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
/**
* Register a temp file for cleanup.
*/
export function trackTempFile(env: TestEnv, path: string): void {
env.tempFiles.push(path);
}
@@ -0,0 +1,378 @@
/**
* Integration tests for the full subagent lifecycle.
*
* 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
* tool, and verifies the outcome via marker files and screen output.
*
* Costs: ~$0.01-0.05 per test run.
* Duration: ~30-90s per test.
*
* Run inside Herdr, inside an Orca terminal, or inside tmux:
* tmux new 'npm run test:integration'
*
* Configuration:
* 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)
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import {
getAvailableBackends,
createTestEnv,
cleanupTestEnv,
createTrackedSurface,
startPi,
waitForScreen,
waitForFile,
sleep,
uniqueId,
trackTempFile,
readScreen,
sendCommand,
PI_TIMEOUT,
type TestEnv,
} from "./harness.ts";
const backends = getAvailableBackends();
if (backends.length === 0) {
console.log("⚠️ Herdr/Orca/tmux is not available — skipping subagent lifecycle integration tests");
console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
}
for (const backend of backends) {
describe(`subagent-lifecycle [${backend}]`, { timeout: PI_TIMEOUT * 3 }, () => {
let env: TestEnv;
before(() => {
env = createTestEnv();
});
after(() => {
cleanupTestEnv(env);
});
// ── Basic spawn + completion ──
it("spawns a subagent that writes a file and verifies the session", async () => {
const id = uniqueId();
const markerFile = `/tmp/pi-integ-echo-${id}.txt`;
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `echo-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Echo-${id}"`,
` agent: "test-echo"`,
` task: "Run this bash command: echo 'PASS_${id}' > '${markerFile}'"`,
`Do not do anything else. Just call the subagent tool once.`,
`After you receive the subagent result, say INTEGRATION_COMPLETE.`,
].join("\n");
startPi(surface, env.dir, task);
// Verify: subagent created the marker file
const content = await waitForFile(markerFile, PI_TIMEOUT, /PASS/);
assert.ok(
content.includes(`PASS_${id}`),
`Marker file should contain PASS_${id}. Got: ${content.trim()}`,
);
// Verify: outer pi received the subagent result
const screen = await waitForScreen(
surface,
/INTEGRATION_COMPLETE|completed|Sub-agent.*"Echo/i,
PI_TIMEOUT,
);
// Verify: session file was created (shown in steer result)
const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/);
if (sessionMatch) {
const sessionFile = sessionMatch[1];
assert.ok(existsSync(sessionFile), `Subagent session file should exist: ${sessionFile}`);
const lines = readFileSync(sessionFile, "utf8").trim().split("\n");
assert.ok(lines.length >= 2, `Session should have ≥2 entries, got ${lines.length}`);
const header = JSON.parse(lines[0]);
assert.equal(header.type, "session", "First entry should be session header");
assert.ok(header.id, "Session header should have an id");
}
});
// ── Reload preservation ──
it("keeps a running subagent and fresh tool wrappers across parent extension reload", async () => {
const id = uniqueId();
const startedFile = `/tmp/pi-integ-reload-start-${id}.txt`;
const completedFile = `/tmp/pi-integ-reload-done-${id}.txt`;
const followupFile = `/tmp/pi-integ-reload-followup-${id}.txt`;
trackTempFile(env, startedFile);
trackTempFile(env, completedFile);
trackTempFile(env, followupFile);
const surface = createTrackedSurface(env, `reload-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool once with these EXACT parameters:`,
` name: "Reload-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'START_${id}' > '${startedFile}'; sleep 10; echo 'DONE_${id}' > '${completedFile}'"`,
`Do not run the command yourself.`,
`After that subagent result arrives, call the subagent tool once more with these EXACT parameters:`,
` name: "Reload-followup-${id}"`,
` agent: "test-echo"`,
` task: "Write 'FOLLOWUP_${id}' to '${followupFile}'"`,
`After the second subagent result arrives, say RELOAD_SURVIVED_${id}.`,
].join("\n");
startPi(surface, env.dir, task);
await waitForFile(startedFile, PI_TIMEOUT, /START_/);
sendCommand(surface, "/reload");
const completed = await waitForFile(completedFile, PI_TIMEOUT, /DONE_/);
assert.ok(completed.includes(`DONE_${id}`), "child should finish after parent reload");
const followup = await waitForFile(followupFile, PI_TIMEOUT, /FOLLOWUP_/);
assert.ok(
followup.includes(`FOLLOWUP_${id}`),
"the result-triggered turn should spawn through the fresh tool wrapper",
);
const resultMarker = `RELOAD_SURVIVED_${id}`;
const responseLine = new RegExp(`^ ${resultMarker}\\s*$`, "m");
const screen = await waitForScreen(surface, responseLine, PI_TIMEOUT, 300);
assert.equal(
screen.match(new RegExp(`^ ${resultMarker}\\s*$`, "gm"))?.length,
1,
"the fresh runtime should receive the completion exactly once",
);
assert.doesNotMatch(screen, /Subagent cancelled|ctx is stale after session replacement or reload/i);
});
// ── In-progress activity snapshots ──
it("keeps a long active tool call from surfacing false stalled status", async () => {
const id = uniqueId();
const startFile = `/tmp/pi-integ-status-start-${id}.txt`;
const markerFile = `/tmp/pi-integ-status-${id}.txt`;
trackTempFile(env, startFile);
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `status-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Status-${id}"`,
` agent: "test-echo"`,
` task: "Run this bash command: echo 'START_${id}' > '${startFile}'; sleep 90; echo 'STATUS_${id}' > '${markerFile}'"`,
`Do not do anything else. Just call the subagent tool once.`,
`After you receive the subagent result, say STATUS_TEST_DONE.`,
].join("\n");
startPi(surface, env.dir, task);
const activeScreen = await waitForScreen(surface, /active[\s\S]*bash|bash[\s\S]*active/i, PI_TIMEOUT, 300);
assert.doesNotMatch(activeScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i);
await waitForFile(startFile, PI_TIMEOUT, /START_/);
assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the long sleep");
await sleep(65_000);
assert.equal(existsSync(markerFile), false, "Completion marker should not exist before the watchdog assertion");
const watchdogScreen = readScreen(surface, 300);
assert.doesNotMatch(watchdogScreen, /Subagent status[\s\S]*stalled|stalled[\s\S]*Subagent status/i);
const content = await waitForFile(markerFile, PI_TIMEOUT, /STATUS_/);
assert.ok(content.includes(`STATUS_${id}`), `Marker file should contain STATUS_${id}`);
const completionScreen = await waitForScreen(
surface,
/STATUS_TEST_DONE|completed|Sub-agent.*"Status-/i,
PI_TIMEOUT,
300,
);
assert.ok(/STATUS_TEST_DONE|completed/i.test(completionScreen));
});
// ── Parallel subagent spawn ──
it("spawns two subagents in parallel and both complete", async () => {
const id = uniqueId();
const fileA = `/tmp/pi-integ-para-${id}-a.txt`;
const fileB = `/tmp/pi-integ-para-${id}-b.txt`;
trackTempFile(env, fileA);
trackTempFile(env, fileB);
const surface = createTrackedSurface(env, `parallel-${id}`);
await sleep(1000);
const task = [
`You must call the subagent tool TWICE. Make both calls before waiting for results.`,
``,
`First call:`,
` name: "ParaA-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'DONE_A_${id}' > '${fileA}'"`,
``,
`Second call:`,
` name: "ParaB-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'DONE_B_${id}' > '${fileB}'"`,
``,
`Call both subagent tools NOW, do not wait between them.`,
].join("\n");
startPi(surface, env.dir, task);
// Both marker files should appear
const [contentA, contentB] = await Promise.all([
waitForFile(fileA, PI_TIMEOUT, /DONE_A/),
waitForFile(fileB, PI_TIMEOUT, /DONE_B/),
]);
assert.ok(contentA.includes(`DONE_A_${id}`), `File A should contain marker`);
assert.ok(contentB.includes(`DONE_B_${id}`), `File B should contain marker`);
});
// ── Fork mode ──
it("fork mode creates a child session linked to the parent", async () => {
const id = uniqueId();
const markerFile = `/tmp/pi-integ-fork-${id}.txt`;
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `fork-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Fork-${id}"`,
` agent: "test-fork"`,
` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
`Do not do anything else. Just call the subagent tool once.`,
`After you receive the result, say FORK_COMPLETE.`,
].join("\n");
startPi(surface, env.dir, task);
// Verify: forked subagent created the file
const content = await waitForFile(markerFile, PI_TIMEOUT, /FORK_OK/);
assert.ok(content.includes(`FORK_OK_${id}`), `Fork marker file should exist with content`);
// Wait for the outer pi to show the result
const screen = await waitForScreen(
surface,
/FORK_COMPLETE|completed|Sub-agent.*"Fork/i,
PI_TIMEOUT,
);
// Verify: the forked session has a parent link
const sessionMatch = screen.match(/Session:\s*(\S+\.jsonl)/);
if (sessionMatch) {
const sessionFile = sessionMatch[1];
assert.ok(existsSync(sessionFile), `Fork session file should exist: ${sessionFile}`);
const entries = readFileSync(sessionFile, "utf8")
.trim()
.split("\n")
.map((l) => JSON.parse(l));
const header = entries[0];
assert.equal(header.type, "session", "First entry should be session header");
assert.ok(header.parentSession, "Fork session should have parentSession field");
// Fork sessions include parent context (model_change entries etc.)
assert.ok(entries.length >= 2, "Fork session should have context entries beyond header");
}
});
// ── caller_ping ──
it("subagent caller_ping sends notification back to the parent", async () => {
const id = uniqueId();
const surface = createTrackedSurface(env, `ping-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these EXACT parameters:`,
` name: "Ping-${id}"`,
` agent: "test-ping"`,
` task: "PING_TEST_${id}"`,
`Just call the subagent tool once. Do not do anything else before calling it.`,
].join("\n");
startPi(surface, env.dir, task);
// The test-ping agent calls caller_ping, which steers a "needs help" message
// back to the outer pi. Look for it on screen.
const screen = await waitForScreen(
surface,
/needs help|PING|caller_ping|ping/i,
PI_TIMEOUT,
);
assert.ok(
/needs help|PING/i.test(screen),
`Screen should show ping notification. Got:\n${screen.slice(-800)}`,
);
});
// ── Agent discovery ──
it("subagent discovers project-local test agents", async () => {
const id = uniqueId();
const markerFile = `/tmp/pi-integ-discovery-${id}.txt`;
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `discovery-${id}`);
await sleep(1000);
// Use subagents_list to verify test agents are discoverable,
// then spawn one to prove it works end-to-end.
const task = [
`First, call the subagents_list tool to see available agents.`,
`Then call the subagent tool:`,
` name: "Disco-${id}"`,
` agent: "test-echo"`,
` task: "Run: echo 'DISCO_${id}' > '${markerFile}'"`,
`After you receive the subagent result, say DISCOVERY_DONE.`,
].join("\n");
startPi(surface, env.dir, task);
// The test-echo agent (discovered from project .pi/agents/) should work
const content = await waitForFile(markerFile, PI_TIMEOUT, /DISCO/);
assert.ok(content.includes(`DISCO_${id}`), `Discovery test marker should exist`);
});
// ── Subagent with custom system prompt ──
it("passes systemPrompt to subagent", async () => {
const id = uniqueId();
const markerFile = `/tmp/pi-integ-sysprompt-${id}.txt`;
trackTempFile(env, markerFile);
const surface = createTrackedSurface(env, `sysprompt-${id}`);
await sleep(1000);
const task = [
`Call the subagent tool with these parameters:`,
` name: "SysP-${id}"`,
` agent: "test-echo"`,
` systemPrompt: "Always start your response with CUSTOM_PROMPT_ACTIVE."`,
` task: "Write 'SYSPROMPT_${id}' to ${markerFile} using bash: echo 'SYSPROMPT_${id}' > '${markerFile}'"`,
`After the subagent completes, say SYSPROMPT_TEST_DONE.`,
].join("\n");
startPi(surface, env.dir, task);
const content = await waitForFile(markerFile, PI_TIMEOUT, /SYSPROMPT/);
assert.ok(content.includes(`SYSPROMPT_${id}`), `System prompt test marker should exist`);
});
});
}
@@ -0,0 +1,226 @@
/**
* Integration tests for the selected terminal-surface backend.
*
* These tests exercise real Herdr, Orca, or tmux operations: creating surfaces,
* sending commands, reading screen output, and closing panes.
* No LLM calls — fast and free.
*
* Run inside Herdr, inside an Orca terminal, or inside tmux:
* tmux new 'npm run test:integration'
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { unlinkSync } from "node:fs";
import {
getAvailableBackends,
createTestEnv,
cleanupTestEnv,
createTrackedSurface,
focusSurface,
getFocusedSurface,
waitForFocusedSurface,
untrackSurface,
sendCommand,
sendLongCommand,
readScreen,
readScreenAsync,
closeSurface,
pollForExit,
wrapWithCompletionFile,
sleep,
uniqueId,
trackTempFile,
waitForFile,
waitForScreen,
type TestEnv,
} from "./harness.ts";
const backends = getAvailableBackends();
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
if (backends.length === 0) {
console.log("⚠️ Herdr/Orca/tmux is not available — skipping surface integration tests");
console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
}
for (const backend of backends) {
describe(`surface [${backend}]`, { timeout: 60_000 }, () => {
let env: TestEnv;
before(() => {
env = createTestEnv();
});
after(() => {
cleanupTestEnv(env);
});
it("keeps focus on the active surface while creating and targeting subagent surfaces", async () => {
const anchor = createTrackedSurface(env, "focus-anchor");
await sleep(1000);
focusSurface(anchor);
await waitForFocusedSurface(anchor, 10_000);
const childA = createTrackedSurface(env, "focus-child-a");
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
assert.equal(getFocusedSurface(), anchor);
const childB = createTrackedSurface(env, "focus-child-b");
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
assert.equal(getFocusedSurface(), anchor);
const markerA = uniqueId();
const markerB = uniqueId();
focusSurface(childA);
sendCommand(childA, `echo "FOCUS_A_${markerA}"`);
await waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50);
focusSurface(childB);
sendCommand(childB, `echo "FOCUS_B_${markerB}"`);
await waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50);
focusSurface(anchor);
await waitForFocusedSurface(anchor, 10_000);
});
it("polls file-based completion and closes an unfocused surface", async () => {
const focusedBefore = getFocusedSurface();
const surface = createTrackedSurface(env, "background-completion");
await sleep(1000);
const completionFile = `${env.dir}/background.exit-code`;
sendLongCommand(surface, wrapWithCompletionFile("false", completionFile));
const result = await pollForExit(surface, new AbortController().signal, {
interval: 100,
completionFile,
});
assert.deepEqual(result, { reason: "sentinel", exitCode: 1 });
assert.equal(getFocusedSurface(), focusedBefore);
closeSurface(surface);
untrackSurface(env, surface);
assert.throws(() => readScreen(surface, 5));
});
it("creates a surface, sends a command, reads output, and closes it", async () => {
const surface = createTrackedSurface(env, "echo-test");
await sleep(1000);
focusSurface(surface);
const marker = uniqueId();
sendCommand(surface, `echo "MARKER_${marker}"`);
await sleep(1500);
const screen = readScreen(surface, 50);
assert.ok(
screen.includes(`MARKER_${marker}`),
`Expected screen to contain MARKER_${marker}. Got:\n${screen}`,
);
closeSurface(surface);
untrackSurface(env, surface);
});
it("preserves shell special characters in echo output", async () => {
const surface = createTrackedSurface(env, "escape-test");
await sleep(1000);
focusSurface(surface);
const marker = uniqueId();
// Single-quoted string — $ and " are literal inside single quotes
sendCommand(surface, `echo 'SPEC_${marker}_$HOME_"quotes"_done'`);
await sleep(1500);
const screen = readScreen(surface, 50);
assert.ok(
screen.includes(`SPEC_${marker}`),
`Expected special-char output. Got:\n${screen}`,
);
// $ should be literal inside single quotes
assert.ok(
screen.includes("$HOME"),
`Expected literal $HOME in output. Got:\n${screen}`,
);
});
it("sends a long command via script file without truncation", async () => {
const surface = createTrackedSurface(env, "long-cmd-test");
await sleep(1000);
focusSurface(surface);
const marker = uniqueId();
const longValue = "X".repeat(500);
const command = `echo "LONG_${marker}_${longValue}_END"`;
sendLongCommand(surface, command);
await sleep(2000);
const screen = readScreen(surface, 50);
assert.ok(
screen.includes(`LONG_${marker}`),
`Expected long command output. Got:\n${screen.slice(0, 300)}...`,
);
assert.ok(
screen.includes("_END"),
`Expected full output (not truncated). Got:\n${screen.slice(-300)}`,
);
});
it("reads screen asynchronously", async () => {
const surface = createTrackedSurface(env, "async-read-test");
await sleep(1000);
focusSurface(surface);
const marker = uniqueId();
sendCommand(surface, `echo "ASYNC_${marker}"`);
await sleep(1500);
const screen = await readScreenAsync(surface, 50);
assert.ok(
screen.includes(`ASYNC_${marker}`),
`Async read should find marker. Got:\n${screen}`,
);
});
it("manages multiple surfaces concurrently", async () => {
const s1 = createTrackedSurface(env, "multi-1");
const s2 = createTrackedSurface(env, "multi-2");
await sleep(1500);
const m1 = uniqueId();
const m2 = uniqueId();
focusSurface(s1);
sendCommand(s1, `echo "S1_${m1}"`);
await sleep(750);
focusSurface(s2);
sendCommand(s2, `echo "S2_${m2}"`);
await sleep(1500);
const screen1 = readScreen(s1, 50);
const screen2 = readScreen(s2, 50);
assert.ok(screen1.includes(`S1_${m1}`), `Surface 1 missing marker. Got:\n${screen1}`);
assert.ok(screen2.includes(`S2_${m2}`), `Surface 2 missing marker. Got:\n${screen2}`);
});
it("writes output to a file and verifies via surface", async () => {
const surface = createTrackedSurface(env, "file-test");
await sleep(1000);
focusSurface(surface);
const marker = uniqueId();
const filePath = `/tmp/pi-surface-test-${marker}.txt`;
sendCommand(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
await waitForScreen(surface, new RegExp(`WRITTEN_${marker}`), 10_000, 50);
const content = await waitForFile(filePath, 10_000, new RegExp(`FILE_${marker}`));
assert.ok(content.includes(`FILE_${marker}`), `File content wrong. Got: ${content}`);
// Clean up
try {
unlinkSync(filePath);
} catch {}
});
});
}
@@ -0,0 +1,163 @@
/**
* Smoke tests for the systemPromptMode feature.
* Tests: frontmatter parsing, identity routing, CLI flag generation.
*/
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
let passed = 0;
let failed = 0;
function assert(condition: boolean, msg: string) {
if (condition) {
console.log(` ✅ ${msg}`);
passed++;
} else {
console.log(` ❌ ${msg}`);
failed++;
}
}
// --- Extracted logic under test ---
function parseFrontmatter(content: string) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
const frontmatter = match[1];
const get = (key: string) => {
const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
return m ? m[1].trim() : undefined;
};
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
const spm = get("system-prompt");
return {
systemPromptMode: spm === "replace" ? "replace" : spm === "append" ? "append" : undefined,
body: body || undefined,
};
}
function simulateRouting(
agentBody: string | undefined,
systemPromptMode: "append" | "replace" | undefined,
paramSystemPrompt: string | undefined,
) {
const identity = agentBody ?? paramSystemPrompt ?? null;
const identityInSystemPrompt = systemPromptMode && identity;
const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
let cliFlag: string | null = null;
if (identityInSystemPrompt && identity) {
cliFlag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
}
return { roleBlock, cliFlag, identityInSystemPrompt: !!identityInSystemPrompt };
}
// --- Fixtures ---
const AGENT_REPLACE = `---
model: anthropic/claude-sonnet-4-20250514
system-prompt: replace
auto-exit: true
---
You are a specialized agent.`;
const AGENT_APPEND = `---
model: anthropic/claude-sonnet-4-20250514
system-prompt: append
---
You are an appended identity.`;
const AGENT_DEFAULT = `---
model: anthropic/claude-sonnet-4-20250514
---
You are a default agent.`;
const AGENT_INVALID = `---
model: anthropic/claude-sonnet-4-20250514
system-prompt: foobar
---
Body here.`;
// --- Test 1: Frontmatter parsing ---
console.log("\n🧪 Frontmatter parsing of system-prompt field");
const r1 = parseFrontmatter(AGENT_REPLACE)!;
assert(r1.systemPromptMode === "replace", "system-prompt: replace → mode is 'replace'");
assert(r1.body === "You are a specialized agent.", "body extracted correctly");
const r2 = parseFrontmatter(AGENT_APPEND)!;
assert(r2.systemPromptMode === "append", "system-prompt: append → mode is 'append'");
const r3 = parseFrontmatter(AGENT_DEFAULT)!;
assert(r3.systemPromptMode === undefined, "no system-prompt field → mode is undefined");
const r4 = parseFrontmatter(AGENT_INVALID)!;
assert(r4.systemPromptMode === undefined, "system-prompt: foobar → mode is undefined (ignored)");
// --- Test 2: Identity routing ---
console.log("\n🧪 Identity routing (system prompt vs user message)");
const s1 = simulateRouting("You are X.", "replace", undefined);
assert(s1.roleBlock === "", "replace mode: roleBlock empty (not in task)");
assert(s1.cliFlag === "--system-prompt", "replace mode: uses --system-prompt flag");
const s2 = simulateRouting("You are X.", "append", undefined);
assert(s2.roleBlock === "", "append mode: roleBlock empty (not in task)");
assert(s2.cliFlag === "--append-system-prompt", "append mode: uses --append-system-prompt flag");
const s3 = simulateRouting("You are X.", undefined, undefined);
assert(s3.roleBlock === "\n\nYou are X.", "no mode: roleBlock contains identity");
assert(s3.cliFlag === null, "no mode: no CLI flag");
const s4 = simulateRouting(undefined, undefined, undefined);
assert(s4.roleBlock === "", "no identity: roleBlock empty");
assert(s4.cliFlag === null, "no identity: no CLI flag");
const s5 = simulateRouting(undefined, "replace", undefined);
assert(s5.roleBlock === "", "mode set but no body: roleBlock empty");
assert(s5.cliFlag === null, "mode set but no body: no CLI flag");
const s6 = simulateRouting(undefined, "replace", "Param identity");
assert(s6.cliFlag === "--system-prompt", "mode + param systemPrompt: uses CLI flag");
assert(s6.roleBlock === "", "mode + param systemPrompt: roleBlock empty");
// --- Test 3: End-to-end with temp agent files ---
console.log("\n🧪 End-to-end with temp agent files");
const tmpDir = mkdtempSync(join(tmpdir(), "pi-test-spm-"));
const agentsDir = join(tmpDir, ".pi", "agents");
mkdirSync(agentsDir, { recursive: true });
writeFileSync(join(agentsDir, "test-replace.md"), AGENT_REPLACE);
writeFileSync(join(agentsDir, "test-append.md"), AGENT_APPEND);
writeFileSync(join(agentsDir, "test-default.md"), AGENT_DEFAULT);
function loadFromDir(name: string) {
const p = join(agentsDir, `${name}.md`);
if (!existsSync(p)) return null;
return parseFrontmatter(readFileSync(p, "utf8"));
}
const t1 = loadFromDir("test-replace")!;
assert(t1.systemPromptMode === "replace", "file test-replace.md → replace mode");
assert(t1.body === "You are a specialized agent.", "file test-replace.md → body correct");
const t2 = loadFromDir("test-append")!;
assert(t2.systemPromptMode === "append", "file test-append.md → append mode");
const t3 = loadFromDir("test-default")!;
assert(t3.systemPromptMode === undefined, "file test-default.md → no mode");
rmSync(tmpDir, { recursive: true });
// --- Summary ---
console.log(`\n${"=".repeat(40)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
console.log("All tests passed! ✅\n");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["pi-extension/**/*.ts", "test/**/*.ts"]
}